summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-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
15 files changed, 2547 insertions, 2 deletions
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)
+ }
+