summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-16 03:10:55 +0200
committerPaul Buetow <paul@buetow.org>2026-03-16 03:10:55 +0200
commit1fc1611fa99993cab5dc8bf0844183285296e3b2 (patch)
treec5c9b8b5abac5b5d4c0d56ed90b0580184cc4383
parent12090f25a3677291863dbb80277bdad3eaec0324 (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.go65
-rw-r--r--cmd/hexai-mcp-server/main_test.go166
-rw-r--r--cmd/hexai-tmux-action/main.go43
-rw-r--r--cmd/hexai-tmux-action/main_test.go63
-rw-r--r--cmd/hexai-tmux-edit/main.go21
-rw-r--r--cmd/hexai-tmux-edit/main_test.go59
-rw-r--r--cmd/hexai/main_test.go111
-rw-r--r--internal/editor/editor_test.go140
-rw-r--r--internal/gotest/heuristics_test.go120
-rw-r--r--internal/hexaicli/run_output_test.go538
-rw-r--r--internal/hexaimcp/run_test.go314
-rw-r--r--internal/runtimeconfig/store_test.go279
-rw-r--r--internal/stats/stats_test.go250
-rw-r--r--internal/testutil/fixtures_test.go41
-rw-r--r--internal/tmux/status_coverage_test.go418
-rw-r--r--internal/tmuxedit/agent_test.go46
-rw-r--r--internal/tmuxedit/agentutil_test.go60
-rw-r--r--internal/tmuxedit/claude_agent_test.go63
-rw-r--r--internal/tmuxedit/cursor_agent_test.go49
-rw-r--r--internal/tmuxedit/history_test.go128
-rw-r--r--internal/tmuxedit/run_test.go101
-rw-r--r--internal/version.go2
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"