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 != "" {