diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-16 03:10:55 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-16 03:10:55 +0200 |
| commit | 1fc1611fa99993cab5dc8bf0844183285296e3b2 (patch) | |
| tree | c5c9b8b5abac5b5d4c0d56ed90b0580184cc4383 /cmd | |
| parent | 12090f25a3677291863dbb80277bdad3eaec0324 (diff) | |
Release v0.24.0v0.24.0
Bring unit test coverage from ~75% to 85.1% project-wide. All internal
packages now exceed 80% coverage. Refactored cmd entrypoints to extract
testable run() functions with injectable seams.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/hexai-mcp-server/main.go | 65 | ||||
| -rw-r--r-- | cmd/hexai-mcp-server/main_test.go | 166 | ||||
| -rw-r--r-- | cmd/hexai-tmux-action/main.go | 43 | ||||
| -rw-r--r-- | cmd/hexai-tmux-action/main_test.go | 63 | ||||
| -rw-r--r-- | cmd/hexai-tmux-edit/main.go | 21 | ||||
| -rw-r--r-- | cmd/hexai-tmux-edit/main_test.go | 59 | ||||
| -rw-r--r-- | cmd/hexai/main_test.go | 111 |
7 files changed, 496 insertions, 32 deletions
diff --git a/cmd/hexai-mcp-server/main.go b/cmd/hexai-mcp-server/main.go index 557172f..1f52616 100644 --- a/cmd/hexai-mcp-server/main.go +++ b/cmd/hexai-mcp-server/main.go @@ -4,7 +4,7 @@ package main import ( "flag" "fmt" - "log" + "io" "os" "codeberg.org/snonux/hexai/internal" @@ -12,6 +12,12 @@ import ( "codeberg.org/snonux/hexai/internal/hexaimcp" ) +// Seams for testing: override in tests to avoid launching real MCP server. +var ( + runMCP = hexaimcp.Run + runBackfill = hexaimcp.RunBackfill +) + // printDeprecationWarning outputs a deprecation notice to stderr explaining // that hexai-mcp-server is experimental and not actively maintained. func printDeprecationWarning() { @@ -36,6 +42,17 @@ Use at your own risk. fmt.Fprintln(os.Stderr, warning) } +// mcpOptions holds the parsed command-line flags for the MCP server. +type mcpOptions struct { + logPath string + configPath string + promptsDir string + slashCommandSync bool + slashCommandDir string + syncAll bool + showVersion bool +} + func main() { printDeprecationWarning() @@ -49,33 +66,45 @@ func main() { showVersion := flag.Bool("version", false, "print version and exit") flag.Parse() - if *showVersion { - fmt.Println(internal.Version) - return + opts := mcpOptions{ + logPath: *logPath, + configPath: *configPath, + promptsDir: *promptsDir, + slashCommandSync: *slashCommandSync, + slashCommandDir: *slashCommandDir, + syncAll: *syncAll, + showVersion: *showVersion, + } + if err := run(opts, os.Stdin, os.Stdout, os.Stderr); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) } +} - // If prompts-dir is specified, set environment variable for RunWithFactory - if *promptsDir != "" { - os.Setenv("HEXAI_MCP_PROMPTS_DIR", *promptsDir) +// run executes the MCP server logic with the given options and I/O streams. +func run(opts mcpOptions, stdin io.Reader, stdout, stderr io.Writer) error { + // Set environment variables for RunWithFactory based on flag values + if opts.promptsDir != "" { + os.Setenv("HEXAI_MCP_PROMPTS_DIR", opts.promptsDir) } - if *slashCommandSync { + if opts.slashCommandSync { os.Setenv("HEXAI_MCP_SLASHCOMMAND_SYNC", "true") } - if *slashCommandDir != "" { - os.Setenv("HEXAI_MCP_SLASHCOMMAND_DIR", *slashCommandDir) + if opts.slashCommandDir != "" { + os.Setenv("HEXAI_MCP_SLASHCOMMAND_DIR", opts.slashCommandDir) } - // Handle backfill operation - if *syncAll { - if err := hexaimcp.RunBackfill(*logPath, *configPath); err != nil { - log.Fatalf("backfill error: %v", err) - } - return + if opts.showVersion { + fmt.Fprintln(stdout, internal.Version) + return nil } - if err := hexaimcp.Run(*logPath, *configPath, os.Stdin, os.Stdout, os.Stderr); err != nil { - log.Fatalf("server error: %v", err) + // Handle backfill operation + if opts.syncAll { + return runBackfill(opts.logPath, opts.configPath) } + + return runMCP(opts.logPath, opts.configPath, stdin, stdout, stderr) } // defaultLogPath returns the default MCP log file path in the state directory. diff --git a/cmd/hexai-mcp-server/main_test.go b/cmd/hexai-mcp-server/main_test.go new file mode 100644 index 0000000..cf48954 --- /dev/null +++ b/cmd/hexai-mcp-server/main_test.go @@ -0,0 +1,166 @@ +package main + +import ( + "bytes" + "errors" + "io" + "os" + "strings" + "testing" + + "codeberg.org/snonux/hexai/internal" +) + +func TestPrintDeprecationWarning(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + + oldStderr := os.Stderr + os.Stderr = w + defer func() { os.Stderr = oldStderr }() + + printDeprecationWarning() + + if err := w.Close(); err != nil { + t.Fatalf("failed to close pipe writer: %v", err) + } + + b, err := io.ReadAll(r) + if err != nil { + t.Fatalf("failed to read pipe: %v", err) + } + + output := string(b) + for _, want := range []string{"DEPRECATION NOTICE", "EXPERIMENTAL", "NOT ACTIVELY MAINTAINED"} { + if !strings.Contains(output, want) { + t.Errorf("expected %q in output, got %q", want, output) + } + } +} + +func TestDefaultLogPath(t *testing.T) { + path := defaultLogPath() + if path == "" { + t.Fatal("expected non-empty log path") + } + if !strings.HasSuffix(path, "hexai-mcp-server.log") { + t.Errorf("expected path to end with hexai-mcp-server.log, got %q", path) + } +} + +func TestRun_ShowVersion(t *testing.T) { + var stdout bytes.Buffer + opts := mcpOptions{showVersion: true} + if err := run(opts, nil, &stdout, nil); err != nil { + t.Fatalf("run --version: %v", err) + } + got := strings.TrimSpace(stdout.String()) + if got != internal.Version { + t.Fatalf("expected version %q, got %q", internal.Version, got) + } +} + +func TestRun_SetsPromptsDir(t *testing.T) { + t.Setenv("HEXAI_MCP_PROMPTS_DIR", "") + opts := mcpOptions{showVersion: true, promptsDir: "/tmp/test-prompts"} + var stdout bytes.Buffer + if err := run(opts, nil, &stdout, nil); err != nil { + t.Fatalf("run: %v", err) + } + if got := os.Getenv("HEXAI_MCP_PROMPTS_DIR"); got != "/tmp/test-prompts" { + t.Fatalf("expected HEXAI_MCP_PROMPTS_DIR=/tmp/test-prompts, got %q", got) + } +} + +func TestRun_SetsSlashCommandSync(t *testing.T) { + t.Setenv("HEXAI_MCP_SLASHCOMMAND_SYNC", "") + opts := mcpOptions{showVersion: true, slashCommandSync: true} + var stdout bytes.Buffer + if err := run(opts, nil, &stdout, nil); err != nil { + t.Fatalf("run: %v", err) + } + if got := os.Getenv("HEXAI_MCP_SLASHCOMMAND_SYNC"); got != "true" { + t.Fatalf("expected HEXAI_MCP_SLASHCOMMAND_SYNC=true, got %q", got) + } +} + +func TestRun_SetsSlashCommandDir(t *testing.T) { + t.Setenv("HEXAI_MCP_SLASHCOMMAND_DIR", "") + opts := mcpOptions{showVersion: true, slashCommandDir: "/tmp/test-cmds"} + var stdout bytes.Buffer + if err := run(opts, nil, &stdout, nil); err != nil { + t.Fatalf("run: %v", err) + } + if got := os.Getenv("HEXAI_MCP_SLASHCOMMAND_DIR"); got != "/tmp/test-cmds" { + t.Fatalf("expected HEXAI_MCP_SLASHCOMMAND_DIR=/tmp/test-cmds, got %q", got) + } +} + +func TestRun_SyncAll(t *testing.T) { + old := runBackfill + t.Cleanup(func() { runBackfill = old }) + + var gotLog, gotConfig string + runBackfill = func(logPath, configPath string) error { + gotLog = logPath + gotConfig = configPath + return nil + } + + opts := mcpOptions{syncAll: true, logPath: "/tmp/test.log", configPath: "/tmp/cfg.toml"} + if err := run(opts, nil, nil, nil); err != nil { + t.Fatalf("run syncAll: %v", err) + } + if gotLog != "/tmp/test.log" { + t.Fatalf("expected logPath=/tmp/test.log, got %q", gotLog) + } + if gotConfig != "/tmp/cfg.toml" { + t.Fatalf("expected configPath=/tmp/cfg.toml, got %q", gotConfig) + } +} + +func TestRun_SyncAllError(t *testing.T) { + old := runBackfill + t.Cleanup(func() { runBackfill = old }) + + wantErr := errors.New("backfill failed") + runBackfill = func(_, _ string) error { return wantErr } + + opts := mcpOptions{syncAll: true} + if err := run(opts, nil, nil, nil); !errors.Is(err, wantErr) { + t.Fatalf("expected backfill error, got: %v", err) + } +} + +func TestRun_MCPServer(t *testing.T) { + old := runMCP + t.Cleanup(func() { runMCP = old }) + + called := false + runMCP = func(logPath, configPath string, stdin io.Reader, stdout, stderr io.Writer) error { + called = true + return nil + } + + opts := mcpOptions{logPath: "/tmp/mcp.log"} + if err := run(opts, nil, nil, nil); err != nil { + t.Fatalf("run MCP: %v", err) + } + if !called { + t.Fatal("expected runMCP to be called") + } +} + +func TestRun_MCPServerError(t *testing.T) { + old := runMCP + t.Cleanup(func() { runMCP = old }) + + wantErr := errors.New("server failed") + runMCP = func(_, _ string, _ io.Reader, _, _ io.Writer) error { return wantErr } + + if err := run(mcpOptions{}, nil, nil, nil); !errors.Is(err, wantErr) { + t.Fatalf("expected server error, got: %v", err) + } +} diff --git a/cmd/hexai-tmux-action/main.go b/cmd/hexai-tmux-action/main.go index 6249de3..715c41f 100644 --- a/cmd/hexai-tmux-action/main.go +++ b/cmd/hexai-tmux-action/main.go @@ -4,6 +4,7 @@ import ( "context" "flag" "fmt" + "io" "os" "strings" @@ -11,6 +12,10 @@ 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 + func main() { infile := flag.String("infile", "", "Read input from this file instead of stdin") outfile := flag.String("outfile", "", "Write output to this file instead of stdout") @@ -22,16 +27,38 @@ func main() { tmuxPercent := flag.Int("tmux-percent", 33, "tmux split size percentage (1-100)") flag.Parse() - opts := hexaiaction.Options{ - Infile: *infile, Outfile: *outfile, - UIChild: *uiChild, TmuxTarget: *tmuxTarget, TmuxSplit: *tmuxSplit, TmuxPercent: *tmuxPercent, - } - ctx := context.Background() - if path := strings.TrimSpace(*configPath); path != "" { - ctx = hexaiaction.WithConfigPath(ctx, path) + opts := actionOptions{ + infile: *infile, outfile: *outfile, + uiChild: *uiChild, configPath: *configPath, + tmuxTarget: *tmuxTarget, tmuxSplit: *tmuxSplit, tmuxPercent: *tmuxPercent, } - if err := hexaiaction.RunCommand(ctx, opts, os.Stdin, os.Stdout, os.Stderr); err != nil { + if err := run(opts, os.Stdin, os.Stdout, os.Stderr); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } + +// actionOptions holds the parsed command-line flags for hexai-tmux-action. +type actionOptions struct { + infile string + outfile string + uiChild bool + configPath string + tmuxTarget string + tmuxSplit string + tmuxPercent int +} + +// run builds the hexaiaction.Options and context, then delegates to runCommand. +func 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, + TmuxSplit: opts.tmuxSplit, TmuxPercent: opts.tmuxPercent, + } + ctx := context.Background() + if path := strings.TrimSpace(opts.configPath); path != "" { + ctx = hexaiaction.WithConfigPath(ctx, path) + } + return runCommand(ctx, haOpts, stdin, stdout, stderr) +} diff --git a/cmd/hexai-tmux-action/main_test.go b/cmd/hexai-tmux-action/main_test.go new file mode 100644 index 0000000..8abd420 --- /dev/null +++ b/cmd/hexai-tmux-action/main_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "errors" + "io" + "testing" + + "codeberg.org/snonux/hexai/internal/hexaiaction" +) + +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 { + gotOpts = opts + return nil + } + + opts := actionOptions{ + infile: "in.txt", outfile: "out.txt", + tmuxSplit: "h", tmuxPercent: 50, + } + if err := run(opts, nil, nil, nil); err != nil { + t.Fatalf("run: %v", err) + } + if gotOpts.Infile != "in.txt" || gotOpts.Outfile != "out.txt" { + t.Fatalf("unexpected opts: %+v", gotOpts) + } + if gotOpts.TmuxSplit != "h" || gotOpts.TmuxPercent != 50 { + t.Fatalf("unexpected tmux opts: %+v", gotOpts) + } +} + +func TestRun_WithConfigPath(t *testing.T) { + old := runCommand + t.Cleanup(func() { runCommand = old }) + + runCommand = func(_ context.Context, _ hexaiaction.Options, _ io.Reader, _, _ io.Writer) error { + return nil + } + + opts := actionOptions{configPath: " /tmp/test.toml ", tmuxSplit: "v", tmuxPercent: 33} + if err := 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 { + return wantErr + } + + if err := run(actionOptions{}, nil, nil, nil); !errors.Is(err, wantErr) { + t.Fatalf("expected error, got: %v", err) + } +} diff --git a/cmd/hexai-tmux-edit/main.go b/cmd/hexai-tmux-edit/main.go index ea3330b..6d0e75e 100644 --- a/cmd/hexai-tmux-edit/main.go +++ b/cmd/hexai-tmux-edit/main.go @@ -21,6 +21,9 @@ import ( "codeberg.org/snonux/hexai/internal/tmuxedit" ) +// runTmuxEdit is the seam for testing: override in tests to avoid real tmux. +var runTmuxEdit = tmuxedit.Run + func main() { defaultPath := appconfig.DefaultConfigPath() configPath := flag.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultPath)) @@ -28,13 +31,19 @@ func main() { pane := flag.String("pane", "", "tmux target pane ID (e.g. %%5)") flag.Parse() - opts := tmuxedit.Options{ - ConfigPath: strings.TrimSpace(*configPath), - Agent: strings.TrimSpace(*agent), - Pane: strings.TrimSpace(*pane), - } - if err := tmuxedit.Run(opts); err != nil { + opts := buildOptions(*configPath, *agent, *pane) + if err := runTmuxEdit(opts); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } + +// buildOptions constructs tmuxedit.Options from the parsed flag values, +// trimming whitespace from each field. +func buildOptions(configPath, agent, pane string) tmuxedit.Options { + return tmuxedit.Options{ + ConfigPath: strings.TrimSpace(configPath), + Agent: strings.TrimSpace(agent), + Pane: strings.TrimSpace(pane), + } +} diff --git a/cmd/hexai-tmux-edit/main_test.go b/cmd/hexai-tmux-edit/main_test.go new file mode 100644 index 0000000..fc2364c --- /dev/null +++ b/cmd/hexai-tmux-edit/main_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "errors" + "testing" + + "codeberg.org/snonux/hexai/internal/tmuxedit" +) + +func TestBuildOptions_AllEmpty(t *testing.T) { + opts := buildOptions("", "", "") + if opts.ConfigPath != "" || opts.Agent != "" || opts.Pane != "" { + t.Fatalf("expected all empty, got %+v", opts) + } +} + +func TestBuildOptions_TrimsWhitespace(t *testing.T) { + opts := buildOptions(" /tmp/cfg.toml ", " claude ", " %5 ") + if opts.ConfigPath != "/tmp/cfg.toml" { + t.Fatalf("expected trimmed config path, got %q", opts.ConfigPath) + } + if opts.Agent != "claude" { + t.Fatalf("expected trimmed agent, got %q", opts.Agent) + } + if opts.Pane != "%5" { + t.Fatalf("expected trimmed pane, got %q", opts.Pane) + } +} + +func TestRunTmuxEdit_Success(t *testing.T) { + old := runTmuxEdit + t.Cleanup(func() { runTmuxEdit = old }) + + var gotOpts tmuxedit.Options + runTmuxEdit = func(opts tmuxedit.Options) error { + gotOpts = opts + return nil + } + + opts := buildOptions("/tmp/cfg.toml", "cursor", "%3") + if err := runTmuxEdit(opts); err != nil { + t.Fatalf("runTmuxEdit: %v", err) + } + if gotOpts.ConfigPath != "/tmp/cfg.toml" || gotOpts.Agent != "cursor" || gotOpts.Pane != "%3" { + t.Fatalf("unexpected opts: %+v", gotOpts) + } +} + +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 } + + if err := runTmuxEdit(tmuxedit.Options{}); !errors.Is(err, wantErr) { + t.Fatalf("expected error, got: %v", err) + } +} diff --git a/cmd/hexai/main_test.go b/cmd/hexai/main_test.go index 531a11f..7c20cc4 100644 --- a/cmd/hexai/main_test.go +++ b/cmd/hexai/main_test.go @@ -5,6 +5,8 @@ import ( "os" "strings" "testing" + + "codeberg.org/snonux/hexai/internal/appconfig" ) func TestMain_Version(t *testing.T) { @@ -25,6 +27,115 @@ func TestMain_Version(t *testing.T) { } } +func TestSplitConfigPath(t *testing.T) { + tests := []struct { + name string + args []string + wantPath string + wantRest []string + }{ + { + name: "no args", + args: nil, + wantPath: "", + wantRest: []string{}, + }, + { + name: "no config flag", + args: []string{"-version", "hello"}, + wantPath: "", + wantRest: []string{"-version", "hello"}, + }, + { + name: "--config with separate value", + args: []string{"--config", "/tmp/cfg.toml", "-version"}, + wantPath: "/tmp/cfg.toml", + wantRest: []string{"-version"}, + }, + { + name: "-config with separate value", + args: []string{"-config", "/tmp/cfg.toml", "extra"}, + wantPath: "/tmp/cfg.toml", + wantRest: []string{"extra"}, + }, + { + name: "--config= form", + args: []string{"--config=/tmp/cfg.toml", "extra"}, + wantPath: "/tmp/cfg.toml", + wantRest: []string{"extra"}, + }, + { + name: "-config= form", + args: []string{"-config=/tmp/cfg.toml", "extra"}, + wantPath: "/tmp/cfg.toml", + wantRest: []string{"extra"}, + }, + { + name: "--config as last arg without value", + args: []string{"extra", "--config"}, + wantPath: "", + wantRest: []string{"extra"}, + }, + { + name: "-config as last arg without value", + args: []string{"-config"}, + wantPath: "", + wantRest: []string{}, + }, + { + name: "path with whitespace is trimmed", + args: []string{"--config", " /tmp/cfg.toml "}, + wantPath: "/tmp/cfg.toml", + wantRest: []string{}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotPath, gotRest := splitConfigPath(tc.args) + if gotPath != tc.wantPath { + t.Errorf("path = %q, want %q", gotPath, tc.wantPath) + } + if len(gotRest) != len(tc.wantRest) { + t.Fatalf("rest len = %d, want %d; got %v", len(gotRest), len(tc.wantRest), gotRest) + } + for i := range gotRest { + if gotRest[i] != tc.wantRest[i] { + t.Errorf("rest[%d] = %q, want %q", i, gotRest[i], tc.wantRest[i]) + } + } + }) + } +} + +func TestPickDefaultModel(t *testing.T) { + cfg := appconfig.App{ + OllamaModel: "llama3", + AnthropicModel: "claude-sonnet", + OpenAIModel: "gpt-4o", + } + tests := []struct { + provider string + want string + }{ + {"ollama", "llama3"}, + {"Ollama", "llama3"}, + {" OLLAMA ", "llama3"}, + {"anthropic", "claude-sonnet"}, + {"Anthropic", "claude-sonnet"}, + {"openai", "gpt-4o"}, + {"unknown-provider", "gpt-4o"}, + {"", "gpt-4o"}, + } + for _, tc := range tests { + t.Run(tc.provider, func(t *testing.T) { + got := pickDefaultModel(cfg, tc.provider) + if got != tc.want { + t.Errorf("pickDefaultModel(%q) = %q, want %q", tc.provider, got, tc.want) + } + }) + } +} + func TestMain_TPSSimulation(t *testing.T) { oldArgs := os.Args defer func() { os.Args = oldArgs }() |
