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 | |
| 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>
| -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 | ||||
| -rw-r--r-- | internal/editor/editor_test.go | 140 | ||||
| -rw-r--r-- | internal/gotest/heuristics_test.go | 120 | ||||
| -rw-r--r-- | internal/hexaicli/run_output_test.go | 538 | ||||
| -rw-r--r-- | internal/hexaimcp/run_test.go | 314 | ||||
| -rw-r--r-- | internal/runtimeconfig/store_test.go | 279 | ||||
| -rw-r--r-- | internal/stats/stats_test.go | 250 | ||||
| -rw-r--r-- | internal/testutil/fixtures_test.go | 41 | ||||
| -rw-r--r-- | internal/tmux/status_coverage_test.go | 418 | ||||
| -rw-r--r-- | internal/tmuxedit/agent_test.go | 46 | ||||
| -rw-r--r-- | internal/tmuxedit/agentutil_test.go | 60 | ||||
| -rw-r--r-- | internal/tmuxedit/claude_agent_test.go | 63 | ||||
| -rw-r--r-- | internal/tmuxedit/cursor_agent_test.go | 49 | ||||
| -rw-r--r-- | internal/tmuxedit/history_test.go | 128 | ||||
| -rw-r--r-- | internal/tmuxedit/run_test.go | 101 | ||||
| -rw-r--r-- | internal/version.go | 2 |
22 files changed, 3043 insertions, 34 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 }() diff --git a/internal/editor/editor_test.go b/internal/editor/editor_test.go index 06cc165..260fb85 100644 --- a/internal/editor/editor_test.go +++ b/internal/editor/editor_test.go @@ -1,11 +1,32 @@ package editor import ( + "errors" "os" "path/filepath" "testing" ) +// TestRunEditor_Default exercises the default RunEditor function with a harmless command. +func TestRunEditor_Default(t *testing.T) { + t.Setenv("HEXAI_EDITOR", "true") // /usr/bin/true — exits 0 immediately + tmp := filepath.Join(t.TempDir(), "test.md") + if err := os.WriteFile(tmp, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + if err := RunEditor("true", tmp); err != nil { + t.Fatalf("RunEditor with 'true': %v", err) + } +} + +// TestRunEditor_Default_BadCommand verifies RunEditor returns an error for a nonexistent command. +func TestRunEditor_Default_BadCommand(t *testing.T) { + err := RunEditor("nonexistent-editor-cmd-12345", "/dev/null") + if err == nil { + t.Fatal("expected error for nonexistent editor command") + } +} + func TestResolve_EnvPriority(t *testing.T) { t.Setenv("HEXAI_EDITOR", "ed1") t.Setenv("EDITOR", "ed2") @@ -20,6 +41,26 @@ func TestResolve_EnvPriority(t *testing.T) { } } +// TestResolve_NoEditor verifies the error when neither HEXAI_EDITOR nor EDITOR is set. +func TestResolve_NoEditor(t *testing.T) { + t.Setenv("HEXAI_EDITOR", "") + t.Setenv("EDITOR", "") + _, err := Resolve() + if err == nil { + t.Fatal("expected error when no editor is configured") + } +} + +// TestResolve_WhitespaceOnly verifies that whitespace-only values are treated as empty. +func TestResolve_WhitespaceOnly(t *testing.T) { + t.Setenv("HEXAI_EDITOR", " ") + t.Setenv("EDITOR", " \t ") + _, err := Resolve() + if err == nil { + t.Fatal("expected error for whitespace-only editor values") + } +} + func TestOpenTempAndEdit_UsesRunEditor(t *testing.T) { old := RunEditor t.Cleanup(func() { RunEditor = old }) @@ -42,3 +83,102 @@ func TestOpenTempAndEdit_UsesRunEditor(t *testing.T) { t.Fatalf("expected .md suffix: %s", capturedPath) } } + +// TestOpenTempAndEdit_NoEditor verifies error propagation when no editor is configured. +func TestOpenTempAndEdit_NoEditor(t *testing.T) { + t.Setenv("HEXAI_EDITOR", "") + t.Setenv("EDITOR", "") + _, err := OpenTempAndEdit(nil) + if err == nil { + t.Fatal("expected error when no editor is set") + } +} + +// 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(editor, path string) error { + // simulate user writing content into a file that started empty + return os.WriteFile(path, []byte("result"), 0o600) + } + out, err := OpenTempAndEdit(nil) + if err != nil { + t.Fatalf("OpenTempAndEdit with nil initial: %v", err) + } + if out != "result" { + t.Fatalf("unexpected content: %q", out) + } +} + +// 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(editor, path string) error { + return os.WriteFile(path, []byte(" trimmed "), 0o600) + } + out, err := OpenTempAndEdit([]byte{}) + if err != nil { + t.Fatalf("OpenTempAndEdit with empty initial: %v", err) + } + if out != "trimmed" { + t.Fatalf("expected trimmed content, got %q", out) + } +} + +// 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(editor, path string) error { + return editorErr + } + _, err := OpenTempAndEdit([]byte("some content")) + if err == nil { + t.Fatal("expected error when editor fails") + } + if !errors.Is(err, editorErr) { + t.Fatalf("expected editor error, got: %v", err) + } +} + +// 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(editor, path string) error { + // simulate the editor deleting the file + return os.Remove(path) + } + _, err := OpenTempAndEdit([]byte("content")) + if err == nil { + t.Fatal("expected error when temp file is deleted by editor") + } +} + +// 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(editor, path string) error { + capturedPath = path + return os.WriteFile(path, []byte("done"), 0o600) + } + _, err := OpenTempAndEdit(nil) + if err != nil { + t.Fatalf("OpenTempAndEdit: %v", err) + } + // The deferred os.Remove should have cleaned up the temp file + if _, err := os.Stat(capturedPath); !os.IsNotExist(err) { + t.Fatalf("temp file was not cleaned up: %s", capturedPath) + } +} diff --git a/internal/gotest/heuristics_test.go b/internal/gotest/heuristics_test.go index 831262d..6597238 100644 --- a/internal/gotest/heuristics_test.go +++ b/internal/gotest/heuristics_test.go @@ -12,6 +12,22 @@ func TestParsePackageName(t *testing.T) { } } +func TestParsePackageName_TabAfterName(t *testing.T) { + // Covers the tab-trimming branch in ParsePackageName. + lines := []string{"package mypkg\t// tab then comment"} + if got := ParsePackageName(lines); got != "mypkg" { + t.Fatalf("got %q, want %q", got, "mypkg") + } +} + +func TestParsePackageName_SpaceAfterName(t *testing.T) { + // Covers the space-trimming branch (no comment, just trailing space). + lines := []string{"package mypkg "} + if got := ParsePackageName(lines); got != "mypkg" { + t.Fatalf("got %q, want %q", got, "mypkg") + } +} + func TestFindFunctionAtLine_NoBody(t *testing.T) { lines := []string{"func X(a int)", "// comment"} start, end := FindFunctionAtLine(lines, 0) @@ -20,6 +36,81 @@ func TestFindFunctionAtLine_NoBody(t *testing.T) { } } +func TestFindFunctionAtLine_EmptyLines(t *testing.T) { + // Covers the empty-lines early return. + start, end := FindFunctionAtLine([]string{}, 0) + if start != -1 || end != -1 { + t.Fatalf("expected -1,-1 for empty input, got %d,%d", start, end) + } +} + +func TestFindFunctionAtLine_NegativeIdx(t *testing.T) { + // Covers the idx < 0 clamping branch. + lines := []string{"func Foo() {", " return", "}"} + start, end := FindFunctionAtLine(lines, -5) + if start != 0 || end != 2 { + t.Fatalf("expected 0,2 got %d,%d", start, end) + } +} + +func TestFindFunctionAtLine_IdxBeyondEnd(t *testing.T) { + // Covers the idx >= len(lines) clamping branch. + // The last line contains "func " so the backward scan finds it directly. + lines := []string{"package main", "", "func Last() { }"} + start, end := FindFunctionAtLine(lines, 100) + if start != 2 || end != 2 { + t.Fatalf("expected 2,2 got %d,%d", start, end) + } +} + +func TestFindFunctionAtLine_ClosingBraceBeforeFunc(t *testing.T) { + // When scanning backward, hitting '}' before 'func ' means no enclosing function. + lines := []string{"func A() {", "}", " x := 1"} + start, end := FindFunctionAtLine(lines, 2) + if start != -1 || end != -1 { + t.Fatalf("expected -1,-1 got %d,%d", start, end) + } +} + +func TestFindFunctionAtLine_NormalFunction(t *testing.T) { + // Covers the normal path: finding a complete function with braces. + lines := []string{ + "package main", + "", + "func Hello() {", + " fmt.Println(\"hi\")", + "}", + } + start, end := FindFunctionAtLine(lines, 3) + if start != 2 || end != 4 { + t.Fatalf("expected 2,4 got %d,%d", start, end) + } +} + +func TestFindFunctionAtLine_UnclosedBrace(t *testing.T) { + // Covers the branch where opening brace is seen but never closed. + lines := []string{"func Broken() {", " x := 1"} + start, end := FindFunctionAtLine(lines, 0) + if start != 0 || end != -1 { + t.Fatalf("expected 0,-1 for unclosed brace, got %d,%d", start, end) + } +} + +func TestFindFunctionAtLine_NestedBraces(t *testing.T) { + // Covers depth tracking with nested braces. + lines := []string{ + "func Nested() {", + " if true {", + " x := 1", + " }", + "}", + } + start, end := FindFunctionAtLine(lines, 2) + if start != 0 || end != 4 { + t.Fatalf("expected 0,4 got %d,%d", start, end) + } +} + func TestDeriveFuncName(t *testing.T) { if got := DeriveFuncName("func Sum(a int) int { return a }"); got != "Sum" { t.Fatalf("got %q", got) @@ -29,6 +120,28 @@ func TestDeriveFuncName(t *testing.T) { } } +func TestDeriveFuncName_NotAFunc(t *testing.T) { + // Covers the early return when line doesn't start with "func ". + if got := DeriveFuncName("var x = 1"); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +func TestDeriveFuncName_MultiLine(t *testing.T) { + // Covers the firstLine newline-splitting branch. + code := "func Multi() {\n return\n}" + if got := DeriveFuncName(code); got != "Multi" { + t.Fatalf("got %q, want %q", got, "Multi") + } +} + +func TestDeriveFuncName_MethodReceiverNoParenAfter(t *testing.T) { + // Covers the case where receiver is parsed but no '(' follows the name. + if got := DeriveFuncName("func (t *T) "); got != "" { + t.Fatalf("expected empty, got % |
