diff options
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/ask/main.go | 16 | ||||
| -rw-r--r-- | cmd/ask/main_test.go | 28 | ||||
| -rw-r--r-- | cmd/hexai-tmux-action/main.go | 32 | ||||
| -rw-r--r-- | cmd/hexai-tmux-action/main_test.go | 52 | ||||
| -rw-r--r-- | cmd/hexai-tmux-edit/main.go | 13 | ||||
| -rw-r--r-- | cmd/hexai-tmux-edit/main_test.go | 39 |
6 files changed, 81 insertions, 99 deletions
diff --git a/cmd/ask/main.go b/cmd/ask/main.go index fbd5bb0..689cce0 100644 --- a/cmd/ask/main.go +++ b/cmd/ask/main.go @@ -15,19 +15,21 @@ type dispatcher interface { Dispatch(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) } -// dispatcherFactory is a test seam: override to inject a fake dispatcher so -// runMain can be exercised without a real `task` binary on PATH. -var dispatcherFactory = func() dispatcher { - return askcli.NewDispatcher(nil) +type app struct { + newDispatcher func() dispatcher } -func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } +func newApp() *app { + return &app{newDispatcher: func() dispatcher { return askcli.NewDispatcher(nil) }} +} + +func main() { os.Exit(newApp().runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } // runMain dispatches the command and returns the process exit code; errors // are printed to stderr. The dispatcher's exit code is returned regardless // of err so callers see Taskwarrior's own exit code on failure paths. -func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int { - code, err := dispatcherFactory().Dispatch(context.Background(), args, stdin, stdout, stderr) +func (a *app) runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + code, err := a.newDispatcher().Dispatch(context.Background(), args, stdin, stdout, stderr) if err != nil { fmt.Fprintln(stderr, err) } diff --git a/cmd/ask/main_test.go b/cmd/ask/main_test.go index 643dde4..0f6a0c7 100644 --- a/cmd/ask/main_test.go +++ b/cmd/ask/main_test.go @@ -68,14 +68,11 @@ func (f *fakeDispatcher) Dispatch(_ context.Context, args []string, _ io.Reader, // Driving runMain through a fake dispatcher proves the wiring (args // forwarded, exit code returned) without touching the real ask CLI. func TestRunMain_DelegatesAndReturnsCode(t *testing.T) { - old := dispatcherFactory - t.Cleanup(func() { dispatcherFactory = old }) - fake := &fakeDispatcher{code: 0} - dispatcherFactory = func() dispatcher { return fake } + a := &app{newDispatcher: func() dispatcher { return fake }} var stdout, stderr bytes.Buffer - got := runMain([]string{"list", "limit:1"}, nil, &stdout, &stderr) + got := a.runMain([]string{"list", "limit:1"}, nil, &stdout, &stderr) if got != 0 { t.Fatalf("runMain code = %d, want 0", got) } @@ -87,30 +84,27 @@ func TestRunMain_DelegatesAndReturnsCode(t *testing.T) { } } -// The default dispatcherFactory must return a working real dispatcher (this -// is the path main() uses in production); fakes used elsewhere don't cover -// it, so verify it explicitly. -func TestDispatcherFactory_DefaultReturnsRealDispatcher(t *testing.T) { - d := dispatcherFactory() +// The default app dispatcher factory must return a working real dispatcher +// (this is the path main() uses in production); fakes used elsewhere don't +// cover it, so verify it explicitly. +func TestNewApp_DefaultReturnsRealDispatcher(t *testing.T) { + d := newApp().newDispatcher() if d == nil { - t.Fatal("default dispatcherFactory returned nil") + t.Fatal("default dispatcher factory returned nil") } if _, ok := d.(*askcli.Dispatcher); !ok { - t.Fatalf("default dispatcherFactory returned %T, want *askcli.Dispatcher", d) + t.Fatalf("default dispatcher factory returned %T, want *askcli.Dispatcher", d) } } // On a dispatcher error, runMain must print the error to stderr AND surface // the dispatcher's exit code so the shell sees Taskwarrior's own status. func TestRunMain_PrintsErrorAndPropagatesExitCode(t *testing.T) { - old := dispatcherFactory - t.Cleanup(func() { dispatcherFactory = old }) - fake := &fakeDispatcher{code: 7, err: errors.New("dispatch boom")} - dispatcherFactory = func() dispatcher { return fake } + a := &app{newDispatcher: func() dispatcher { return fake }} var stdout, stderr bytes.Buffer - got := runMain(nil, nil, &stdout, &stderr) + got := a.runMain(nil, nil, &stdout, &stderr) if got != 7 { t.Fatalf("runMain code = %d, want 7", got) } diff --git a/cmd/hexai-tmux-action/main.go b/cmd/hexai-tmux-action/main.go index e2f50eb..8830aa8 100644 --- a/cmd/hexai-tmux-action/main.go +++ b/cmd/hexai-tmux-action/main.go @@ -12,18 +12,20 @@ import ( "codeberg.org/snonux/hexai/internal/hexaiaction" ) -// runCommand is the seam for testing: override in tests to avoid launching -// the real tmux action. -var runCommand = hexaiaction.RunCommand +// actionRunner is the dependency that performs the tmux action. Production +// code uses hexaiaction.RunCommand; tests inject a stub to avoid launching the +// real tmux popup. +type actionRunner func(context.Context, hexaiaction.Options, io.Reader, io.Writer, io.Writer) error -func main() { os.Exit(runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } +func main() { os.Exit(newApp().runMain(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } // runMain parses command-line flags from args, builds actionOptions, and // delegates to run. It returns the process exit code: 2 for flag-parse // errors (matching stdlib `flag.ExitOnError`), 1 for runtime failures, 0 on // success. Splitting the body out of main keeps it testable without -// touching package-level flag state. -func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int { +// touching package-level flag state. It is a method on app so tests can inject +// a stub runCommand. +func (a *app) runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("hexai-tmux-action", flag.ContinueOnError) fs.SetOutput(stderr) infile := fs.String("infile", "", "Read input from this file instead of stdin") @@ -43,7 +45,7 @@ func runMain(args []string, stdin io.Reader, stdout, stderr io.Writer) int { uiChild: *uiChild, configPath: *configPath, tmuxTarget: *tmuxTarget, tmuxPopupWidth: *tmuxPopupWidth, tmuxPopupHeight: *tmuxPopupHeight, } - if err := run(opts, stdin, stdout, stderr); err != nil { + if err := a.run(opts, stdin, stdout, stderr); err != nil { fmt.Fprintln(stderr, err) return 1 } @@ -61,8 +63,18 @@ type actionOptions struct { tmuxPopupHeight string } -// run builds the hexaiaction.Options and context, then delegates to runCommand. -func run(opts actionOptions, stdin io.Reader, stdout, stderr io.Writer) error { +// app wires the injected dependencies for the command. runCommand defaults to +// hexaiaction.RunCommand in production and is replaced by tests. +type app struct { + runCommand actionRunner +} + +// newApp returns an app with the production action runner installed. +func newApp() *app { return &app{runCommand: hexaiaction.RunCommand} } + +// run builds the hexaiaction.Options and context, then delegates to the +// injected runCommand dependency. +func (a *app) run(opts actionOptions, stdin io.Reader, stdout, stderr io.Writer) error { haOpts := hexaiaction.Options{ Infile: opts.infile, Outfile: opts.outfile, UIChild: opts.uiChild, TmuxTarget: opts.tmuxTarget, @@ -72,5 +84,5 @@ func run(opts actionOptions, stdin io.Reader, stdout, stderr io.Writer) error { if path := strings.TrimSpace(opts.configPath); path != "" { ctx = hexaiaction.WithConfigPath(ctx, path) } - return runCommand(ctx, haOpts, stdin, stdout, stderr) + return a.runCommand(ctx, haOpts, stdin, stdout, stderr) } diff --git a/cmd/hexai-tmux-action/main_test.go b/cmd/hexai-tmux-action/main_test.go index e1c02e1..98f0c7c 100644 --- a/cmd/hexai-tmux-action/main_test.go +++ b/cmd/hexai-tmux-action/main_test.go @@ -12,20 +12,17 @@ import ( ) func TestRun_DelegatesToRunCommand(t *testing.T) { - old := runCommand - t.Cleanup(func() { runCommand = old }) - var gotOpts hexaiaction.Options - runCommand = func(_ context.Context, opts hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { + a := &app{runCommand: func(_ context.Context, opts hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { gotOpts = opts return nil - } + }} opts := actionOptions{ infile: "in.txt", outfile: "out.txt", tmuxPopupWidth: "90%", tmuxPopupHeight: "70%", } - if err := run(opts, nil, nil, nil); err != nil { + if err := a.run(opts, nil, nil, nil); err != nil { t.Fatalf("run: %v", err) } if gotOpts.Infile != "in.txt" || gotOpts.Outfile != "out.txt" { @@ -37,29 +34,23 @@ func TestRun_DelegatesToRunCommand(t *testing.T) { } func TestRun_WithConfigPath(t *testing.T) { - old := runCommand - t.Cleanup(func() { runCommand = old }) - - runCommand = func(_ context.Context, _ hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { + a := &app{runCommand: func(_ context.Context, _ hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { return nil - } + }} opts := actionOptions{configPath: " /tmp/test.toml "} - if err := run(opts, nil, nil, nil); err != nil { + if err := a.run(opts, nil, nil, nil); err != nil { t.Fatalf("run: %v", err) } } func TestRun_Error(t *testing.T) { - old := runCommand - t.Cleanup(func() { runCommand = old }) - wantErr := errors.New("action failed") - runCommand = func(_ context.Context, _ hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { + a := &app{runCommand: func(_ context.Context, _ hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { return wantErr - } + }} - if err := run(actionOptions{}, nil, nil, nil); !errors.Is(err, wantErr) { + if err := a.run(actionOptions{}, nil, nil, nil); !errors.Is(err, wantErr) { t.Fatalf("expected error, got: %v", err) } } @@ -68,14 +59,11 @@ func TestRun_Error(t *testing.T) { // the stub returns 0. The captured Options confirm the field-by-field // mapping that main relies on. func TestRunMain_FlagsForwardedToHexaiaction(t *testing.T) { - old := runCommand - t.Cleanup(func() { runCommand = old }) - var got hexaiaction.Options - runCommand = func(_ context.Context, opts hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { + a := &app{runCommand: func(_ context.Context, opts hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { got = opts return nil - } + }} args := []string{ "-infile", "in.txt", @@ -86,7 +74,7 @@ func TestRunMain_FlagsForwardedToHexaiaction(t *testing.T) { "-ui-child", } var stderr bytes.Buffer - code := runMain(args, nil, &bytes.Buffer{}, &stderr) + code := a.runMain(args, nil, &bytes.Buffer{}, &stderr) if code != 0 { t.Fatalf("runMain code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -104,14 +92,12 @@ func TestRunMain_FlagsForwardedToHexaiaction(t *testing.T) { // On runCommand failure, runMain returns 1 (the production exit code) and // writes the error message to stderr so users see what went wrong. func TestRunMain_RuntimeErrorReturnsOne(t *testing.T) { - old := runCommand - t.Cleanup(func() { runCommand = old }) - runCommand = func(context.Context, hexaiaction.Options, io.Reader, io.Writer, io.Writer) error { + a := &app{runCommand: func(context.Context, hexaiaction.Options, io.Reader, io.Writer, io.Writer) error { return errors.New("action exploded") - } + }} var stderr bytes.Buffer - code := runMain(nil, nil, &bytes.Buffer{}, &stderr) + code := a.runMain(nil, nil, &bytes.Buffer{}, &stderr) if code != 1 { t.Fatalf("runMain code = %d, want 1", code) } @@ -122,15 +108,13 @@ func TestRunMain_RuntimeErrorReturnsOne(t *testing.T) { // Bad flag must yield exit 2 without ever invoking runCommand. func TestRunMain_BadFlagReturnsTwo(t *testing.T) { - old := runCommand - t.Cleanup(func() { runCommand = old }) called := false - runCommand = func(context.Context, hexaiaction.Options, io.Reader, io.Writer, io.Writer) error { + a := &app{runCommand: func(context.Context, hexaiaction.Options, io.Reader, io.Writer, io.Writer) error { called = true return nil - } + }} var stderr bytes.Buffer - code := runMain([]string{"--bogus"}, nil, &bytes.Buffer{}, &stderr) + code := a.runMain([]string{"--bogus"}, nil, &bytes.Buffer{}, &stderr) if code != 2 { t.Fatalf("runMain code = %d, want 2", code) } diff --git a/cmd/hexai-tmux-edit/main.go b/cmd/hexai-tmux-edit/main.go index 6177008..d61f68a 100644 --- a/cmd/hexai-tmux-edit/main.go +++ b/cmd/hexai-tmux-edit/main.go @@ -22,15 +22,18 @@ import ( "codeberg.org/snonux/hexai/internal/tmuxedit" ) -// runTmuxEdit is the seam for testing: override in tests to avoid real tmux. -var runTmuxEdit = tmuxedit.Run +type app struct { + runTmuxEdit func(tmuxedit.Options) error +} + +func newApp() *app { return &app{runTmuxEdit: tmuxedit.Run} } -func main() { os.Exit(runMain(os.Args[1:], os.Stderr)) } +func main() { os.Exit(newApp().runMain(os.Args[1:], os.Stderr)) } // runMain parses flags from args and runs the tmux edit popup. It returns // the process exit code; flag errors return 2 (matching stdlib convention), // runtime failures return 1. -func runMain(args []string, stderr io.Writer) int { +func (a *app) runMain(args []string, stderr io.Writer) int { defaultPath := appconfig.DefaultConfigPath() fs := flag.NewFlagSet("hexai-tmux-edit", flag.ContinueOnError) fs.SetOutput(stderr) @@ -42,7 +45,7 @@ func runMain(args []string, stderr io.Writer) int { } opts := buildOptions(*configPath, *agent, *pane) - if err := runTmuxEdit(opts); err != nil { + if err := a.runTmuxEdit(opts); err != nil { fmt.Fprintln(stderr, err) return 1 } diff --git a/cmd/hexai-tmux-edit/main_test.go b/cmd/hexai-tmux-edit/main_test.go index 6881556..3171b86 100644 --- a/cmd/hexai-tmux-edit/main_test.go +++ b/cmd/hexai-tmux-edit/main_test.go @@ -30,17 +30,14 @@ func TestBuildOptions_TrimsWhitespace(t *testing.T) { } func TestRunTmuxEdit_Success(t *testing.T) { - old := runTmuxEdit - t.Cleanup(func() { runTmuxEdit = old }) - var gotOpts tmuxedit.Options - runTmuxEdit = func(opts tmuxedit.Options) error { + a := &app{runTmuxEdit: func(opts tmuxedit.Options) error { gotOpts = opts return nil - } + }} opts := buildOptions("/tmp/cfg.toml", "cursor", "%3") - if err := runTmuxEdit(opts); err != nil { + if err := a.runTmuxEdit(opts); err != nil { t.Fatalf("runTmuxEdit: %v", err) } if gotOpts.ConfigPath != "/tmp/cfg.toml" || gotOpts.Agent != "cursor" || gotOpts.Pane != "%3" { @@ -49,13 +46,10 @@ func TestRunTmuxEdit_Success(t *testing.T) { } func TestRunTmuxEdit_Error(t *testing.T) { - old := runTmuxEdit - t.Cleanup(func() { runTmuxEdit = old }) - wantErr := errors.New("tmux not found") - runTmuxEdit = func(_ tmuxedit.Options) error { return wantErr } + a := &app{runTmuxEdit: func(_ tmuxedit.Options) error { return wantErr }} - if err := runTmuxEdit(tmuxedit.Options{}); !errors.Is(err, wantErr) { + if err := a.runTmuxEdit(tmuxedit.Options{}); !errors.Is(err, wantErr) { t.Fatalf("expected error, got: %v", err) } } @@ -63,17 +57,14 @@ func TestRunTmuxEdit_Error(t *testing.T) { // runMain happy path: flags parse, runTmuxEdit returns nil, exit code 0. // We capture the resolved Options to confirm flags map onto fields correctly. func TestRunMain_FlagsForwardedToTmuxedit(t *testing.T) { - old := runTmuxEdit - t.Cleanup(func() { runTmuxEdit = old }) - var got tmuxedit.Options - runTmuxEdit = func(opts tmuxedit.Options) error { + a := &app{runTmuxEdit: func(opts tmuxedit.Options) error { got = opts return nil - } + }} var stderr bytes.Buffer - code := runMain([]string{"-config", " /tmp/cfg.toml ", "-agent", "claude", "-pane", "%9"}, &stderr) + code := a.runMain([]string{"-config", " /tmp/cfg.toml ", "-agent", "claude", "-pane", "%9"}, &stderr) if code != 0 { t.Fatalf("runMain code = %d, want 0", code) } @@ -88,12 +79,10 @@ func TestRunMain_FlagsForwardedToTmuxedit(t *testing.T) { // runMain reports tmuxedit.Run failures by writing to stderr and returning 1 // — the production exit code that the shipped binary uses. func TestRunMain_RunErrorReturnsOne(t *testing.T) { - old := runTmuxEdit - t.Cleanup(func() { runTmuxEdit = old }) - runTmuxEdit = func(tmuxedit.Options) error { return errors.New("boom") } + a := &app{runTmuxEdit: func(tmuxedit.Options) error { return errors.New("boom") }} var stderr bytes.Buffer - code := runMain(nil, &stderr) + code := a.runMain(nil, &stderr) if code != 1 { t.Fatalf("runMain code = %d, want 1", code) } @@ -105,16 +94,14 @@ func TestRunMain_RunErrorReturnsOne(t *testing.T) { // Unknown flags must yield exit 2 (the convention used by stdlib `flag` when // ExitOnError aborts) without ever invoking runTmuxEdit. func TestRunMain_BadFlagReturnsTwo(t *testing.T) { - old := runTmuxEdit - t.Cleanup(func() { runTmuxEdit = old }) called := false - runTmuxEdit = func(tmuxedit.Options) error { + a := &app{runTmuxEdit: func(tmuxedit.Options) error { called = true return nil - } + }} var stderr bytes.Buffer - code := runMain([]string{"--no-such-flag"}, &stderr) + code := a.runMain([]string{"--no-such-flag"}, &stderr) if code != 2 { t.Fatalf("runMain code = %d, want 2", code) } |
