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 %q", got) + } +} + func TestExportName(t *testing.T) { if got := ExportName("sum"); got != "Sum" { t.Fatalf("got %q", got) @@ -37,3 +150,10 @@ func TestExportName(t *testing.T) { t.Fatalf("got %q", got) } } + +func TestExportName_Empty(t *testing.T) { + // Covers the empty-string early return. + if got := ExportName(""); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} diff --git a/internal/hexaicli/run_output_test.go b/internal/hexaicli/run_output_test.go new file mode 100644 index 0000000..f4e47fe --- /dev/null +++ b/internal/hexaicli/run_output_test.go @@ -0,0 +1,538 @@ +// Summary: Tests for CLI job output writing, result counting, config path context, +// cached output writing, and streaming error paths. +package hexaicli + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + "testing" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/llm" +) + +func TestCliJobResultCount(t *testing.T) { + tests := []struct { + name string + results []*cliJobResult + want int + }{ + {name: "all nil", results: []*cliJobResult{nil, nil}, want: 0}, + {name: "empty slice", results: nil, want: 0}, + {name: "one result", results: []*cliJobResult{{provider: "a"}}, want: 1}, + {name: "mixed", results: []*cliJobResult{{provider: "a"}, nil, {provider: "b"}}, want: 2}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := cliJobResultCount(tc.results); got != tc.want { + t.Fatalf("cliJobResultCount = %d, want %d", got, tc.want) + } + }) + } +} + +func TestWriteCLIJobOutput_WithHeading(t *testing.T) { + var buf bytes.Buffer + res := &cliJobResult{provider: "openai", model: "gpt-4.1", output: "hello world"} + if err := writeCLIJobOutput(&buf, res, true); err != nil { + t.Fatalf("writeCLIJobOutput: %v", err) + } + got := buf.String() + if !strings.Contains(got, "=== openai:gpt-4.1 ===") { + t.Fatalf("expected heading, got %q", got) + } + if !strings.Contains(got, "hello world") { + t.Fatalf("expected output, got %q", got) + } + // Output without trailing newline should get one appended. + if !strings.HasSuffix(got, "\n") { + t.Fatalf("expected trailing newline, got %q", got) + } +} + +func TestWriteCLIJobOutput_WithoutHeading(t *testing.T) { + var buf bytes.Buffer + res := &cliJobResult{provider: "openai", model: "gpt-4.1", output: "hello\n"} + if err := writeCLIJobOutput(&buf, res, false); err != nil { + t.Fatalf("writeCLIJobOutput: %v", err) + } + got := buf.String() + if strings.Contains(got, "===") { + t.Fatalf("expected no heading, got %q", got) + } + // Output already ends with newline; no extra newline should be appended. + if got != "hello\n" { + t.Fatalf("unexpected output %q", got) + } +} + +func TestWriteCLIJobOutput_EmptyOutput(t *testing.T) { + var buf bytes.Buffer + res := &cliJobResult{provider: "p", model: "m", output: ""} + if err := writeCLIJobOutput(&buf, res, true); err != nil { + t.Fatalf("writeCLIJobOutput: %v", err) + } + // Should print heading but no body content. + if !strings.Contains(buf.String(), "=== p:m ===") { + t.Fatalf("expected heading even for empty output, got %q", buf.String()) + } +} + +func TestWriteCLIJobOutputs_SingleResult(t *testing.T) { + var buf bytes.Buffer + results := []*cliJobResult{{provider: "p", model: "m", output: "out"}} + if err := writeCLIJobOutputs(&buf, results); err != nil { + t.Fatalf("writeCLIJobOutputs: %v", err) + } + // Single result: showHeading is false (count == 1). + if strings.Contains(buf.String(), "===") { + t.Fatalf("single result should have no heading, got %q", buf.String()) + } + if !strings.Contains(buf.String(), "out") { + t.Fatalf("expected output, got %q", buf.String()) + } +} + +func TestWriteCLIJobOutputs_MultipleResults(t *testing.T) { + var buf bytes.Buffer + results := []*cliJobResult{ + {provider: "a", model: "m1", output: "first"}, + {provider: "b", model: "m2", output: "second"}, + } + if err := writeCLIJobOutputs(&buf, results); err != nil { + t.Fatalf("writeCLIJobOutputs: %v", err) + } + got := buf.String() + // Multiple results: headings shown. + if !strings.Contains(got, "=== a:m1 ===") || !strings.Contains(got, "=== b:m2 ===") { + t.Fatalf("expected headings for both results, got %q", got) + } + // Separator newline between results. + if !strings.Contains(got, "first") || !strings.Contains(got, "second") { + t.Fatalf("expected both outputs, got %q", got) + } +} + +func TestWriteCLIJobOutputs_WithNils(t *testing.T) { + var buf bytes.Buffer + results := []*cliJobResult{nil, {provider: "a", model: "m", output: "ok"}, nil} + if err := writeCLIJobOutputs(&buf, results); err != nil { + t.Fatalf("writeCLIJobOutputs: %v", err) + } + // Only one non-nil result, so count=1, no heading. + if strings.Contains(buf.String(), "===") { + t.Fatalf("single non-nil result should have no heading, got %q", buf.String()) + } +} + +func TestWriteCLIJobOutputs_Empty(t *testing.T) { + var buf bytes.Buffer + if err := writeCLIJobOutputs(&buf, nil); err != nil { + t.Fatalf("writeCLIJobOutputs: %v", err) + } + if buf.Len() != 0 { + t.Fatalf("expected empty output, got %q", buf.String()) + } +} + +func TestWithCLIConfigPath_And_ConfigPathFromContext(t *testing.T) { + // Normal usage. + ctx := WithCLIConfigPath(context.Background(), "/tmp/config.toml") + if got := configPathFromContext(ctx); got != "/tmp/config.toml" { + t.Fatalf("configPathFromContext = %q, want /tmp/config.toml", got) + } + + // With whitespace trimming. + ctx = WithCLIConfigPath(context.Background(), " /tmp/cfg.toml ") + if got := configPathFromContext(ctx); got != "/tmp/cfg.toml" { + t.Fatalf("configPathFromContext = %q, want /tmp/cfg.toml", got) + } + + // Nil context for WithCLIConfigPath creates a background context. + ctx = WithCLIConfigPath(nil, "/path") + if got := configPathFromContext(ctx); got != "/path" { + t.Fatalf("configPathFromContext = %q, want /path", got) + } + + // Empty context returns empty string. + if got := configPathFromContext(context.Background()); got != "" { + t.Fatalf("configPathFromContext on empty ctx = %q, want empty", got) + } + + // Nil context returns empty string. + if got := configPathFromContext(nil); got != "" { + t.Fatalf("configPathFromContext on nil = %q, want empty", got) + } +} + +func TestWriteCachedCLIJobOutput_StreamOutput(t *testing.T) { + var buf bytes.Buffer + // streamOutput=true, printer=nil => writes to stdout. + if err := writeCachedCLIJobOutput("cached", &buf, nil, 0, true); err != nil { + t.Fatalf("writeCachedCLIJobOutput: %v", err) + } + if buf.String() != "cached" { + t.Fatalf("expected 'cached', got %q", buf.String()) + } +} + +func TestWriteCachedCLIJobOutput_NoStreamNoPrinter(t *testing.T) { + var buf bytes.Buffer + // streamOutput=false, printer=nil => returns nil without writing. + if err := writeCachedCLIJobOutput("cached", &buf, nil, 0, false); err != nil { + t.Fatalf("writeCachedCLIJobOutput: %v", err) + } + if buf.Len() != 0 { + t.Fatalf("expected no output, got %q", buf.String()) + } +} + +// errWriter is an io.Writer that always returns an error. +type errWriter struct{ err error } + +func (e errWriter) Write([]byte) (int, error) { return 0, e.err } + +func TestWriteCLIJobOutput_WriteError(t *testing.T) { + w := errWriter{err: errors.New("write fail")} + res := &cliJobResult{provider: "p", model: "m", output: "out"} + if err := writeCLIJobOutput(w, res, true); err == nil { + t.Fatalf("expected error from failing writer") + } +} + +func TestWriteCLIJobOutputs_WriteError(t *testing.T) { + w := errWriter{err: errors.New("write fail")} + results := []*cliJobResult{{provider: "p", model: "m", output: "out"}} + if err := writeCLIJobOutputs(w, results); err == nil { + t.Fatalf("expected error from failing writer") + } +} + +// streamErrClient is a Streamer that returns a stream error. +type streamErrClient struct { + fakeClient + streamErr error +} + +func (s *streamErrClient) ChatStream(_ context.Context, _ []llm.Message, _ func(string), _ ...llm.RequestOption) error { + return s.streamErr +} + +func TestRunStreamingChat_StreamError(t *testing.T) { + client := &streamErrClient{ + fakeClient: fakeClient{name: "p", model: "m"}, + streamErr: fmt.Errorf("stream broken"), + } + var out bytes.Buffer + _, err := runStreamingChat(context.Background(), client, nil, nil, &out) + if err == nil || !strings.Contains(err.Error(), "stream broken") { + t.Fatalf("expected stream error, got %v", err) + } +} + +// streamWriteErrClient is a Streamer that writes chunks to trigger a write error. +type streamWriteErrClient struct { + fakeClient +} + +func (s *streamWriteErrClient) ChatStream(_ context.Context, _ []llm.Message, onDelta func(string), _ ...llm.RequestOption) error { + onDelta("chunk1") + onDelta("chunk2") + return nil +} + +func TestRunStreamingChat_WriteError(t *testing.T) { + client := &streamWriteErrClient{fakeClient: fakeClient{name: "p", model: "m"}} + w := errWriter{err: errors.New("write fail")} + _, err := runStreamingChat(context.Background(), client, nil, nil, w) + if err == nil || !strings.Contains(err.Error(), "write fail") { + t.Fatalf("expected write error, got %v", err) + } +} + +func TestRunWithClient_NoInput(t *testing.T) { + var out, errb bytes.Buffer + err := RunWithClient(context.Background(), nil, strings.NewReader(""), &out, &errb, &fakeClient{name: "p", model: "m", resp: "out"}) + if err == nil { + t.Fatalf("expected error for no input") + } + if !strings.Contains(errb.String(), "no input provided") { + t.Fatalf("expected no-input error message, got %q", errb.String()) + } +} + +func TestRunWithClient_Success(t *testing.T) { + var out, errb bytes.Buffer + client := &fakeClient{name: "p", model: "m", resp: "result"} + err := RunWithClient(context.Background(), []string{"hello"}, strings.NewReader(""), &out, &errb, client) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.String() != "result" { + t.Fatalf("stdout = %q, want result", out.String()) + } + if !strings.Contains(errb.String(), "provider=p model=m") { + t.Fatalf("expected summary in stderr, got %q", errb.String()) + } +} + +func TestEffectiveModel_Empty(t *testing.T) { + client := &fakeClient{name: "p", model: "default-model"} + req := requestArgs{model: ""} + if got := effectiveModel(req, client); got != "default-model" { + t.Fatalf("effectiveModel = %q, want default-model", got) + } +} + +func TestEffectiveModel_Whitespace(t *testing.T) { + client := &fakeClient{name: "p", model: "default-model"} + req := requestArgs{model: " "} + if got := effectiveModel(req, client); got != "default-model" { + t.Fatalf("effectiveModel = %q, want default-model", got) + } +} + +func TestRunSimpleChat_WriteError(t *testing.T) { + client := &fakeClient{name: "p", model: "m", resp: "ok"} + w := errWriter{err: errors.New("write fail")} + _, err := runSimpleChat(context.Background(), client, nil, nil, w) + if err == nil || !strings.Contains(err.Error(), "write fail") { + t.Fatalf("expected write error, got %v", err) + } +} + +func TestChooseCLIModel_Empty(t *testing.T) { + if got := chooseCLIModel("", "fallback"); got != "fallback" { + t.Fatalf("chooseCLIModel = %q, want fallback", got) + } +} + +func TestChooseCLIModel_Whitespace(t *testing.T) { + if got := chooseCLIModel(" ", "fallback"); got != "fallback" { + t.Fatalf("chooseCLIModel = %q, want fallback", got) + } +} + +func TestPrintProviderLabel_EmptyModel(t *testing.T) { + var buf bytes.Buffer + printProviderLabel(&buf, "p", "") + if buf.Len() != 0 { + t.Fatalf("expected no output for empty model, got %q", buf.String()) + } +} + +func TestPrintProviderLabel_WhitespaceModel(t *testing.T) { + var buf bytes.Buffer + printProviderLabel(&buf, "p", " ") + if buf.Len() != 0 { + t.Fatalf("expected no output for whitespace model, got %q", buf.String()) + } +} + +func TestCacheHitSummary_NegativeAge(t *testing.T) { + got := cacheHitSummary("p", "m", -5) + if !strings.Contains(got, "cache hit") || !strings.Contains(got, "age=0s") { + t.Fatalf("expected cache hit with age=0s, got %q", got) + } +} + +func TestRunCLIJobs_MultiJob_WritesOutputs(t *testing.T) { + // runCLIJobs with multiple jobs should call writeCLIJobOutputs + // (the non-streaming, non-printer path). + oldNew := newClientFromApp + defer func() { newClientFromApp = oldNew }() + newClientFromApp = func(cfg appconfig.App) (llm.Client, error) { + return &fakeClient{name: cfg.Provider, model: "m", resp: "out-" + cfg.Provider}, nil + } + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + jobs := []cliJob{ + {index: 0, provider: "a", cfg: appconfig.App{Provider: "a", OllamaBaseURL: "http://x", OllamaModel: "m"}, req: requestArgs{model: "m"}}, + {index: 1, provider: "b", cfg: appconfig.App{Provider: "b", OllamaBaseURL: "http://x", OllamaModel: "m"}, req: requestArgs{model: "m"}}, + } + msgs := buildMessages("hello") + var stdout, stderr bytes.Buffer + + // Test writeCLIJobOutputs and writeCLIJobSummaries directly + // since executeCLIJobs with multiple jobs uses a column printer. + _ = jobs + _ = msgs + results := []*cliJobResult{ + {provider: "a", model: "m1", output: "first"}, + {provider: "b", model: "m2", output: "second"}, + } + if err := writeCLIJobOutputs(&stdout, results); err != nil { + t.Fatalf("writeCLIJobOutputs: %v", err) + } + if err := writeCLIJobSummaries(&stderr, results); err != nil { + t.Fatalf("writeCLIJobSummaries: %v", err) + } + + got := stdout.String() + if !strings.Contains(got, "=== a:m1 ===") || !strings.Contains(got, "=== b:m2 ===") { + t.Fatalf("expected headings, got %q", got) + } + + // Also test the runCLIJobs single-job (streaming) path. + singleJobs := []cliJob{ + {index: 0, provider: "a", cfg: appconfig.App{Provider: "a", OllamaBaseURL: "http://x", OllamaModel: "m"}, req: requestArgs{model: "m"}}, + } + stdout.Reset() + stderr.Reset() + if err := runCLIJobs(context.Background(), singleJobs, msgs, "hello", &stdout, &stderr); err != nil { + t.Fatalf("runCLIJobs single: %v", err) + } + if !strings.Contains(stdout.String(), "out-a") { + t.Fatalf("expected single job output, got %q", stdout.String()) + } +} + +func TestWithCLISelection_NilContext(t *testing.T) { + ctx := WithCLISelection(nil, []int{1, 2}) + got := selectionFromContext(ctx) + if len(got) != 2 || got[0] != 1 || got[1] != 2 { + t.Fatalf("unexpected selection: %v", got) + } +} + +func TestPrintCLIHeader_EmptyJobs(t *testing.T) { + var buf bytes.Buffer + printCLIHeader(&buf, nil, nil) + if buf.Len() != 0 { + t.Fatalf("expected no output for empty jobs, got %q", buf.String()) + } +} + +func TestWriteCachedCLIJobOutput_StreamWriteError(t *testing.T) { + w := errWriter{err: errors.New("write fail")} + err := writeCachedCLIJobOutput("data", w, nil, 0, true) + if err == nil || !strings.Contains(err.Error(), "write fail") { + t.Fatalf("expected write error, got %v", err) + } +} + +// chatErrClient fails on Chat but not Name/DefaultModel. +type chatErrClient struct { + fakeClient + chatErr error +} + +func (c *chatErrClient) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { + return "", c.chatErr +} + +func TestRunSimpleChat_ChatError(t *testing.T) { + client := &chatErrClient{ + fakeClient: fakeClient{name: "p", model: "m"}, + chatErr: fmt.Errorf("chat broken"), + } + var out bytes.Buffer + _, err := runSimpleChat(context.Background(), client, nil, nil, &out) + if err == nil || !strings.Contains(err.Error(), "chat broken") { + t.Fatalf("expected chat error, got %v", err) + } +} + +func TestWriteCLIJobSummary_WithError(t *testing.T) { + var buf bytes.Buffer + res := &cliJobResult{provider: "p", model: "m", err: fmt.Errorf("boom"), summary: ""} + if err := writeCLIJobSummary(&buf, res); err != nil { + t.Fatalf("writeCLIJobSummary: %v", err) + } + if !strings.Contains(buf.String(), "boom") || !strings.Contains(buf.String(), "provider=p model=m") { + t.Fatalf("expected error info, got %q", buf.String()) + } +} + +func TestWriteCLIJobSummaries_FirstError(t *testing.T) { + results := []*cliJobResult{ + {provider: "a", model: "m", err: nil}, + {provider: "b", model: "m", err: fmt.Errorf("fail")}, + } + var buf bytes.Buffer + err := writeCLIJobSummaries(&buf, results) + if err == nil || !strings.Contains(err.Error(), "fail") { + t.Fatalf("expected first error, got %v", err) + } +} + +func TestFilterJobsBySelection_Dedup(t *testing.T) { + jobs := []cliJob{{index: 0, provider: "a"}, {index: 1, provider: "b"}} + filtered, err := filterJobsBySelection(jobs, []int{0, 0, 1}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(filtered) != 2 { + t.Fatalf("expected 2 jobs after dedup, got %d", len(filtered)) + } +} + +func TestWriteCLIJobOutputs_SeparatorBetweenMultiple(t *testing.T) { + var buf bytes.Buffer + results := []*cliJobResult{ + {provider: "a", model: "m", output: "one\n"}, + nil, + {provider: "b", model: "m", output: "two\n"}, + } + if err := writeCLIJobOutputs(&buf, results); err != nil { + t.Fatalf("writeCLIJobOutputs: %v", err) + } + got := buf.String() + // Should have a blank line separator between the two non-nil results. + if !strings.Contains(got, "one\n\n") { + t.Fatalf("expected separator between results, got %q", got) + } +} + +func TestRunChatRequest_NonStreamer(t *testing.T) { + client := &fakeClient{name: "p", model: "m", resp: "hello"} + var out bytes.Buffer + got, err := runChatRequest(context.Background(), client, requestArgs{model: "m"}, nil, &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "hello" { + t.Fatalf("expected hello, got %q", got) + } +} + +func TestRunChatRequest_Streamer(t *testing.T) { + client := &fakeStreamer{ + fakeClient: fakeClient{name: "p", model: "m"}, + chunks: []string{"a", "b"}, + } + var out bytes.Buffer + got, err := runChatRequest(context.Background(), client, requestArgs{model: "m"}, nil, &out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "ab" { + t.Fatalf("expected ab, got %q", got) + } +} + +func TestSelectionFromContext_Nil(t *testing.T) { + if got := selectionFromContext(nil); got != nil { + t.Fatalf("expected nil, got %v", got) + } +} + +func TestSelectionFromContext_NoValue(t *testing.T) { + if got := selectionFromContext(context.Background()); got != nil { + t.Fatalf("expected nil, got %v", got) + } +} + +func TestFilterJobsBySelection_Empty(t *testing.T) { + jobs := []cliJob{{index: 0, provider: "a"}} + filtered, err := filterJobsBySelection(jobs, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(filtered) != 1 { + t.Fatalf("expected original jobs, got %d", len(filtered)) + } +} diff --git a/internal/hexaimcp/run_test.go b/internal/hexaimcp/run_test.go index 3c3f9d8..7883efd 100644 --- a/internal/hexaimcp/run_test.go +++ b/internal/hexaimcp/run_test.go @@ -340,3 +340,317 @@ func TestRunWithFactory_ServerError(t *testing.T) { t.Errorf("RunWithFactory() error = %v, want to contain 'server error'", err) } } + +// TestRunWithFactory_LoggerError verifies that a bad log path propagates as an error. +func TestRunWithFactory_LoggerError(t *testing.T) { + // Use /dev/null/impossible as log path — directory creation will fail + // because /dev/null is a file, not a directory. + badLogPath := "/dev/null/impossible/test.log" + + mockFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore, syncer mcp.SlashCommandSyncer) ServerRunner { + return &mockServerRunner{} + } + + err := RunWithFactory(badLogPath, "", &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}, mockFactory) + if err == nil { + t.Fatal("expected error for invalid log path, got nil") + } + if !strings.Contains(err.Error(), "cannot setup logger") { + t.Errorf("error = %v, want to contain 'cannot setup logger'", err) + } +} + +// TestRunWithFactory_StderrLogger verifies RunWithFactory works when logPath +// is empty (logger writes to stderr, defer close branch is a no-op). +func TestRunWithFactory_StderrLogger(t *testing.T) { + tmpDir := t.TempDir() + + mockFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore, syncer mcp.SlashCommandSyncer) ServerRunner { + return &mockServerRunner{} + } + + oldEnv := os.Getenv("HEXAI_MCP_PROMPTS_DIR") + defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldEnv) + os.Setenv("HEXAI_MCP_PROMPTS_DIR", tmpDir) + + // Empty logPath causes logger to write to stderr (no file to close) + err := RunWithFactory("", "", &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}, mockFactory) + if err != nil { + t.Fatalf("RunWithFactory() error = %v", err) + } +} + +// TestRun_CallsDefaultFactory verifies the Run() entry point invokes +// RunWithFactory with the defaultServerFactory. The real server reads +// from stdin until EOF; with an empty buffer it returns immediately. +func TestRun_CallsDefaultFactory(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "test.log") + + oldEnv := os.Getenv("HEXAI_MCP_PROMPTS_DIR") + defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldEnv) + os.Setenv("HEXAI_MCP_PROMPTS_DIR", tmpDir) + + // Run with empty stdin — the real server hits EOF and exits cleanly. + // This exercises the full Run -> RunWithFactory -> defaultServerFactory path. + err := Run(logPath, "", &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}) + // The server may return nil or an error depending on how it handles EOF; + // the important thing is that Run() itself does not panic. + _ = err +} + +// TestSetupLogger_InvalidPath verifies setupLogger returns an error when +// the log directory cannot be created. +func TestSetupLogger_InvalidPath(t *testing.T) { + // /dev/null is a file, so creating a subdirectory under it fails + _, err := setupLogger("/dev/null/subdir/test.log") + if err == nil { + t.Fatal("expected error for invalid log path, got nil") + } + if !strings.Contains(err.Error(), "cannot create log directory") { + t.Errorf("error = %v, want to contain 'cannot create log directory'", err) + } +} + +// TestSetupLogger_WhitespacePath verifies that a whitespace-only path +// falls back to stderr logging. +func TestSetupLogger_WhitespacePath(t *testing.T) { + logger, err := setupLogger(" ") + if err != nil { + t.Fatalf("setupLogger() error = %v", err) + } + if logger == nil { + t.Fatal("setupLogger() returned nil logger") + } +} + +// TestGetPromptsDir_XDGDataHome verifies getPromptsDir uses XDG_DATA_HOME +// when set (covers the branch where XDG_DATA_HOME is non-empty). +func TestGetPromptsDir_XDGDataHome(t *testing.T) { + oldPrompts := os.Getenv("HEXAI_MCP_PROMPTS_DIR") + defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldPrompts) + os.Setenv("HEXAI_MCP_PROMPTS_DIR", "") + + oldXDG := os.Getenv("XDG_DATA_HOME") + defer os.Setenv("XDG_DATA_HOME", oldXDG) + os.Setenv("XDG_DATA_HOME", "/custom/xdg/data") + + cfg := appconfig.App{} + result, err := getPromptsDir(cfg) + if err != nil { + t.Fatalf("getPromptsDir() error = %v", err) + } + + want := "/custom/xdg/data/prompts" + if result != want { + t.Errorf("getPromptsDir() = %v, want %v", result, want) + } +} + +// TestGetPromptsDir_TildeInConfig verifies tilde expansion for config path. +func TestGetPromptsDir_TildeInConfig(t *testing.T) { + oldPrompts := os.Getenv("HEXAI_MCP_PROMPTS_DIR") + defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldPrompts) + os.Setenv("HEXAI_MCP_PROMPTS_DIR", "") + + cfg := appconfig.App{ + MCPPromptsDir: "~/my-prompts", + } + + result, err := getPromptsDir(cfg) + if err != nil { + t.Fatalf("getPromptsDir() error = %v", err) + } + + // Should not contain tilde and should be absolute + if strings.Contains(result, "~") { + t.Errorf("getPromptsDir() = %v, tilde not expanded", result) + } + if !filepath.IsAbs(result) { + t.Errorf("getPromptsDir() = %v, want absolute path", result) + } + if !strings.HasSuffix(result, "my-prompts") { + t.Errorf("getPromptsDir() = %v, want suffix 'my-prompts'", result) + } +} + +// TestCreateSyncer_Disabled verifies createSyncer returns a non-nil syncer +// when sync is disabled. +func TestCreateSyncer_Disabled(t *testing.T) { + logger := log.New(io.Discard, "", 0) + cfg := appconfig.App{ + MCPSlashCommandSync: false, + } + + syncer, err := createSyncer(cfg, logger) + if err != nil { + t.Fatalf("createSyncer() error = %v", err) + } + if syncer == nil { + t.Fatal("createSyncer() returned nil syncer") + } +} + +// TestCreateSyncer_Enabled verifies createSyncer when sync is enabled +// with a valid temporary directory. +func TestCreateSyncer_Enabled(t *testing.T) { + tmpDir := t.TempDir() + logger := log.New(io.Discard, "", 0) + cfg := appconfig.App{ + MCPSlashCommandSync: true, + MCPSlashCommandDir: tmpDir, + } + + syncer, err := createSyncer(cfg, logger) + if err != nil { + t.Fatalf("createSyncer() error = %v", err) + } + if syncer == nil { + t.Fatal("createSyncer() returned nil syncer") + } +} + +// TestCreateSyncer_Error verifies createSyncer returns an error when sync +// is enabled but the directory config is empty. +func TestCreateSyncer_Error(t *testing.T) { + logger := log.New(io.Discard, "", 0) + cfg := appconfig.App{ + MCPSlashCommandSync: true, + MCPSlashCommandDir: "", // empty directory triggers error + } + + _, err := createSyncer(cfg, logger) + if err == nil { + t.Fatal("createSyncer() expected error for empty dir, got nil") + } +} + +// TestRunBackfill_FullHappyPath verifies the happy path of RunBackfill by +// providing a config file with a valid slash command directory and prompts dir. +func TestRunBackfill_FullHappyPath(t *testing.T) { + tmpDir := t.TempDir() + promptsDir := filepath.Join(tmpDir, "prompts") + cmdDir := filepath.Join(tmpDir, "commands") + logPath := filepath.Join(tmpDir, "test.log") + + // Create prompts directory so the store can be initialized + if err := os.MkdirAll(promptsDir, 0o755); err != nil { + t.Fatalf("cannot create prompts dir: %v", err) + } + + // Set environment to control prompts and slash command directories + oldPrompts := os.Getenv("HEXAI_MCP_PROMPTS_DIR") + defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldPrompts) + os.Setenv("HEXAI_MCP_PROMPTS_DIR", promptsDir) + + // Write a config file with [mcp] section that sets the slash command dir + cfgContent := fmt.Sprintf("[mcp]\nslashcommand_dir = %q\nslashcommand_sync = true\n", cmdDir) + cfgPath := filepath.Join(tmpDir, "config.toml") + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { + t.Fatalf("cannot write config: %v", err) + } + + // RunBackfill should succeed: config sets MCPSlashCommandDir, prompts + // dir exists, and SyncAll on an empty store is a no-op. + err := RunBackfill(logPath, cfgPath) + if err != nil { + t.Fatalf("RunBackfill() error = %v", err) + } + + // Verify log file was created + if _, statErr := os.Stat(logPath); os.IsNotExist(statErr) { + t.Error("log file was not created") + } +} + +// TestRunBackfill_CreateSyncerError verifies RunBackfill propagates +// syncer creation errors (e.g. when MCPSlashCommandDir is set but +// the syncer cannot be created due to an invalid path). +func TestRunBackfill_CreateSyncerError(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "test.log") + + // Use /dev/null as the slash command dir — creating subdirs under + // /dev/null will fail, which triggers a syncer creation error. + cfgContent := "[mcp]\nslashcommand_dir = \"/dev/null/impossible\"\n" + cfgPath := filepath.Join(tmpDir, "config.toml") + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { + t.Fatalf("cannot write config: %v", err) + } + + err := RunBackfill(logPath, cfgPath) + if err == nil { + t.Fatal("expected error for invalid slash command dir, got nil") + } + if !strings.Contains(err.Error(), "cannot create syncer") { + t.Errorf("error = %v, want to contain 'cannot create syncer'", err) + } +} + +// TestRunBackfill_StderrLogger verifies RunBackfill works when logPath +// is empty (logger writes to stderr). +func TestRunBackfill_StderrLogger(t *testing.T) { + tmpDir := t.TempDir() + promptsDir := filepath.Join(tmpDir, "prompts") + cmdDir := filepath.Join(tmpDir, "commands") + + if err := os.MkdirAll(promptsDir, 0o755); err != nil { + t.Fatalf("cannot create prompts dir: %v", err) + } + + oldPrompts := os.Getenv("HEXAI_MCP_PROMPTS_DIR") + defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldPrompts) + os.Setenv("HEXAI_MCP_PROMPTS_DIR", promptsDir) + + cfgContent := fmt.Sprintf("[mcp]\nslashcommand_dir = %q\n", cmdDir) + cfgPath := filepath.Join(tmpDir, "config.toml") + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { + t.Fatalf("cannot write config: %v", err) + } + + // Empty logPath — logger writes to stderr, defer close is a no-op + err := RunBackfill("", cfgPath) + if err != nil { + t.Fatalf("RunBackfill() error = %v", err) + } +} + +// TestRunBackfill_LoggerError verifies RunBackfill returns an error when +// the log path is invalid. +func TestRunBackfill_LoggerError(t *testing.T) { + err := RunBackfill("/dev/null/impossible/test.log", "") + if err == nil { + t.Fatal("expected error for invalid log path, got nil") + } + if !strings.Contains(err.Error(), "cannot setup logger") { + t.Errorf("error = %v, want to contain 'cannot setup logger'", err) + } +} + +// TestRunBackfill_NoCmdDir verifies RunBackfill returns an error when +// slash command directory is not configured. Uses a nonexistent config +// path and unsets relevant env vars to avoid picking up real config. +func TestRunBackfill_NoCmdDir(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "test.log") + + // Write an empty config file so loadConfig doesn't fall back to + // the user's real global config or project config. + emptyCfgPath := filepath.Join(tmpDir, "empty.toml") + if err := os.WriteFile(emptyCfgPath, []byte(""), 0o644); err != nil { + t.Fatalf("cannot write empty config: %v", err) + } + + // Unset env var that could set the slash command dir + oldEnv := os.Getenv("HEXAI_MCP_SLASHCOMMAND_DIR") + defer os.Setenv("HEXAI_MCP_SLASHCOMMAND_DIR", oldEnv) + os.Setenv("HEXAI_MCP_SLASHCOMMAND_DIR", "") + + err := RunBackfill(logPath, emptyCfgPath) + if err == nil { + t.Fatal("expected error for empty slash command dir, got nil") + } + if !strings.Contains(err.Error(), "commands directory not configured") { + t.Errorf("error = %v, want to contain 'commands directory not configured'", err) + } +} diff --git a/internal/runtimeconfig/store_test.go b/internal/runtimeconfig/store_test.go index 168d2cd..906d7f6 100644 --- a/internal/runtimeconfig/store_test.go +++ b/internal/runtimeconfig/store_test.go @@ -97,6 +97,285 @@ func TestStoreReloadLogsSummary(t *testing.T) { } } +func TestSubscribe_NilListener(t *testing.T) { + store := New(appconfig.App{}) + unsub := store.Subscribe(nil) + // Should return a no-op unsubscribe without panicking. + unsub() +} + +func TestSubscribe_ReceivesUpdates(t *testing.T) { + store := New(appconfig.App{MaxTokens: 100}) + + var gotOld, gotNew appconfig.App + callCount := 0 + unsub := store.Subscribe(func(old, new appconfig.App) { + gotOld = old + gotNew = new + callCount++ + }) + + store.Set(appconfig.App{MaxTokens: 200}) + if callCount != 1 { + t.Fatalf("expected listener called once, got %d", callCount) + } + if gotOld.MaxTokens != 100 || gotNew.MaxTokens != 200 { + t.Fatalf("unexpected old/new: %d / %d", gotOld.MaxTokens, gotNew.MaxTokens) + } + + // After unsubscribe, listener must not be called again. + unsub() + store.Set(appconfig.App{MaxTokens: 300}) + if callCount != 1 { + t.Fatalf("expected listener not called after unsubscribe, got %d", callCount) + } +} + +func TestSubscribe_MultipleListeners(t *testing.T) { + store := New(appconfig.App{}) + calls := [2]int{} + unsub0 := store.Subscribe(func(_, _ appconfig.App) { calls[0]++ }) + unsub1 := store.Subscribe(func(_, _ appconfig.App) { calls[1]++ }) + + store.Set(appconfig.App{MaxTokens: 1}) + if calls[0] != 1 || calls[1] != 1 { + t.Fatalf("expected both listeners called once: %v", calls) + } + + // Unsubscribe first listener only. + unsub0() + store.Set(appconfig.App{MaxTokens: 2}) + if calls[0] != 1 || calls[1] != 2 { + t.Fatalf("expected only second listener called: %v", calls) + } + unsub1() +} + +func TestSet_ReturnsChanges(t *testing.T) { + store := New(appconfig.App{MaxTokens: 10, Provider: "ollama"}) + changes := store.Set(appconfig.App{MaxTokens: 20, Provider: "ollama"}) + found := false + for _, ch := range changes { + if ch.Key == "max_tokens" { + found = true + if ch.Old != "10" || ch.New != "20" { + t.Fatalf("unexpected change values: %+v", ch) + } + } + } + if !found { + t.Fatalf("expected max_tokens in changes, got %+v", changes) + } +} + +func TestSet_NoChanges(t *testing.T) { + cfg := appconfig.App{MaxTokens: 10} + store := New(cfg) + changes := store.Set(cfg) + if len(changes) != 0 { + t.Fatalf("expected no changes, got %+v", changes) + } +} + +func TestReload_NilLogger(t *testing.T) { + // Reload with nil logger should not panic; it exercises the nil-logger guard + // in Reload (skipping logger.Print). LoadWithOptions returns defaults when + // logger is nil, so the store gets default config applied. + store := New(appconfig.App{MaxTokens: 1}) + changes, err := store.Reload(nil, appconfig.LoadOptions{IgnoreEnv: true}) + if err != nil { + t.Fatalf("reload failed: %v", err) + } + // Config was updated from our custom value (1) to defaults (4000). + if snap := store.Snapshot(); snap.MaxTokens != 4000 { + t.Fatalf("expected default 4000, got %d", snap.MaxTokens) + } + // Should report a change for max_tokens at minimum. + found := false + for _, ch := range changes { + if ch.Key == "max_tokens" { + found = true + } + } + if !found { + t.Fatalf("expected max_tokens change, got %+v", changes) + } +} + +func TestFormatSummary_NoChanges(t *testing.T) { + result := FormatSummary("Test", nil) + if result != "Test (no changes detected)." { + t.Fatalf("unexpected: %q", result) + } +} + +func TestFormatSummary_WithChanges(t *testing.T) { + changes := []Change{ + {Key: "a", Old: "1", New: "2"}, + {Key: "b", Old: "x", New: "y"}, + } + result := FormatSummary("Reloaded", changes) + if !strings.Contains(result, "Reloaded (2 changes):") { + t.Fatalf("missing header: %q", result) + } + if !strings.Contains(result, "- a: 1 → 2") || !strings.Contains(result, "- b: x → y") { + t.Fatalf("missing details: %q", result) + } +} + +func TestStringifyValue_BoolAndFloat(t *testing.T) { + // Exercise the bool and float branches via Diff on App fields. + temp1 := 0.5 + temp2 := 0.9 + oldCfg := appconfig.App{CodingTemperature: &temp1} + newCfg := appconfig.App{CodingTemperature: &temp2} + changes := Diff(oldCfg, newCfg) + found := false + for _, ch := range changes { + if ch.Key == "coding_temperature" { + found = true + if ch.Old != "0.5" || ch.New != "0.9" { + t.Fatalf("unexpected values: %+v", ch) + } + } + } + if !found { + t.Fatalf("expected coding_temperature change, got %+v", changes) + } +} + +func TestStringifyValue_NilPointer(t *testing.T) { + // nil *float64 should produce "(unset)". + oldCfg := appconfig.App{} + temp := 0.3 + newCfg := appconfig.App{CodingTemperature: &temp} + changes := Diff(oldCfg, newCfg) + found := false + for _, ch := range changes { + if ch.Key == "coding_temperature" { + found = true + if ch.Old != "(unset)" { + t.Fatalf("expected (unset) for nil ptr, got %q", ch.Old) + } + } + } + if !found { + t.Fatalf("expected coding_temperature change") + } +} + +func TestStringifyValue_NilBoolPointer(t *testing.T) { + // CompletionWaitAll is *bool; nil should produce "(unset)". + b := true + oldCfg := appconfig.App{} + newCfg := appconfig.App{CompletionWaitAll: &b} + changes := Diff(oldCfg, newCfg) + found := false + for _, ch := range changes { + if ch.Key == "completion_wait_all" { + found = true + if ch.Old != "(unset)" || ch.New != "true" { + t.Fatalf("unexpected: old=%q new=%q", ch.Old, ch.New) + } + } + } + if !found { + t.Fatalf("expected completion_wait_all change") + } +} + +func TestStringifyValue_StringSlice(t *testing.T) { + // TriggerCharacters is []string; exercise the string-slice branch. + oldCfg := appconfig.App{TriggerCharacters: []string{".", ":"}} + newCfg := appconfig.App{TriggerCharacters: []string{".", ":", "("}} + changes := Diff(oldCfg, newCfg) + found := false + for _, ch := range changes { + if ch.Key == "trigger_characters" { + found = true + if ch.Old != ".,::" || ch.New != ".,:,(" { + // Join uses comma separator. + if ch.Old != ".,:" || ch.New != ".,:,(" { + t.Fatalf("unexpected: old=%q new=%q", ch.Old, ch.New) + } + } + } + } + if !found { + t.Fatalf("expected trigger_characters change, got %+v", changes) + } +} + +func TestStringifyValue_NilSlice(t *testing.T) { + // nil slice vs non-nil slice. + oldCfg := appconfig.App{} + newCfg := appconfig.App{TriggerCharacters: []string{"x"}} + changes := Diff(oldCfg, newCfg) + found := false + for _, ch := range changes { + if ch.Key == "trigger_characters" { + found = true + if ch.Old != "" { + t.Fatalf("expected empty for nil slice, got %q", ch.Old) + } + } + } + if !found { + t.Fatalf("expected trigger_characters change") + } +} + +func TestStringifyValue_SurfaceConfigWithTemperature(t *testing.T) { + // Exercise the SurfaceConfig temperature branch. + temp := 0.750 + oldCfg := appconfig.App{ + CompletionConfigs: []appconfig.SurfaceConfig{ + {Provider: "openai", Model: "gpt-4o", Temperature: &temp}, + }, + } + newCfg := appconfig.App{ + CompletionConfigs: []appconfig.SurfaceConfig{ + {Provider: "openai", Model: "gpt-4o"}, + }, + } + changes := Diff(oldCfg, newCfg) + found := false + for _, ch := range changes { + if ch.Key == "completion_configs" { + found = true + if !strings.Contains(ch.Old, "@0.750") { + t.Fatalf("expected temperature in old value, got %q", ch.Old) + } + } + } + if !found { + t.Fatalf("expected completion_configs change") + } +} + +func TestStringifyValue_SurfaceConfigEmptyProvider(t *testing.T) { + // Exercise the SurfaceConfig branch where provider is empty. + oldCfg := appconfig.App{ + ChatConfigs: []appconfig.SurfaceConfig{ + {Provider: "", Model: "some-model"}, + }, + } + newCfg := appconfig.App{} + changes := Diff(oldCfg, newCfg) + found := false + for _, ch := range changes { + if ch.Key == "chat_configs" { + found = true + if ch.Old != "some-model" { + t.Fatalf("expected 'some-model', got %q", ch.Old) + } + } + } + if !found { + t.Fatalf("expected chat_configs change") + } +} + func TestDiff_SurfaceModel(t *testing.T) { oldCfg := appconfig.App{CompletionConfigs: []appconfig.SurfaceConfig{{Provider: "openai", Model: "gpt-4o"}}} newCfg := appconfig.App{CompletionConfigs: []appconfig.SurfaceConfig{{Provider: "anthropic", Model: "claude-3-5-sonnet"}}} diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go index a81e215..75c1c5b 100644 --- a/internal/stats/stats_test.go +++ b/internal/stats/stats_test.go @@ -2,6 +2,8 @@ package stats import ( "context" + "encoding/json" + "os" "path/filepath" "sync" "testing" @@ -83,3 +85,251 @@ func TestCacheDir_XDG(t *testing.T) { t.Fatalf("got %q want %q", got, want) } } + +// TestCacheDir_FallbackHome covers the branch where XDG_CACHE_HOME is unset, +// so CacheDir falls back to $HOME/.local/hexai/cache. +func TestCacheDir_FallbackHome(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", "") + got, err := CacheDir() + if err != nil { + t.Fatal(err) + } + home, _ := os.UserHomeDir() + want := filepath.Join(home, ".local", "hexai", "cache") + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +// TestCacheDir_WhitespaceXDG covers the branch where XDG_CACHE_HOME contains +// only whitespace, which stringsTrim reduces to "" so the fallback is used. +func TestCacheDir_WhitespaceXDG(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", " \t\n ") + got, err := CacheDir() + if err != nil { + t.Fatal(err) + } + home, _ := os.UserHomeDir() + want := filepath.Join(home, ".local", "hexai", "cache") + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +// TestSetWindow_ClampLow covers the branch where d < 1s is clamped to 1s. +func TestSetWindow_ClampLow(t *testing.T) { + SetWindow(100 * time.Millisecond) + got := Window() + if got != time.Second { + t.Fatalf("expected 1s, got %v", got) + } +} + +// TestSetWindow_ClampHigh covers the branch where d > 24h is clamped to 24h. +func TestSetWindow_ClampHigh(t *testing.T) { + SetWindow(48 * time.Hour) + got := Window() + if got != 24*time.Hour { + t.Fatalf("expected 24h, got %v", got) + } + // Restore a reasonable default for other tests. + SetWindow(time.Hour) +} + +// TestStringsTrim_NoTrimNeeded covers the early-return branch where the input +// has no leading or trailing whitespace, so the original string is returned. +func TestStringsTrim_NoTrimNeeded(t *testing.T) { + in := "hello" + got := stringsTrim(in) + if got != "hello" { + t.Fatalf("expected %q, got %q", "hello", got) + } +} + +// TestStringsTrim_AllWhitespace covers trimming a string that is entirely whitespace. +func TestStringsTrim_AllWhitespace(t *testing.T) { + got := stringsTrim(" \t\r\n ") + if got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +// TestStringsTrim_LeadingAndTrailing covers trimming from both ends. +func TestStringsTrim_LeadingAndTrailing(t *testing.T) { + got := stringsTrim(" abc ") + if got != "abc" { + t.Fatalf("expected %q, got %q", "abc", got) + } +} + +// TestStringsTrim_Empty covers the empty string edge case. +func TestStringsTrim_Empty(t *testing.T) { + got := stringsTrim("") + if got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +// TestUpdate_CorruptFile covers the branch where the existing stats file has +// invalid JSON or a wrong version, forcing a reset. +func TestUpdate_CorruptFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + SetWindow(1 * time.Minute) + + // Write a corrupt stats file. + statsDir := filepath.Join(dir, "hexai") + if err := os.MkdirAll(statsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(statsDir, fileName), []byte("{invalid json"), 0o644); err != nil { + t.Fatal(err) + } + + // Update should still succeed: the corrupt file is discarded. + if err := Update(context.Background(), "p", "m", 5, 5); err != nil { + t.Fatalf("update after corrupt file: %v", err) + } + snap, err := TakeSnapshot() + if err != nil { + t.Fatal(err) + } + if snap.Global.Reqs != 1 { + t.Fatalf("expected 1 req, got %d", snap.Global.Reqs) + } +} + +// TestUpdate_WrongVersion covers the branch where the file version does not +// match fileVersion, causing a reset of the file structure. +func TestUpdate_WrongVersion(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + SetWindow(1 * time.Minute) + + statsDir := filepath.Join(dir, "hexai") + if err := os.MkdirAll(statsDir, 0o755); err != nil { + t.Fatal(err) + } + // Write a valid JSON file but with version=99 (wrong). + wrongVer := File{Version: 99, Events: []Event{{TS: time.Now(), Provider: "old", Model: "old", Sent: 100, Recv: 100}}} + b, _ := json.Marshal(wrongVer) + if err := os.WriteFile(filepath.Join(statsDir, fileName), b, 0o644); err != nil { + t.Fatal(err) + } + + if err := Update(context.Background(), "p", "m", 1, 1); err != nil { + t.Fatalf("update: %v", err) + } + snap, err := TakeSnapshot() + if err != nil { + t.Fatal(err) + } + // The old event from version 99 should be discarded. + if snap.Global.Reqs != 1 { + t.Fatalf("expected 1 req after version reset, got %d", snap.Global.Reqs) + } +} + +// TestTakeSnapshot_NoFile covers the ErrNotExist branch in TakeSnapshot. +func TestTakeSnapshot_NoFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + SetWindow(5 * time.Minute) + + snap, err := TakeSnapshot() + if err != nil { + t.Fatal(err) + } + if snap.Global.Reqs != 0 { + t.Fatalf("expected 0 reqs, got %d", snap.Global.Reqs) + } + if snap.Providers == nil { + t.Fatal("expected non-nil Providers map") + } +} + +// TestTakeSnapshot_BadJSON covers the json.Unmarshal error branch in TakeSnapshot. +func TestTakeSnapshot_BadJSON(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + + statsDir := filepath.Join(dir, "hexai") + if err := os.MkdirAll(statsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(statsDir, fileName), []byte("not json"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := TakeSnapshot() + if err == nil { + t.Fatal("expected error for bad JSON, got nil") + } +} + +// TestTakeSnapshot_ZeroWindowSeconds covers the branch where the file has +// WindowSeconds <= 0, causing TakeSnapshot to use the process-level Window(). +func TestTakeSnapshot_ZeroWindowSeconds(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + SetWindow(5 * time.Minute) + + statsDir := filepath.Join(dir, "hexai") + if err := os.MkdirAll(statsDir, 0o755); err != nil { + t.Fatal(err) + } + sf := File{ + Version: fileVersion, + WindowSeconds: 0, // triggers the win <= 0 branch + Events: []Event{{TS: time.Now(), Provider: "p", Model: "m", Sent: 1, Recv: 1}}, + } + b, _ := json.Marshal(sf) + if err := os.WriteFile(filepath.Join(statsDir, fileName), b, 0o644); err != nil { + t.Fatal(err) + } + + snap, err := TakeSnapshot() + if err != nil { + t.Fatal(err) + } + if snap.Window != 5*time.Minute { + t.Fatalf("expected 5m window fallback, got %v", snap.Window) + } + if snap.Global.Reqs != 1 { + t.Fatalf("expected 1 req, got %d", snap.Global.Reqs) + } +} + +// TestUpdate_CancelledContext covers the context cancellation branch in +// acquireFileLock when the lock is already held. +func TestUpdate_CancelledContext(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + SetWindow(1 * time.Minute) + + statsDir := filepath.Join(dir, "hexai") + if err := os.MkdirAll(statsDir, 0o755); err != nil { + t.Fatal(err) + } + + // Hold the lock file to force acquireFileLock to spin. + lockPath := filepath.Join(statsDir, lockFileName) + lf, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatal(err) + } + defer func() { _ = lf.Close() }() + unlock, err := acquireFileLock(context.Background(), lf) + if err != nil { + t.Fatal(err) + } + defer func() { _ = unlock() }() + + // Now try to Update with an already-cancelled context. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err = Update(ctx, "p", "m", 1, 1) + if err == nil { + t.Fatal("expected error from cancelled context, got nil") + } +} diff --git a/internal/testutil/fixtures_test.go b/internal/testutil/fixtures_test.go index 7c7f239..4f6b365 100644 --- a/internal/testutil/fixtures_test.go +++ b/internal/testutil/fixtures_test.go @@ -1,6 +1,9 @@ package testutil -import "testing" +import ( + "strings" + "testing" +) func TestFixtures_ZeroCovTargets(t *testing.T) { if MarkdownCodeFence() == "" { @@ -10,3 +13,39 @@ func TestFixtures_ZeroCovTargets(t *testing.T) { t.Fatal("MalformedJSON empty") } } + +func TestMultilineDocBlock(t *testing.T) { + got := MultilineDocBlock() + if !strings.Contains(got, "\n") { + t.Fatal("expected multi-line string") + } + if !strings.HasPrefix(got, "//") { + t.Fatal("expected comment prefix") + } + if !strings.Contains(got, "sum") { + t.Fatal("expected documentation about sum") + } +} + +func TestMultilineChatReply(t *testing.T) { + got := MultilineChatReply() + if !strings.Contains(got, "\n") { + t.Fatal("expected multi-line string") + } + if !strings.Contains(got, "Hello") { + t.Fatal("expected greeting in reply") + } +} + +func TestMultilineFunctionSuggestion(t *testing.T) { + got := MultilineFunctionSuggestion() + if !strings.Contains(got, "\n") { + t.Fatal("expected multi-line string") + } + if !strings.Contains(got, "context.Context") { + t.Fatal("expected context parameter") + } + if !strings.Contains(got, "return") { + t.Fatal("expected return statement") + } +} diff --git a/internal/tmux/status_coverage_test.go b/internal/tmux/status_coverage_test.go new file mode 100644 index 0000000..8f7c034 --- /dev/null +++ b/internal/tmux/status_coverage_test.go @@ -0,0 +1,418 @@ +package tmux + +import ( + "strings" + "testing" + "time" +) + +// --- Enabled --- + +func TestEnabled_DefaultTrue(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS", "") + if !Enabled() { + t.Fatal("expected Enabled() true when env is empty") + } +} + +func TestEnabled_TruthyValues(t *testing.T) { + for _, v := range []string{"1", "true", "yes", "on", " TRUE ", " On "} { + t.Run(v, func(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS", v) + if !Enabled() { + t.Fatalf("expected Enabled() true for %q", v) + } + }) + } +} + +func TestEnabled_FalsyValues(t *testing.T) { + for _, v := range []string{"0", "false", "no", "off", "random"} { + t.Run(v, func(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS", v) + if Enabled() { + t.Fatalf("expected Enabled() false for %q", v) + } + }) + } +} + +// --- SetUserOption (logic paths, not actual tmux calls) --- + +func TestSetUserOption_DisabledByEnv(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS", "off") + // Should return nil immediately when disabled + if err := SetUserOption("hexai_status", "test"); err != nil { + t.Fatalf("expected nil error when disabled, got %v", err) + } +} + +func TestSetUserOption_EmptyKey(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS", "1") + t.Setenv("TMUX", "/tmp/tmux-1,1,1") + old := lookPath + t.Cleanup(func() { lookPath = old }) + lookPath = func(string) (string, error) { return "/bin/tmux", nil } + // Empty key after trimming should return nil + if err := SetUserOption(" @ ", "test"); err != nil { + t.Fatalf("expected nil for empty key, got %v", err) + } + if err := SetUserOption(" ", "test"); err != nil { + t.Fatalf("expected nil for blank key, got %v", err) + } +} + +// --- SetStatus (just verifies it delegates; no tmux binary needed when disabled) --- + +func TestSetStatus_DisabledNoOp(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS", "off") + if err := SetStatus("anything"); err != nil { + t.Fatalf("expected nil when status disabled, got %v", err) + } +} + +// --- FormatLLMStartStatus --- + +func TestFormatLLMStartStatus(t *testing.T) { + s := FormatLLMStartStatus("openai", "gpt-4.1") + if !strings.Contains(s, "LLM:openai:gpt-4.1") { + t.Fatalf("missing provider:model in %q", s) + } + if !strings.Contains(s, "⏳") { + t.Fatalf("missing hourglass emoji in %q", s) + } + // Should contain baseFGToken placeholders (pre-theme) + if !strings.Contains(s, baseFGToken) { + t.Fatalf("expected baseFGToken placeholder in %q", s) + } +} + +// --- humanWindow --- + +func TestHumanWindow(t *testing.T) { + tests := []struct { + name string + d time.Duration + want string + }{ + {"zero", 0, "?"}, + {"negative", -5 * time.Minute, "?"}, + {"exact hour", time.Hour, "1h"}, + {"two hours", 2 * time.Hour, "2h"}, + {"30 minutes", 30 * time.Minute, "30m"}, + {"90 minutes", 90 * time.Minute, "90m"}, + {"45 minutes", 45 * time.Minute, "45m"}, + {"120 minutes", 120 * time.Minute, "2h"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := humanWindow(tt.d) + if got != tt.want { + t.Errorf("humanWindow(%v) = %q, want %q", tt.d, got, tt.want) + } + }) + } +} + +// --- truncateStatus --- + +func TestTruncateStatus(t *testing.T) { + tests := []struct { + name string + s string + n int + want string + }{ + {"zero limit", "hello", 0, ""}, + {"negative limit", "hello", -1, ""}, + {"within limit", "hi", 5, "hi"}, + {"exact limit", "hello", 5, "hello"}, + {"over limit", "hello world", 5, "hell…"}, + {"limit 1", "hello", 1, "h"}, + {"limit 2", "hello", 2, "h…"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateStatus(tt.s, tt.n) + if got != tt.want { + t.Errorf("truncateStatus(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want) + } + }) + } +} + +// --- stringsTrim --- + +func TestStringsTrim(t *testing.T) { + tests := []struct { + name string + s string + want string + }{ + {"empty", "", ""}, + {"no whitespace", "hello", "hello"}, + {"leading spaces", " hello", "hello"}, + {"trailing spaces", "hello ", "hello"}, + {"both sides", " hello ", "hello"}, + {"tabs", "\thello\t", "hello"}, + {"newlines", "\nhello\n", "hello"}, + {"carriage returns", "\rhello\r", "hello"}, + {"mixed whitespace", " \t\n\rhello \t\n\r", "hello"}, + {"all whitespace", " \t\n ", ""}, + {"internal spaces preserved", "hello world", "hello world"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stringsTrim(tt.s) + if got != tt.want { + t.Errorf("stringsTrim(%q) = %q, want %q", tt.s, got, tt.want) + } + }) + } +} + +// --- maxStatusLen --- + +func TestMaxStatusLen(t *testing.T) { + tests := []struct { + name string + env string + want int + }{ + {"empty", "", 0}, + {"valid", "80", 80}, + {"negative", "-5", 0}, + {"zero", "0", 0}, + {"non-numeric", "abc", 0}, + {"whitespace padded", " 100 ", 100}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS_MAXLEN", tt.env) + got := maxStatusLen() + if got != tt.want { + t.Errorf("maxStatusLen() = %d, want %d (env=%q)", got, tt.want, tt.env) + } + }) + } +} + +// --- narrowEnabled --- + +func TestNarrowEnabled(t *testing.T) { + tests := []struct { + name string + env string + want bool + }{ + {"empty", "", false}, + {"1", "1", true}, + {"true", "true", true}, + {"yes", "yes", true}, + {"on", "on", true}, + {"0", "0", false}, + {"false", "false", false}, + {"random", "random", false}, + {"TRUE uppercase", "TRUE", true}, + {"padded", " 1 ", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS_NARROW", tt.env) + got := narrowEnabled() + if got != tt.want { + t.Errorf("narrowEnabled() = %v, want %v (env=%q)", got, tt.want, tt.env) + } + }) + } +} + +// --- applyTheme --- + +func TestApplyTheme_NoTheme(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS_THEME", "") + t.Setenv("HEXAI_TMUX_STATUS_FG", "") + t.Setenv("HEXAI_TMUX_STATUS_BG", "") + input := baseFGToken + "hello" + arrowUpToken + "up" + arrowDownToken + "down" + got := applyTheme(input) + // Should replace tokens with default fg and default arrow colors + if strings.Contains(got, baseFGToken) { + t.Fatalf("baseFGToken not replaced in %q", got) + } + if !strings.Contains(got, "#[fg=default]") { + t.Fatalf("expected default fg in %q", got) + } + // No wrap, so no bg=default suffix + if strings.HasSuffix(got, "#[fg=default,bg=default]") { + t.Fatalf("should not wrap without theme in %q", got) + } +} + +func TestApplyTheme_PurpleTheme(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS_THEME", "purple") + t.Setenv("HEXAI_TMUX_STATUS_FG", "") + t.Setenv("HEXAI_TMUX_STATUS_BG", "") + input := baseFGToken + "hello" + got := applyTheme(input) + if !strings.Contains(got, "#[fg=white") { + t.Fatalf("expected white fg for purple theme in %q", got) + } + if !strings.Contains(got, "bg=magenta") { + t.Fatalf("expected magenta bg for purple theme in %q", got) + } + if !strings.HasSuffix(got, "#[fg=default,bg=default]") { + t.Fatalf("expected reset suffix for themed output in %q", got) + } +} + +func TestApplyTheme_YellowTheme(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS_THEME", "yellow") + t.Setenv("HEXAI_TMUX_STATUS_FG", "") + t.Setenv("HEXAI_TMUX_STATUS_BG", "") + input := baseFGToken + "test" + arrowUpToken + "u" + arrowDownToken + "d" + got := applyTheme(input) + if !strings.Contains(got, "#[fg=black") { + t.Fatalf("expected black fg for yellow theme in %q", got) + } + if !strings.Contains(got, "bg=yellow") { + t.Fatalf("expected yellow bg in %q", got) + } +} + +func TestApplyTheme_BlueTheme(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS_THEME", "blue") + t.Setenv("HEXAI_TMUX_STATUS_FG", "") + t.Setenv("HEXAI_TMUX_STATUS_BG", "") + input := baseFGToken + "test" + arrowUpToken + "u" + arrowDownToken + "d" + got := applyTheme(input) + if !strings.Contains(got, "#[fg=white") { + t.Fatalf("expected white fg for blue theme in %q", got) + } + if !strings.Contains(got, "bg=blue") { + t.Fatalf("expected blue bg in %q", got) + } +} + +func TestApplyTheme_ExplicitFGBG(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS_THEME", "") + t.Setenv("HEXAI_TMUX_STATUS_FG", "red") + t.Setenv("HEXAI_TMUX_STATUS_BG", "green") + input := baseFGToken + "test" + arrowUpToken + "u" + arrowDownToken + "d" + got := applyTheme(input) + if !strings.Contains(got, "#[fg=red") { + t.Fatalf("expected red fg in %q", got) + } + if !strings.Contains(got, "bg=green") { + t.Fatalf("expected green bg in %q", got) + } + if !strings.HasSuffix(got, "#[fg=default,bg=default]") { + t.Fatalf("expected reset suffix in %q", got) + } +} + +func TestApplyTheme_ExplicitBGOnly(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS_THEME", "") + t.Setenv("HEXAI_TMUX_STATUS_FG", "") + t.Setenv("HEXAI_TMUX_STATUS_BG", "cyan") + input := baseFGToken + "test" + got := applyTheme(input) + // When only bg is set, fg defaults to "default" + if !strings.Contains(got, "#[fg=default") { + t.Fatalf("expected default fg when only bg set in %q", got) + } + if !strings.Contains(got, "bg=cyan") { + t.Fatalf("expected cyan bg in %q", got) + } +} + +func TestApplyTheme_NoTokensInInput(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS_THEME", "") + t.Setenv("HEXAI_TMUX_STATUS_FG", "") + t.Setenv("HEXAI_TMUX_STATUS_BG", "") + // Input without any tokens should pass through unchanged + got := applyTheme("plain text") + if got != "plain text" { + t.Fatalf("expected unchanged output for tokenless input, got %q", got) + } +} + +func TestApplyTheme_ThemeAliases(t *testing.T) { + // Test theme aliases that map to the same preset + aliases := map[string]string{ + "white-on-purple": "magenta", + "magenta": "magenta", + "white-on-magenta": "magenta", + "black-on-yellow": "yellow", + "black-on-gold": "yellow", + "white-on-blue": "blue", + "white-on-navy": "blue", + } + for theme, expectBG := range aliases { + t.Run(theme, func(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS_THEME", theme) + t.Setenv("HEXAI_TMUX_STATUS_FG", "") + t.Setenv("HEXAI_TMUX_STATUS_BG", "") + got := applyTheme(baseFGToken + "x") + if !strings.Contains(got, "bg="+expectBG) { + t.Fatalf("theme %q: expected bg=%s in %q", theme, expectBG, got) + } + }) + } +} + +// --- FormatGlobalStatusColored branch coverage --- + +func TestFormatGlobalStatusColored_EmptyProvider(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS_NARROW", "") + t.Setenv("HEXAI_TMUX_STATUS_MAXLEN", "") + // Empty provider should return head only (no tail) + s := FormatGlobalStatusColored(10, 3.3, 1000, 2000, "", "model", 1.1, 4, time.Hour) + if strings.Contains(s, "|") { + t.Fatalf("expected no tail with empty provider: %q", s) + } +} + +func TestFormatGlobalStatusColored_EmptyModel(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS_NARROW", "") + t.Setenv("HEXAI_TMUX_STATUS_MAXLEN", "") + // Empty model should return head only + s := FormatGlobalStatusColored(10, 3.3, 1000, 2000, "openai", "", 1.1, 4, time.Hour) + if strings.Contains(s, "|") { + t.Fatalf("expected no tail with empty model: %q", s) + } +} + +func TestFormatGlobalStatusColored_WithTail(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS_NARROW", "") + t.Setenv("HEXAI_TMUX_STATUS_MAXLEN", "") + // With valid provider and model, should include tail + s := FormatGlobalStatusColored(10, 3.3, 1000, 2000, "openai", "gpt-4.1", 1.1, 4, time.Hour) + if !strings.Contains(s, "|") || !strings.Contains(s, "openai:gpt-4.1") { + t.Fatalf("expected tail with provider:model: %q", s) + } +} + +func TestFormatGlobalStatusColored_MaxLenTruncatesHead(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS_NARROW", "") + t.Setenv("HEXAI_TMUX_STATUS_THEME", "") + t.Setenv("HEXAI_TMUX_STATUS_FG", "") + t.Setenv("HEXAI_TMUX_STATUS_BG", "") + // Set maxlen very small so even head gets truncated + t.Setenv("HEXAI_TMUX_STATUS_MAXLEN", "5") + s := FormatGlobalStatusColored(10, 3.3, 1000, 2000, "openai", "gpt-4.1", 1.1, 4, time.Hour) + // The string contains control-char tokens; truncateStatus works on raw bytes. + // Just verify it ends with the ellipsis character and is shorter than untruncated. + if !strings.HasSuffix(s, "…") { + t.Fatalf("expected truncated output ending with ellipsis, got %q", s) + } +} + +func TestFormatGlobalStatusColored_MaxLenFitsBoth(t *testing.T) { + t.Setenv("HEXAI_TMUX_STATUS_NARROW", "") + // Set maxlen very large so both head and tail fit + t.Setenv("HEXAI_TMUX_STATUS_MAXLEN", "500") + s := FormatGlobalStatusColored(10, 3.3, 1000, 2000, "openai", "gpt-4.1", 1.1, 4, time.Hour) + if !strings.Contains(s, "|") || !strings.Contains(s, "openai:gpt-4.1") { + t.Fatalf("expected full output with large maxlen: %q", s) + } +} diff --git a/internal/tmuxedit/agent_test.go b/internal/tmuxedit/agent_test.go index 3673d70..8bd1ad4 100644 --- a/internal/tmuxedit/agent_test.go +++ b/internal/tmuxedit/agent_test.go @@ -1,6 +1,8 @@ package tmuxedit import ( + "fmt" + "strings" "testing" ) @@ -86,6 +88,50 @@ func TestBaseAgent_ClearInput_Disabled(t *testing.T) { } } +func TestBaseAgent_ClearInput_EmptyKeys(t *testing.T) { + // clearFirst=true but no clearKeys should be a no-op + b := &baseAgent{clearFirst: true, clearKeys: ""} + err := b.ClearInput("%1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBaseAgent_ClearInput_Enabled(t *testing.T) { + noSleep(t) + var calls []string + oldSend := sendKeys + defer func() { sendKeys = oldSend }() + sendKeys = func(paneID string, keys ...string) error { + calls = append(calls, fmt.Sprintf("send:%s:%s", paneID, strings.Join(keys, ","))) + return nil + } + + b := &baseAgent{clearFirst: true, clearKeys: "C-u"} + err := b.ClearInput("%2") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(calls) != 1 || calls[0] != "send:%2:C-u" { + t.Errorf("expected single C-u send call, got %v", calls) + } +} + +func TestBaseAgent_ClearInput_Error(t *testing.T) { + noSleep(t) + oldSend := sendKeys + defer func() { sendKeys = oldSend }() + sendKeys = func(string, ...string) error { + return fmt.Errorf("send failed") + } + + b := &baseAgent{clearFirst: true, clearKeys: "C-u"} + err := b.ClearInput("%1") + if err == nil { + t.Fatal("expected error from sendClearSequence failure") + } +} + func TestBaseAgent_ExtractPrompt_NoPattern(t *testing.T) { b := &baseAgent{} got := b.ExtractPrompt("some content") diff --git a/internal/tmuxedit/agentutil_test.go b/internal/tmuxedit/agentutil_test.go index 8bf2e64..69111b5 100644 --- a/internal/tmuxedit/agentutil_test.go +++ b/internal/tmuxedit/agentutil_test.go @@ -1,7 +1,9 @@ package tmuxedit import ( + "fmt" "regexp" + "strings" "testing" ) @@ -195,6 +197,64 @@ func TestParseKeyRepeat(t *testing.T) { } } +func TestSendClearSequence_EscapeKey(t *testing.T) { + var calls []string + oldSend := sendKeys + defer func() { sendKeys = oldSend }() + sendKeys = func(paneID string, keys ...string) error { + calls = append(calls, strings.Join(keys, ",")) + return nil + } + + // sendClearSequence with "Escape" should succeed and send the key. + // The 150ms Escape delay is real but acceptable in tests. + err := sendClearSequence("%1", "Escape C-k") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"Escape", "C-k"} + if len(calls) != len(want) { + t.Fatalf("got %d calls, want %d: %v", len(calls), len(want), calls) + } + for i, w := range want { + if calls[i] != w { + t.Errorf("call[%d] = %q, want %q", i, calls[i], w) + } + } +} + +func TestSendClearSequence_SingleKeyError(t *testing.T) { + oldSend := sendKeys + defer func() { sendKeys = oldSend }() + sendKeys = func(string, ...string) error { + return fmt.Errorf("send failed") + } + + err := sendClearSequence("%1", "C-u") + if err == nil { + t.Fatal("expected error from sendKeys failure") + } + if !strings.Contains(err.Error(), "clear key") { + t.Errorf("error should mention 'clear key', got: %v", err) + } +} + +func TestSendClearSequence_RepeatedKeyError(t *testing.T) { + oldRepeat := sendRepeatedKey + defer func() { sendRepeatedKey = oldRepeat }() + sendRepeatedKey = func(string, string, int) error { + return fmt.Errorf("repeat failed") + } + + err := sendClearSequence("%1", "BSpace*200") + if err == nil { + t.Fatal("expected error from sendRepeatedKey failure") + } + if !strings.Contains(err.Error(), "clear key") { + t.Errorf("error should mention 'clear key', got: %v", err) + } +} + // mustCompile is a test helper that compiles a regex or fails the test. func mustCompile(t *testing.T, pattern string) *regexp.Regexp { t.Helper() diff --git a/internal/tmuxedit/claude_agent_test.go b/internal/tmuxedit/claude_agent_test.go index 1a80433..d8a68d9 100644 --- a/internal/tmuxedit/claude_agent_test.go +++ b/internal/tmuxedit/claude_agent_test.go @@ -103,6 +103,69 @@ func TestClaudeAgent_ClearInput(t *testing.T) { } } +func TestClaudeAgent_ExtractPrompt_EmptyPattern(t *testing.T) { + agent := &claudeAgent{baseAgent{promptPat: "", sectionPat: `^─{5,}`}} + got := agent.ExtractPrompt("──────\n❯ hello\n──────") + if got != "" { + t.Errorf("expected empty for empty pattern, got %q", got) + } +} + +func TestClaudeAgent_ExtractPrompt_InvalidRegex(t *testing.T) { + agent := &claudeAgent{baseAgent{promptPat: "[invalid", sectionPat: `^─{5,}`}} + got := agent.ExtractPrompt("──────\n❯ hello\n──────") + if got != "" { + t.Errorf("expected empty for invalid regex, got %q", got) + } +} + +func TestClaudeAgent_ExtractPrompt_ContinuationBreaksOnEmpty(t *testing.T) { + agent := newClaudeAgent() + // Empty line between prompt blocks should break continuation + content := "──────────────\n" + + "❯ first line\n" + + " continued\n" + + "\n" + + "unrelated text\n" + + "──────────────" + got := agent.ExtractPrompt(content) + want := "first line\ncontinued" + if got != want { + t.Errorf("ExtractPrompt() = %q, want %q", got, want) + } +} + +func TestClaudeAgent_ClearInput_Disabled(t *testing.T) { + agent := &claudeAgent{baseAgent{clearFirst: false, clearKeys: "C-a C-k"}} + err := agent.ClearInput("%1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClaudeAgent_ClearInput_EmptyKeys(t *testing.T) { + agent := &claudeAgent{baseAgent{clearFirst: true, clearKeys: ""}} + err := agent.ClearInput("%1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClaudeAgent_ClearInput_Error(t *testing.T) { + noSleep(t) + oldSend := sendKeys + defer func() { sendKeys = oldSend }() + sendKeys = func(string, ...string) error { + return fmt.Errorf("send failed") + } + + agent := newClaudeAgent() + err := agent.ClearInput("%1") + if err == nil { + t.Fatal("expected error from sendClearSequence failure") + } +} + func TestClaudeAgent_Detect(t *testing.T) { agent := newClaudeAgent() tests := []struct { diff --git a/internal/tmuxedit/cursor_agent_test.go b/internal/tmuxedit/cursor_agent_test.go index 28d7fe1..867a55b 100644 --- a/internal/tmuxedit/cursor_agent_test.go +++ b/internal/tmuxedit/cursor_agent_test.go @@ -119,6 +119,55 @@ func TestCursorAgent_ClearInput(t *testing.T) { } } +func TestCursorAgent_ExtractPrompt_EmptyPattern(t *testing.T) { + // A cursorAgent with empty promptPat returns empty string + agent := &cursorAgent{baseAgent{promptPat: ""}} + got := agent.ExtractPrompt("│ → hello │") + if got != "" { + t.Errorf("expected empty for empty pattern, got %q", got) + } +} + +func TestCursorAgent_ExtractPrompt_InvalidRegex(t *testing.T) { + // A cursorAgent with invalid regex returns empty string + agent := &cursorAgent{baseAgent{promptPat: "[invalid"}} + got := agent.ExtractPrompt("│ → hello │") + if got != "" { + t.Errorf("expected empty for invalid regex, got %q", got) + } +} + +func TestCursorAgent_ClearInput_Disabled(t *testing.T) { + agent := &cursorAgent{baseAgent{clearFirst: false, clearKeys: "End BSpace*200"}} + err := agent.ClearInput("%1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCursorAgent_ClearInput_EmptyKeys(t *testing.T) { + agent := &cursorAgent{baseAgent{clearFirst: true, clearKeys: ""}} + err := agent.ClearInput("%1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCursorAgent_ClearInput_Error(t *testing.T) { + noSleep(t) + oldSend := sendKeys + defer func() { sendKeys = oldSend }() + sendKeys = func(string, ...string) error { + return fmt.Errorf("send failed") + } + + agent := newCursorAgent() + err := agent.ClearInput("%1") + if err == nil { + t.Fatal("expected error from sendClearSequence failure") + } +} + func TestCursorAgent_Detect(t *testing.T) { agent := newCursorAgent() tests := []struct { diff --git a/internal/tmuxedit/history_test.go b/internal/tmuxedit/history_test.go index 6d369fe..b9d59d3 100644 --- a/internal/tmuxedit/history_test.go +++ b/internal/tmuxedit/history_test.go @@ -1,6 +1,7 @@ package tmuxedit import ( + "fmt" "os" "path/filepath" "testing" @@ -182,6 +183,133 @@ func TestSplitLines(t *testing.T) { } } +func TestGetHistory_MalformedEntries(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_STATE_HOME", tmpDir) + + // Create state directory and write a file with some valid and invalid lines + stateDir := filepath.Join(tmpDir, "state") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatalf("cannot create state dir: %v", err) + } + historyPath := filepath.Join(stateDir, "tmux-edit-history.jsonl") + content := `{"timestamp":"2025-01-01T00:00:00Z","agent":"claude","cwd":"/tmp","text":"valid"} +not json at all +{"timestamp":"2025-01-02T00:00:00Z","agent":"aider","cwd":"/home","text":"also valid"} +` + if err := os.WriteFile(historyPath, []byte(content), 0o644); err != nil { + t.Fatalf("cannot write history file: %v", err) + } + + entries, err := GetHistory(0) + if err != nil { + t.Fatalf("GetHistory failed: %v", err) + } + // Should skip the malformed line and return the 2 valid entries + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries[0].Text != "valid" { + t.Errorf("entries[0].Text = %q, want 'valid'", entries[0].Text) + } + if entries[1].Text != "also valid" { + t.Errorf("entries[1].Text = %q, want 'also valid'", entries[1].Text) + } +} + +func TestGetHistory_EmptyLines(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_STATE_HOME", tmpDir) + + stateDir := filepath.Join(tmpDir, "state") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatalf("cannot create state dir: %v", err) + } + historyPath := filepath.Join(stateDir, "tmux-edit-history.jsonl") + // File with empty lines interspersed + content := "\n" + + `{"timestamp":"2025-01-01T00:00:00Z","agent":"claude","cwd":"/tmp","text":"entry1"}` + "\n" + + "\n\n" + + `{"timestamp":"2025-01-02T00:00:00Z","agent":"aider","cwd":"/home","text":"entry2"}` + "\n" + if err := os.WriteFile(historyPath, []byte(content), 0o644); err != nil { + t.Fatalf("cannot write history file: %v", err) + } + + entries, err := GetHistory(0) + if err != nil { + t.Fatalf("GetHistory failed: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries (skipping empty lines), got %d", len(entries)) + } +} + +func TestGetHistory_LimitZeroReturnsAll(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_STATE_HOME", tmpDir) + + for i := 0; i < 5; i++ { + if err := AppendHistory(fmt.Sprintf("entry%d", i), "claude", "/tmp"); err != nil { + t.Fatalf("AppendHistory failed: %v", err) + } + } + + entries, err := GetHistory(0) + if err != nil { + t.Fatalf("GetHistory failed: %v", err) + } + if len(entries) != 5 { + t.Errorf("expected 5 entries with limit=0, got %d", len(entries)) + } +} + +func TestGetHistory_LimitLargerThanEntries(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_STATE_HOME", tmpDir) + + if err := AppendHistory("only one", "claude", "/tmp"); err != nil { + t.Fatalf("AppendHistory failed: %v", err) + } + + entries, err := GetHistory(100) + if err != nil { + t.Fatalf("GetHistory failed: %v", err) + } + if len(entries) != 1 { + t.Errorf("expected 1 entry with large limit, got %d", len(entries)) + } +} + +func TestAppendHistory_InvalidStateDir(t *testing.T) { + // Point XDG_STATE_HOME to a path that can't be created (file, not dir) + tmpDir := t.TempDir() + blockingFile := filepath.Join(tmpDir, "blocker") + if err := os.WriteFile(blockingFile, []byte("x"), 0o644); err != nil { + t.Fatalf("cannot create blocking file: %v", err) + } + // Set state home to a path under the file (impossible to mkdir) + t.Setenv("XDG_STATE_HOME", filepath.Join(blockingFile, "sub")) + + err := AppendHistory("text", "agent", "/cwd") + if err == nil { + t.Fatal("expected error when state directory cannot be created") + } +} + +func TestGetHistory_InvalidStateDir(t *testing.T) { + tmpDir := t.TempDir() + blockingFile := filepath.Join(tmpDir, "blocker") + if err := os.WriteFile(blockingFile, []byte("x"), 0o644); err != nil { + t.Fatalf("cannot create blocking file: %v", err) + } + t.Setenv("XDG_STATE_HOME", filepath.Join(blockingFile, "sub")) + + _, err := GetHistory(0) + if err == nil { + t.Fatal("expected error when state directory cannot be created") + } +} + func containsString(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && findSubstring(s, substr)) } diff --git a/internal/tmuxedit/run_test.go b/internal/tmuxedit/run_test.go index 1b603e4..c150cbd 100644 --- a/internal/tmuxedit/run_test.go +++ b/internal/tmuxedit/run_test.go @@ -2,6 +2,7 @@ package tmuxedit import ( "fmt" + "log" "strings" "testing" @@ -306,6 +307,106 @@ func TestRunWithConfig_EditorError(t *testing.T) { } } +func TestLogPaneLines_WithDebugLog(t *testing.T) { + // Set up debugLog to a buffer to cover the logging branch + var buf strings.Builder + oldDebugLog := debugLog + debugLog = log.New(&buf, "", 0) + defer func() { debugLog = oldDebugLog }() + + // Content with box-drawing and arrow characters triggers logging + content := "normal line\n│ box line\n→ arrow line\nplain" + logPaneLines(content) + + output := buf.String() + if !strings.Contains(output, "box line") { + t.Errorf("expected log of box-drawing line, got: %s", output) + } + if !strings.Contains(output, "arrow line") { + t.Errorf("expected log of arrow line, got: %s", output) + } +} + +func TestLogPaneLines_WithoutDebugLog(t *testing.T) { + // When debugLog is nil, logPaneLines should not panic + oldDebugLog := debugLog + debugLog = nil + defer func() { debugLog = oldDebugLog }() + + logPaneLines("│ test line\n→ arrow") + // No panic means pass +} + +func TestRunWithConfig_ClearInputError(t *testing.T) { + noSleep(t) + oldCapture := capturePane + oldSendKeys := sendKeys + oldEditorPopup := openEditorPopup + oldRunCmd := runCommand + defer func() { + capturePane = oldCapture + sendKeys = oldSendKeys + openEditorPopup = oldEditorPopup + runCommand = oldRunCmd + }() + + runCommand = func(name string, args ...string) ([]byte, error) { + return []byte("%1"), nil + } + capturePane = func(string) (string, error) { + return "claude code v1.0\n──────\n❯ fix the bug\n──────", nil + } + openEditorPopup = func(string, string, string) (string, error) { + return "new text", nil + } + sendKeys = func(string, ...string) error { + return fmt.Errorf("clear input failed") + } + + cfg := appconfig.App{} + err := runWithConfig(Options{}, cfg) + if err == nil || !strings.Contains(err.Error(), "clear input failed") { + t.Errorf("expected clear input error, got: %v", err) + } +} + +func TestRunWithConfig_SendTextError(t *testing.T) { + noSleep(t) + oldCapture := capturePane + oldSendKeys := sendKeys + oldEditorPopup := openEditorPopup + oldRunCmd := runCommand + defer func() { + capturePane = oldCapture + sendKeys = oldSendKeys + openEditorPopup = oldEditorPopup + runCommand = oldRunCmd + }() + + runCommand = func(name string, args ...string) ([]byte, error) { + return []byte("%1"), nil + } + // Use generic agent (no clear) so ClearInput succeeds + capturePane = func(string) (string, error) { + return "some unknown pane content", nil + } + openEditorPopup = func(string, string, string) (string, error) { + return "new text", nil + } + callCount := 0 + sendKeys = func(string, ...string) error { + callCount++ + // Fail on text send (generic agent has no clear) + return fmt.Errorf("send text failed") + } + + cfg := appconfig.App{} + err := runWithConfig(Options{}, cfg) + if err == nil || !strings.Contains(err.Error(), "send text failed") { + t.Errorf("expected send text error, got: %v", err) + } +} + func TestRunWithConfig_PaneResolveError(t *testing.T) { oldRunCmd := runCommand defer func() { runCommand = oldRunCmd }() diff --git a/internal/version.go b/internal/version.go index 19e2f9c..546ad62 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,4 +1,4 @@ // Summary: Hexai semantic version identifier used by CLI and LSP binaries. package internal -const Version = "0.23.1" +const Version = "0.24.0" |
