package cmd import ( "errors" "os" "testing" "github.com/spf13/cobra" "gmgauthier.com/grokkit/internal/grok" ) // mockStreamer records calls made to it and returns a canned response. type mockStreamer struct { response string calls int } func (m *mockStreamer) Stream(messages []map[string]string, model string) string { m.calls++ return m.response } func (m *mockStreamer) StreamWithTemp(messages []map[string]string, model string, temp float64) string { m.calls++ return m.response } func (m *mockStreamer) StreamSilent(messages []map[string]string, model string) string { m.calls++ return m.response } // Ensure mockStreamer satisfies the interface at compile time. var _ grok.AIClient = (*mockStreamer)(nil) // withMockClient injects mock and returns a restore func. func withMockClient(mock grok.AIClient) func() { orig := newGrokClient newGrokClient = func() grok.AIClient { return mock } return func() { newGrokClient = orig } } // withMockGit injects a fake git runner and returns a restore func. func withMockGit(fn func([]string) (string, error)) func() { orig := gitRun gitRun = fn return func() { gitRun = orig } } // testCmd returns a minimal cobra command with the model flag registered. func testCmd() *cobra.Command { c := &cobra.Command{} c.Flags().String("model", "", "") return c } // --- runHistory --- func TestRunHistory(t *testing.T) { t.Run("calls AI with log output", func(t *testing.T) { mock := &mockStreamer{response: "3 commits: feat, fix, chore"} defer withMockClient(mock)() defer withMockGit(func(args []string) (string, error) { return "abc1234 feat: add thing\ndef5678 fix: bug", nil })() runHistory(testCmd(), nil) if mock.calls != 1 { t.Errorf("expected 1 AI call, got %d", mock.calls) } }) t.Run("no commits — skips AI", func(t *testing.T) { mock := &mockStreamer{} defer withMockClient(mock)() defer withMockGit(func(args []string) (string, error) { return "", nil })() runHistory(testCmd(), nil) if mock.calls != 0 { t.Errorf("expected 0 AI calls, got %d", mock.calls) } }) t.Run("git error — skips AI", func(t *testing.T) { mock := &mockStreamer{} defer withMockClient(mock)() defer withMockGit(func(args []string) (string, error) { return "", errors.New("not a git repo") })() runHistory(testCmd(), nil) if mock.calls != 0 { t.Errorf("expected 0 AI calls, got %d", mock.calls) } }) } // --- runReview --- func TestRunReview(t *testing.T) { t.Run("reviews with diff and status", func(t *testing.T) { mock := &mockStreamer{response: "looks good"} defer withMockClient(mock)() callCount := 0 defer withMockGit(func(args []string) (string, error) { callCount++ switch args[0] { case "diff": return "diff content", nil case "status": return "M main.go", nil } return "", nil })() runReview(testCmd(), nil) if mock.calls != 1 { t.Errorf("expected 1 AI call, got %d", mock.calls) } if callCount != 2 { t.Errorf("expected 2 git calls (diff + status), got %d", callCount) } }) t.Run("git diff error — skips AI", func(t *testing.T) { mock := &mockStreamer{} defer withMockClient(mock)() defer withMockGit(func(args []string) (string, error) { if args[0] == "diff" { return "", errors.New("git error") } return "", nil })() runReview(testCmd(), nil) if mock.calls != 0 { t.Errorf("expected 0 AI calls, got %d", mock.calls) } }) t.Run("git status error — skips AI", func(t *testing.T) { mock := &mockStreamer{} defer withMockClient(mock)() defer withMockGit(func(args []string) (string, error) { if args[0] == "status" { return "", errors.New("git error") } return "diff content", nil })() runReview(testCmd(), nil) if mock.calls != 0 { t.Errorf("expected 0 AI calls, got %d", mock.calls) } }) } // --- runCommit --- func TestRunCommit(t *testing.T) { t.Run("no staged changes — skips AI", func(t *testing.T) { mock := &mockStreamer{} defer withMockClient(mock)() defer withMockGit(func(args []string) (string, error) { return "", nil })() runCommit(testCmd(), nil) if mock.calls != 0 { t.Errorf("expected 0 AI calls, got %d", mock.calls) } }) t.Run("git error — skips AI", func(t *testing.T) { mock := &mockStreamer{} defer withMockClient(mock)() defer withMockGit(func(args []string) (string, error) { return "", errors.New("not a git repo") })() runCommit(testCmd(), nil) if mock.calls != 0 { t.Errorf("expected 0 AI calls, got %d", mock.calls) } }) t.Run("with staged changes — calls AI then cancels via stdin", func(t *testing.T) { mock := &mockStreamer{response: "feat(cmd): add thing"} defer withMockClient(mock)() defer withMockGit(func(args []string) (string, error) { return "diff --git a/foo.go b/foo.go\n+func bar() {}", nil })() // Pipe "n\n" so the confirmation prompt returns without committing. r, w, _ := os.Pipe() origStdin := os.Stdin os.Stdin = r if _, err := w.WriteString("n\n"); err != nil { t.Fatal(err) } if err := w.Close(); err != nil { t.Fatal(err) } defer func() { os.Stdin = origStdin }() runCommit(testCmd(), nil) if mock.calls != 1 { t.Errorf("expected 1 AI call, got %d", mock.calls) } }) } // --- runCommitMsg --- func TestRunCommitMsg(t *testing.T) { t.Run("no staged changes — skips AI", func(t *testing.T) { mock := &mockStreamer{} defer withMockClient(mock)() defer withMockGit(func(args []string) (string, error) { return "", nil })() runCommitMsg(testCmd(), nil) if mock.calls != 0 { t.Errorf("expected 0 AI calls, got %d", mock.calls) } }) t.Run("with staged changes — calls AI and prints message", func(t *testing.T) { mock := &mockStreamer{response: "feat(api): add endpoint"} defer withMockClient(mock)() defer withMockGit(func(args []string) (string, error) { return "diff --git a/api.go b/api.go", nil })() runCommitMsg(testCmd(), nil) if mock.calls != 1 { t.Errorf("expected 1 AI call, got %d", mock.calls) } }) } // --- runPRDescribe --- func TestRunPRDescribe(t *testing.T) { t.Run("no changes on branch — skips AI", func(t *testing.T) { mock := &mockStreamer{} defer withMockClient(mock)() defer withMockGit(func(args []string) (string, error) { return "", nil // both diff calls return empty })() runPRDescribe(testCmd(), nil) if mock.calls != 0 { t.Errorf("expected 0 AI calls, got %d", mock.calls) } }) t.Run("first diff succeeds — calls AI", func(t *testing.T) { mock := &mockStreamer{response: "## PR Title\n\nDescription"} defer withMockClient(mock)() callCount := 0 defer withMockGit(func(args []string) (string, error) { callCount++ if callCount == 1 { return "diff --git a/foo.go b/foo.go", nil } return "", nil })() runPRDescribe(testCmd(), nil) if mock.calls != 1 { t.Errorf("expected 1 AI call, got %d", mock.calls) } }) t.Run("first diff empty, second succeeds — calls AI", func(t *testing.T) { mock := &mockStreamer{response: "PR description"} defer withMockClient(mock)() callCount := 0 defer withMockGit(func(args []string) (string, error) { callCount++ if callCount == 2 { return "diff --git a/bar.go b/bar.go", nil } return "", nil })() runPRDescribe(testCmd(), nil) if mock.calls != 1 { t.Errorf("expected 1 AI call, got %d", mock.calls) } }) t.Run("second diff error — skips AI", func(t *testing.T) { mock := &mockStreamer{} defer withMockClient(mock)() callCount := 0 defer withMockGit(func(args []string) (string, error) { callCount++ if callCount == 2 { return "", errors.New("no remote") } return "", nil })() runPRDescribe(testCmd(), nil) if mock.calls != 0 { t.Errorf("expected 0 AI calls, got %d", mock.calls) } }) } // --- runLint / processDocsFile (error paths only — linter/file I/O) --- func TestRunLintFileNotFound(t *testing.T) { // Reset flags to defaults dryRun = false autoFix = false // Pass a non-existent file; should return without calling AI. mock := &mockStreamer{} defer withMockClient(mock)() runLint(testCmd(), []string{"/nonexistent/path/file.go"}) if mock.calls != 0 { t.Errorf("expected 0 AI calls for missing file, got %d", mock.calls) } } func TestProcessDocsFileNotFound(t *testing.T) { mock := &mockStreamer{} processDocsFile(mock, "grok-4", "/nonexistent/path/file.go") if mock.calls != 0 { t.Errorf("expected 0 AI calls for missing file, got %d", mock.calls) } } func TestProcessDocsFileUnsupportedLanguage(t *testing.T) { // Create a temp file with an unsupported extension. f, err := os.CreateTemp("", "test*.xyz") if err != nil { t.Fatal(err) } if err := f.Close(); err != nil { t.Fatal(err) } defer func() { _ = os.Remove(f.Name()) }() mock := &mockStreamer{} processDocsFile(mock, "grok-4", f.Name()) if mock.calls != 0 { t.Errorf("expected 0 AI calls for unsupported language, got %d", mock.calls) } } func TestProcessDocsFilePreviewAndCancel(t *testing.T) { f, err := os.CreateTemp("", "test*.go") if err != nil { t.Fatal(err) } if _, err := f.WriteString("package main\n\nfunc Foo() {}\n"); err != nil { t.Fatal(err) } if err := f.Close(); err != nil { t.Fatal(err) } defer func() { _ = os.Remove(f.Name()) }() defer func() { _ = os.Remove(f.Name() + ".bak") }() mock := &mockStreamer{response: "package main\n\n// Foo does nothing.\nfunc Foo() {}\n"} origAutoApply := autoApply autoApply = false defer func() { autoApply = origAutoApply }() // Pipe "n\n" so confirmation returns without writing. r, w, _ := os.Pipe() origStdin := os.Stdin os.Stdin = r if _, err := w.WriteString("n\n"); err != nil { t.Fatal(err) } if err := w.Close(); err != nil { t.Fatal(err) } defer func() { os.Stdin = origStdin }() processDocsFile(mock, "grok-4", f.Name()) if mock.calls != 1 { t.Errorf("expected 1 AI call, got %d", mock.calls) } } func TestProcessDocsFileAutoApply(t *testing.T) { f, err := os.CreateTemp("", "test*.go") if err != nil { t.Fatal(err) } original := "package main\n\nfunc Bar() {}\n" if _, err := f.WriteString(original); err != nil { t.Fatal(err) } if err := f.Close(); err != nil { t.Fatal(err) } defer func() { _ = os.Remove(f.Name()) }() defer func() { _ = os.Remove(f.Name() + ".bak") }() // CleanCodeResponse will trim the trailing newline from the AI response. aiResponse := "package main\n\n// Bar does nothing.\nfunc Bar() {}\n" documented := "package main\n\n// Bar does nothing.\nfunc Bar() {}" mock := &mockStreamer{response: aiResponse} origAutoApply := autoApply autoApply = true defer func() { autoApply = origAutoApply }() processDocsFile(mock, "grok-4", f.Name()) if mock.calls != 1 { t.Errorf("expected 1 AI call, got %d", mock.calls) } // Verify file was rewritten with documented content. content, _ := os.ReadFile(f.Name()) if string(content) != documented { t.Errorf("file content = %q, want %q", string(content), documented) } // Verify backup was created with original content. backup, _ := os.ReadFile(f.Name() + ".bak") if string(backup) != original { t.Errorf("backup content = %q, want %q", string(backup), original) } } func TestRunDocs(t *testing.T) { // runDocs with a missing file: should call processDocsFile which returns early. mock := &mockStreamer{} defer withMockClient(mock)() runDocs(testCmd(), []string{"/nonexistent/file.go"}) if mock.calls != 0 { t.Errorf("expected 0 AI calls for missing file, got %d", mock.calls) } }