diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-18 07:45:37 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-18 07:45:37 +0300 |
| commit | 4ffb22e7f69f1c9c79b095d4e60bad3d97aac55b (patch) | |
| tree | 2dc708cbb95975d34084eb5afd376871caa13e27 /internal | |
| parent | ece7dcfd232b780f5650326c8ac2379ca70387d4 (diff) | |
ik0 replace test seams with dependency injection
Diffstat (limited to 'internal')
43 files changed, 648 insertions, 578 deletions
diff --git a/internal/askcli/command_edit.go b/internal/askcli/command_edit.go index a82d575..e2c2dbf 100644 --- a/internal/askcli/command_edit.go +++ b/internal/askcli/command_edit.go @@ -8,11 +8,11 @@ import ( "codeberg.org/snonux/hexai/internal/editor" ) -// captureFromEditor opens the user's editor on a temporary file pre-filled with +// editorCapture opens the user's editor on a temporary file pre-filled with // the given initial content and returns its trimmed contents after the editor // exits. ctx is forwarded to the editor subprocess so it can be cancelled with -// the surrounding command. It is a variable so tests can stub it. -var captureFromEditor = func(ctx context.Context, initial []byte) (string, error) { +// the surrounding command. +func editorCapture(ctx context.Context, initial []byte) (string, error) { return editor.OpenTempAndEdit(ctx, initial) } @@ -23,7 +23,7 @@ func (d *Dispatcher) handleEdit(ctx context.Context, args []string, stdout, stde if len(args) >= 2 { return d.editTaskDescription(ctx, args[1], stdout, stderr) } - description, err := captureFromEditor(ctx, nil) + description, err := d.capture(ctx, nil) if err != nil { writeInfoError(stderr, err) return 1, nil @@ -44,7 +44,7 @@ func (d *Dispatcher) editTaskDescription(ctx context.Context, selector string, s return code, nil } - description, err := captureFromEditor(ctx, []byte(tasks[0].Description)) + description, err := d.capture(ctx, []byte(tasks[0].Description)) if err != nil { writeInfoError(stderr, err) return 1, nil diff --git a/internal/askcli/command_edit_test.go b/internal/askcli/command_edit_test.go index 4db19c1..91569c8 100644 --- a/internal/askcli/command_edit_test.go +++ b/internal/askcli/command_edit_test.go @@ -9,13 +9,10 @@ import ( "testing" ) -func stubEditorCapture(t *testing.T, content string, err error) { - t.Helper() - old := captureFromEditor - captureFromEditor = func(_ context.Context, initial []byte) (string, error) { +func stubEditorCapture(d *Dispatcher, content string, err error) { + d.capture = func(_ context.Context, initial []byte) (string, error) { return content, err } - t.Cleanup(func() { captureFromEditor = old }) } func TestHandleEdit_Success(t *testing.T) { @@ -26,15 +23,14 @@ func TestHandleEdit_Success(t *testing.T) { {UUID: "existing-uuid", Alias: "0", CreatedAt: now}, }, }) - // editor.OpenTempAndEdit trims content, so mimic that here. - stubEditorCapture(t, "Multi line\ntask description", nil) - var capturedArgs []string d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { capturedArgs = args _, _ = io.WriteString(stdout, "Created task abc-123-def.") return 0, nil }}) + // editor.OpenTempAndEdit trims content, so mimic that here. + stubEditorCapture(d, "Multi line\ntask description", nil) var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"edit"}, nil, &stdout, &stderr) @@ -58,15 +54,8 @@ func TestHandleEdit_ExistingTaskModifiesDescription(t *testing.T) { }, }) - var initialContent []byte - old := captureFromEditor - captureFromEditor = func(_ context.Context, initial []byte) (string, error) { - initialContent = initial - return "updated description", nil - } - t.Cleanup(func() { captureFromEditor = old }) - var capturedArgs []string + var initialContent []byte d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { // First call: export to resolve selector. Subsequent: modify. if len(args) >= 2 && args[1] == "export" { @@ -76,6 +65,10 @@ func TestHandleEdit_ExistingTaskModifiesDescription(t *testing.T) { capturedArgs = args return 0, nil }}) + d.capture = func(_ context.Context, initial []byte) (string, error) { + initialContent = initial + return "updated description", nil + } var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"edit", "0"}, nil, &stdout, &stderr) @@ -97,12 +90,11 @@ func TestHandleEdit_ExistingTaskModifiesDescription(t *testing.T) { } func TestHandleEdit_EmptyContentAborts(t *testing.T) { - stubEditorCapture(t, "", nil) - d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { t.Fatalf("runner should not be called on empty content") return 0, nil }}) + stubEditorCapture(d, "", nil) var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"edit"}, nil, &stdout, &stderr) @@ -115,12 +107,11 @@ func TestHandleEdit_EmptyContentAborts(t *testing.T) { } func TestHandleEdit_EditorError(t *testing.T) { - stubEditorCapture(t, "", errors.New("boom")) - d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { t.Fatalf("runner should not be called when editor fails") return 0, nil }}) + stubEditorCapture(d, "", errors.New("boom")) var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"edit"}, nil, &stdout, &stderr) diff --git a/internal/askcli/command_watch.go b/internal/askcli/command_watch.go index 8d8e35f..0e0a678 100644 --- a/internal/askcli/command_watch.go +++ b/internal/askcli/command_watch.go @@ -30,7 +30,10 @@ func (t realWatchTicker) Stop() { t.ticker.Stop() } -var newWatchTicker = func(interval time.Duration) watchTicker { +// newRealWatchTicker is the default watchTicker factory wired into a +// Dispatcher by NewDispatcher. It is injected as Dispatcher.newTicker so tests +// can substitute a fake ticker instead of mutating package state. +func newRealWatchTicker(interval time.Duration) watchTicker { return realWatchTicker{ticker: time.NewTicker(interval)} } @@ -63,7 +66,7 @@ func (d *Dispatcher) handleWatch(ctx context.Context, args []string, stdout, std return 1, nil } - ticker := newWatchTicker(watchInterval) + ticker := d.newTicker(watchInterval) defer ticker.Stop() var lastOutput []byte diff --git a/internal/askcli/command_watch_test.go b/internal/askcli/command_watch_test.go index abd74ff..7618060 100644 --- a/internal/askcli/command_watch_test.go +++ b/internal/askcli/command_watch_test.go @@ -27,14 +27,11 @@ func (t *fakeWatchTicker) Stop() { func TestHandleWatch_ForwardsNonZeroCode(t *testing.T) { ticks := make(chan time.Time) - oldTicker := newWatchTicker - newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } - t.Cleanup(func() { newWatchTicker = oldTicker }) - ctx := context.Background() d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { return 1, nil }}) + d.newTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } var stdout, stderr bytes.Buffer code, err := d.Dispatch(ctx, []string{"watch", "urgency"}, nil, &stdout, &stderr) @@ -70,10 +67,6 @@ func TestHandleWatch_ForwardsInnerError(t *testing.T) { func TestHandleWatch_DrawsStderrOnNonZero(t *testing.T) { ticks := make(chan time.Time) - oldTicker := newWatchTicker - newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } - t.Cleanup(func() { newWatchTicker = oldTicker }) - ctx, cancel := context.WithCancel(context.Background()) callCount := 0 d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -87,6 +80,7 @@ func TestHandleWatch_DrawsStderrOnNonZero(t *testing.T) { _, _ = io.WriteString(stdout, "[]") return 0, nil }}) + d.newTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } var out bytes.Buffer code, err := d.Dispatch(ctx, []string{"watch", "info"}, nil, &out, &bytes.Buffer{}) @@ -118,14 +112,6 @@ func TestHandleWatch_DefaultsToListAndRedrawsOnChange(t *testing.T) { ticks := make(chan time.Time, 1) ticks <- time.Now() fakeTicker := &fakeWatchTicker{ch: ticks} - oldTicker := newWatchTicker - newWatchTicker = func(interval time.Duration) watchTicker { - if interval != watchInterval { - t.Fatalf("watch interval = %s, want %s", interval, watchInterval) - } - return fakeTicker - } - t.Cleanup(func() { newWatchTicker = oldTicker }) ctx, cancel := context.WithCancel(context.Background()) var calls [][]string @@ -139,6 +125,12 @@ func TestHandleWatch_DefaultsToListAndRedrawsOnChange(t *testing.T) { cancel() return 0, nil }}) + d.newTicker = func(interval time.Duration) watchTicker { + if interval != watchInterval { + t.Fatalf("watch interval = %s, want %s", interval, watchInterval) + } + return fakeTicker + } var stdout, stderr bytes.Buffer code, err := d.Dispatch(ctx, []string{"watch"}, nil, &stdout, &stderr) @@ -176,9 +168,6 @@ func TestHandleWatch_DoesNotRedrawUnchangedOutput(t *testing.T) { ticks := make(chan time.Time, 1) ticks <- time.Now() - oldTicker := newWatchTicker - newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } - t.Cleanup(func() { newWatchTicker = oldTicker }) ctx, cancel := context.WithCancel(context.Background()) runCount := 0 @@ -190,6 +179,7 @@ func TestHandleWatch_DoesNotRedrawUnchangedOutput(t *testing.T) { } return 0, nil }}) + d.newTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } var stdout, stderr bytes.Buffer code, err := d.Dispatch(ctx, []string{"watch", "list"}, nil, &stdout, &stderr) @@ -206,10 +196,6 @@ func TestHandleWatch_DoesNotRedrawUnchangedOutput(t *testing.T) { func TestHandleWatch_ForwardsSubcommandArgs(t *testing.T) { ticks := make(chan time.Time) - oldTicker := newWatchTicker - newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } - t.Cleanup(func() { newWatchTicker = oldTicker }) - ctx, cancel := context.WithCancel(context.Background()) var gotArgs []string d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { @@ -218,6 +204,7 @@ func TestHandleWatch_ForwardsSubcommandArgs(t *testing.T) { _, _ = io.WriteString(stdout, `[]`) return 0, nil }}) + d.newTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} } var stdout, stderr bytes.Buffer code, err := d.Dispatch(ctx, []string{"watch", "ready", "limit:2"}, nil, &stdout, &stderr) diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go index 924ca67..cdfc1d8 100644 --- a/internal/askcli/dispatch.go +++ b/internal/askcli/dispatch.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "time" ) // Runner performs CLI work that would otherwise be handled by the ask CLI itself. @@ -14,18 +15,30 @@ type Runner interface { } // Dispatcher translates CLI arguments into concrete subcommands and presents the output. +// +// newTicker is the injected factory for the watch-loop ticker. Production code +// uses the real time.Ticker-backed factory installed by NewDispatcher; tests +// inject a fake ticker so they can drive the watch loop without real delays. type Dispatcher struct { runner Runner jsonOutput bool + newTicker func(time.Duration) watchTicker + capture func(context.Context, []byte) (string, error) } -// NewDispatcher creates a Dispatcher backed by the provided Runner or a default executor when nil. +// NewDispatcher creates a Dispatcher backed by the provided Runner or a default +// executor when nil. It wires the default real-ticker factory used by the +// `ask watch` loop. func NewDispatcher(runner Runner) *Dispatcher { if runner == nil { e := NewExecutor("ask") runner = &e } - return &Dispatcher{runner: runner} + return &Dispatcher{ + runner: runner, + newTicker: newRealWatchTicker, + capture: editorCapture, + } } func parseGlobalFlags(args []string) ([]string, bool) { diff --git a/internal/editor/editor.go b/internal/editor/editor.go index 9a9e737..aad7047 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -21,11 +21,25 @@ func Resolve() (string, error) { return ed, nil } -// RunEditor is the seam that invokes the editor on the given file path. -// Override in tests to avoid launching a real editor. It uses -// exec.CommandContext so a cancelled ctx (e.g. process shutdown) kills the -// editor subprocess instead of leaving it blocking on terminal input. -var RunEditor = func(ctx context.Context, editor, path string) error { +type Runner struct { + runEditor func(context.Context, string, string) error +} + +func (r Runner) edit(ctx context.Context, editor, path string) error { + if r.runEditor != nil { + return r.runEditor(ctx, editor, path) + } + return runEditor(ctx, editor, path) +} + +// RunEditor invokes the editor on the given file path. It uses +// exec.CommandContext so a cancelled ctx kills the editor subprocess instead +// of leaving it blocking on terminal input. +func RunEditor(ctx context.Context, editor, path string) error { + return runEditor(ctx, editor, path) +} + +func runEditor(ctx context.Context, editor, path string) error { cmd := exec.CommandContext(ctx, editor, path) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout @@ -38,6 +52,10 @@ var RunEditor = func(ctx context.Context, editor, path string) error { // Returns the trimmed content. ctx is forwarded to the editor subprocess so it // can be cancelled along with the surrounding command. func OpenTempAndEdit(ctx context.Context, initial []byte) (string, error) { + return Runner{}.OpenTempAndEdit(ctx, initial) +} + +func (r Runner) OpenTempAndEdit(ctx context.Context, initial []byte) (string, error) { ed, err := Resolve() if err != nil { return "", err @@ -63,7 +81,7 @@ func OpenTempAndEdit(ctx context.Context, initial []byte) (string, error) { if err := f.Close(); err != nil { return "", err } - if err := RunEditor(ctx, ed, path); err != nil { + if err := r.edit(ctx, ed, path); err != nil { return "", err } b, err := os.ReadFile(filepath.Clean(path)) @@ -77,6 +95,10 @@ func OpenTempAndEdit(ctx context.Context, initial []byte) (string, error) { // from Resolve() (HEXAI_EDITOR or EDITOR). ctx is forwarded to the editor // subprocess so it can be cancelled with the surrounding command. func OpenFile(ctx context.Context, path string) error { + return Runner{}.OpenFile(ctx, path) +} + +func (r Runner) OpenFile(ctx context.Context, path string) error { ed, err := Resolve() if err != nil { return err @@ -91,5 +113,5 @@ func OpenFile(ctx context.Context, path string) error { return mkErr } } - return RunEditor(ctx, ed, path) + return r.edit(ctx, ed, path) } diff --git a/internal/editor/editor_test.go b/internal/editor/editor_test.go index f2c1e22..543235e 100644 --- a/internal/editor/editor_test.go +++ b/internal/editor/editor_test.go @@ -75,17 +75,15 @@ func TestResolve_WhitespaceOnly(t *testing.T) { } func TestOpenTempAndEdit_UsesRunEditor(t *testing.T) { - old := RunEditor - t.Cleanup(func() { RunEditor = old }) // Ensure Resolve() succeeds t.Setenv("HEXAI_EDITOR", "dummy") var capturedPath string - RunEditor = func(_ context.Context, editor, path string) error { + r := Runner{runEditor: func(_ context.Context, editor, path string) error { capturedPath = path // simulate user writing content return os.WriteFile(path, []byte("Hello\nWorld\n"), 0o600) - } - out, err := OpenTempAndEdit(context.Background(), []byte("# Start\n\n")) + }} + out, err := r.OpenTempAndEdit(context.Background(), []byte("# Start\n\n")) if err != nil { t.Fatalf("OpenTempAndEdit: %v", err) } @@ -109,14 +107,12 @@ func TestOpenTempAndEdit_NoEditor(t *testing.T) { // TestOpenTempAndEdit_NilInitial verifies that nil initial content works (empty file). func TestOpenTempAndEdit_NilInitial(t *testing.T) { - old := RunEditor - t.Cleanup(func() { RunEditor = old }) t.Setenv("HEXAI_EDITOR", "dummy") - RunEditor = func(_ context.Context, editor, path string) error { + r := Runner{runEditor: func(_ context.Context, editor, path string) error { // simulate user writing content into a file that started empty return os.WriteFile(path, []byte("result"), 0o600) - } - out, err := OpenTempAndEdit(context.Background(), nil) + }} + out, err := r.OpenTempAndEdit(context.Background(), nil) if err != nil { t.Fatalf("OpenTempAndEdit with nil initial: %v", err) } @@ -128,13 +124,11 @@ func TestOpenTempAndEdit_NilInitial(t *testing.T) { // TestOpenTempAndEdit_EmptyInitial verifies that empty (zero-length) initial content // skips the write branch but still works end-to-end. func TestOpenTempAndEdit_EmptyInitial(t *testing.T) { - old := RunEditor - t.Cleanup(func() { RunEditor = old }) t.Setenv("HEXAI_EDITOR", "dummy") - RunEditor = func(_ context.Context, editor, path string) error { + r := Runner{runEditor: func(_ context.Context, editor, path string) error { return os.WriteFile(path, []byte(" trimmed "), 0o600) - } - out, err := OpenTempAndEdit(context.Background(), []byte{}) + }} + out, err := r.OpenTempAndEdit(context.Background(), []byte{}) if err != nil { t.Fatalf("OpenTempAndEdit with empty initial: %v", err) } @@ -145,14 +139,12 @@ func TestOpenTempAndEdit_EmptyInitial(t *testing.T) { // TestOpenTempAndEdit_EditorError verifies that an editor failure propagates the error. func TestOpenTempAndEdit_EditorError(t *testing.T) { - old := RunEditor - t.Cleanup(func() { RunEditor = old }) t.Setenv("HEXAI_EDITOR", "dummy") editorErr := errors.New("editor crashed") - RunEditor = func(_ context.Context, editor, path string) error { + r := Runner{runEditor: func(_ context.Context, editor, path string) error { return editorErr - } - _, err := OpenTempAndEdit(context.Background(), []byte("some content")) + }} + _, err := r.OpenTempAndEdit(context.Background(), []byte("some content")) if err == nil { t.Fatal("expected error when editor fails") } @@ -163,14 +155,12 @@ func TestOpenTempAndEdit_EditorError(t *testing.T) { // TestOpenTempAndEdit_EditorDeletesFile verifies error when the editor removes the temp file. func TestOpenTempAndEdit_EditorDeletesFile(t *testing.T) { - old := RunEditor - t.Cleanup(func() { RunEditor = old }) t.Setenv("HEXAI_EDITOR", "dummy") - RunEditor = func(_ context.Context, editor, path string) error { + r := Runner{runEditor: func(_ context.Context, editor, path string) error { // simulate the editor deleting the file return os.Remove(path) - } - _, err := OpenTempAndEdit(context.Background(), []byte("content")) + }} + _, err := r.OpenTempAndEdit(context.Background(), []byte("content")) if err == nil { t.Fatal("expected error when temp file is deleted by editor") } @@ -178,15 +168,13 @@ func TestOpenTempAndEdit_EditorDeletesFile(t *testing.T) { // TestOpenTempAndEdit_TempFileCleanup verifies the temp file is removed after success. func TestOpenTempAndEdit_TempFileCleanup(t *testing.T) { - old := RunEditor - t.Cleanup(func() { RunEditor = old }) t.Setenv("HEXAI_EDITOR", "dummy") var capturedPath string - RunEditor = func(_ context.Context, editor, path string) error { + r := Runner{runEditor: func(_ context.Context, editor, path string) error { capturedPath = path return os.WriteFile(path, []byte("done"), 0o600) - } - _, err := OpenTempAndEdit(context.Background(), nil) + }} + _, err := r.OpenTempAndEdit(context.Background(), nil) if err != nil { t.Fatalf("OpenTempAndEdit: %v", err) } @@ -197,17 +185,15 @@ func TestOpenTempAndEdit_TempFileCleanup(t *testing.T) { } func TestOpenFile_CreatesParentAndInvokesEditor(t *testing.T) { - old := RunEditor - t.Cleanup(func() { RunEditor = old }) t.Setenv("HEXAI_EDITOR", "dummy") target := filepath.Join(t.TempDir(), "nested", "config.toml") var gotEditor, gotPath string - RunEditor = func(_ context.Context, editorCmd, path string) error { + r := Runner{runEditor: func(_ context.Context, editorCmd, path string) error { gotEditor = editorCmd gotPath = path return nil - } - if err := OpenFile(context.Background(), target); err != nil { + }} + if err := r.OpenFile(context.Background(), target); err != nil { t.Fatalf("OpenFile: %v", err) } if gotEditor != "dummy" { diff --git a/internal/hexaiaction/cmdentry.go b/internal/hexaiaction/cmdentry.go index 85e9059..ec4970c 100644 --- a/internal/hexaiaction/cmdentry.go +++ b/internal/hexaiaction/cmdentry.go @@ -25,23 +25,47 @@ type Options struct { // RunCommand is the CLI orchestrator used by cmd/hexai-tmux-action. It runs in tmux // split-pane mode by default, or child mode when -ui-child is set. func RunCommand(ctx context.Context, opts Options, stdin io.Reader, stdout, stderr io.Writer) error { + return commandRunner{}.RunCommand(ctx, opts, stdin, stdout, stderr) +} + +type commandRunner struct { + popupRun func(tmux.PopupOpts, []string) error + osExecutable func() (string, error) + run func(context.Context, io.Reader, io.Writer, io.Writer) error +} + +func (r commandRunner) popup(opts tmux.PopupOpts, argv []string) error { + if r.popupRun != nil { + return r.popupRun(opts, argv) + } + return tmux.PopupRun(opts, argv) +} + +func (r commandRunner) executable() (string, error) { + if r.osExecutable != nil { + return r.osExecutable() + } + return os.Executable() +} + +func (r commandRunner) runAction(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error { + if r.run != nil { + return r.run(ctx, stdin, stdout, stderr) + } + return Run(ctx, stdin, stdout, stderr) +} + +func (r commandRunner) RunCommand(ctx context.Context, opts Options, stdin io.Reader, stdout, stderr io.Writer) error { if err := llm.RegisterAllProviders(); err != nil { return fmt.Errorf("failed to register LLM providers: %w", err) } if opts.UIChild { - return runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr) + return r.runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr) } // Always use tmux popup path - return runInTmuxParent(ctx, stdin, stdout, opts.TmuxTarget, opts.TmuxPopupWidth, opts.TmuxPopupHeight) + return r.runInTmuxParent(ctx, stdin, stdout, opts.TmuxTarget, opts.TmuxPopupWidth, opts.TmuxPopupHeight) } -// seams for unit tests -var ( - popupRunFn = tmux.PopupRun - osExecutableFn = os.Executable - runFn = Run -) - // openIO returns readers/writers for infile/outfile flags with deferred closers. func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error) { in := io.Reader(os.Stdin) @@ -68,7 +92,7 @@ func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error } // runChild runs the interactive flow and writes the final output atomically when outfile is set. -func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Writer) error { +func (r commandRunner) runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Writer) error { if outfile == "" { // No atomic handoff needed; just run normally to provided stdout var in io.Reader = os.Stdin @@ -80,7 +104,7 @@ func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Wri defer func() { _ = f.Close() }() in = f } - return runFn(ctx, in, stdout, stderr) + return r.runAction(ctx, in, stdout, stderr) } tmp := outfile + ".tmp" in, out, closeIn, closeOut, err := openIO(infile, tmp) @@ -88,7 +112,7 @@ func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Wri return err } defer closeIn() - if err := runFn(ctx, in, out, stderr); err != nil { + if err := r.runAction(ctx, in, out, stderr); err != nil { closeOut() if copyErr := echoThrough(infile, tmp, os.Stdin, stdout); copyErr != nil { // Wrap the primary child error with %w so callers can inspect it @@ -102,7 +126,7 @@ func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Wri return os.Rename(tmp, outfile) } -func runInTmuxParent(ctx context.Context, stdin io.Reader, stdout io.Writer, target, popupWidth, popupHeight string) error { +func (r commandRunner) runInTmuxParent(ctx context.Context, stdin io.Reader, stdout io.Writer, target, popupWidth, popupHeight string) error { dir, err := os.MkdirTemp("", "hexai-tmux-action-") if err != nil { return err @@ -113,13 +137,13 @@ func runInTmuxParent(ctx context.Context, stdin io.Reader, stdout io.Writer, tar if err := persistStdin(inPath, stdin); err != nil { return err } - exe, err := osExecutableFn() + exe, err := r.executable() if err != nil { return err } argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath} opts := tmux.PopupOpts{Target: target, Width: popupWidth, Height: popupHeight} - if err := popupRunFn(opts, argv); err != nil { + if err := r.popup(opts, argv); err != nil { return err } if err := waitForFile(ctx, outPath, 60*time.Second); err != nil { diff --git a/internal/hexaiaction/cmdentry_runcommand_test.go b/internal/hexaiaction/cmdentry_runcommand_test.go index ac6106d..b71a0f2 100644 --- a/internal/hexaiaction/cmdentry_runcommand_test.go +++ b/internal/hexaiaction/cmdentry_runcommand_test.go< |
