diff --git a/README.md b/README.md index 707cfa5..73e755b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Grokkit is a fast Go CLI integrating Grok AI with git workflows and general chat/edit functionality. -[![Test Coverage](https://img.shields.io/badge/coverage-54%25-orange)]() +[![Test Coverage](https://img.shields.io/badge/coverage-62%25-yellow)]() [![Go Version](https://img.shields.io/badge/go-1.24.2-blue)]() [![License](https://img.shields.io/badge/license-Unlicense-lightgrey)]() @@ -188,7 +188,7 @@ Generate shell completions for faster command entry: - ✅ **Automated Workon** - AI-powered todo/fix workflow with branch management, work plan generation, and completion tracking ### Quality & Testing -- ✅ **Test coverage 54%+** - Comprehensive unit tests including all command message builders +- ✅ **Test coverage 60%+** - Comprehensive unit tests including all command message builders - ✅ **Coverage gate in CI** - Builds fail if coverage drops below 50% - ✅ **CI/CD with Gitea Actions** - Tests must pass before lint and build jobs run - ✅ **Golangci-lint configured** - `.golangci.yml` with govet, errcheck, staticcheck, and more diff --git a/cmd/analyze_test.go b/cmd/analyze_test.go new file mode 100644 index 0000000..901e909 --- /dev/null +++ b/cmd/analyze_test.go @@ -0,0 +1,238 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestAnalyzeCmd — FAST DEFAULT TEST (runs on make test / go test ./cmd) +func TestAnalyzeCmd(t *testing.T) { + t.Log("✓ Fast analyze unit test (no Grok API call)") + + found := false + for _, c := range rootCmd.Commands() { + if c.Use == "analyze [flags]" { + found = true + break + } + } + if !found { + t.Fatal("analyze command not registered on rootCmd") + } +} + +func TestAnalyzeFlagRegistration(t *testing.T) { + tests := []struct { + name string + flagName string + shorthand string + }{ + {"output flag", "output", "o"}, + {"yes flag", "yes", "y"}, + {"dir flag", "dir", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flag := analyzeCmd.Flags().Lookup(tt.flagName) + if flag == nil { + t.Fatalf("flag %q not found on analyze command", tt.flagName) + } + if tt.shorthand != "" && flag.Shorthand != tt.shorthand { + t.Errorf("flag %q shorthand = %q, want %q", tt.flagName, flag.Shorthand, tt.shorthand) + } + }) + } +} + +func TestDiscoverSourceFiles(t *testing.T) { + tmpDir := t.TempDir() + + // Create some source files + if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "helper.go"), []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + // Create a non-source file (should be ignored) + if err := os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("notes"), 0644); err != nil { + t.Fatal(err) + } + + files, err := discoverSourceFiles(tmpDir) + if err != nil { + t.Fatalf("discoverSourceFiles failed: %v", err) + } + + if len(files) < 2 { + t.Errorf("expected at least 2 Go files, got %d: %v", len(files), files) + } + + // Verify .txt was not included + for _, f := range files { + if strings.HasSuffix(f, ".txt") { + t.Errorf("unexpected .txt file in results: %s", f) + } + } +} + +func TestDiscoverSourceFilesSkipsDotDirs(t *testing.T) { + tmpDir := t.TempDir() + + // Create a hidden directory with a Go file + hiddenDir := filepath.Join(tmpDir, ".hidden") + if err := os.MkdirAll(hiddenDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(hiddenDir, "secret.go"), []byte("package hidden"), 0644); err != nil { + t.Fatal(err) + } + + // Create vendor directory with a Go file + vendorDir := filepath.Join(tmpDir, "vendor") + if err := os.MkdirAll(vendorDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(vendorDir, "dep.go"), []byte("package dep"), 0644); err != nil { + t.Fatal(err) + } + + // Create a normal Go file + if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + + files, err := discoverSourceFiles(tmpDir) + if err != nil { + t.Fatalf("discoverSourceFiles failed: %v", err) + } + + for _, f := range files { + if strings.Contains(f, ".hidden") { + t.Errorf("should not include files from hidden dirs: %s", f) + } + if strings.Contains(f, "vendor") { + t.Errorf("should not include files from vendor dir: %s", f) + } + } + + if len(files) != 1 { + t.Errorf("expected 1 file (main.go only), got %d: %v", len(files), files) + } +} + +func TestDiscoverSourceFilesSkipsBuildAndDist(t *testing.T) { + tmpDir := t.TempDir() + + for _, dir := range []string{"build", "dist", "node_modules"} { + d := filepath.Join(tmpDir, dir) + if err := os.MkdirAll(d, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(d, "file.go"), []byte("package x"), 0644); err != nil { + t.Fatal(err) + } + } + + if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + + files, err := discoverSourceFiles(tmpDir) + if err != nil { + t.Fatalf("discoverSourceFiles failed: %v", err) + } + + if len(files) != 1 { + t.Errorf("expected 1 file, got %d: %v", len(files), files) + } +} + +func TestDiscoverSourceFilesEmptyDir(t *testing.T) { + tmpDir := t.TempDir() + + files, err := discoverSourceFiles(tmpDir) + if err != nil { + t.Fatalf("discoverSourceFiles failed: %v", err) + } + if len(files) != 0 { + t.Errorf("expected 0 files for empty dir, got %d", len(files)) + } +} + +func TestBuildProjectContext(t *testing.T) { + tmpDir := t.TempDir() + + files := []string{ + filepath.Join(tmpDir, "main.go"), + filepath.Join(tmpDir, "cmd", "root.go"), + } + + ctx := buildProjectContext(tmpDir, files) + + if !strings.Contains(ctx, "Project Root:") { + t.Error("expected context to contain 'Project Root:'") + } + if !strings.Contains(ctx, "Total source files discovered: 2") { + t.Error("expected context to contain file count") + } + if !strings.Contains(ctx, "Key files") { + t.Error("expected context to contain 'Key files'") + } +} + +func TestPreviewLines(t *testing.T) { + tests := []struct { + name string + content string + n int + }{ + { + name: "fewer lines than limit", + content: "line1\nline2\nline3\n", + n: 10, + }, + { + name: "more lines than limit", + content: "a\nb\nc\nd\ne\nf\ng\nh\n", + n: 3, + }, + { + name: "empty content", + content: "", + n: 5, + }, + { + name: "zero lines requested", + content: "line1\nline2\n", + n: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // previewLines prints to stdout; just verify it doesn't panic + previewLines(tt.content, tt.n) + }) + } +} + +func TestBuildProjectContextLimitsSize(t *testing.T) { + tmpDir := t.TempDir() + + // Generate many files to test the 2500 byte limit + var files []string + for i := 0; i < 200; i++ { + files = append(files, filepath.Join(tmpDir, "file_with_a_long_name_for_testing_"+strings.Repeat("x", 20)+".go")) + } + + ctx := buildProjectContext(tmpDir, files) + + // Should be reasonably bounded (the 2500 byte check truncates the file list) + if len(ctx) > 4000 { + t.Errorf("context unexpectedly large: %d bytes", len(ctx)) + } +} diff --git a/cmd/recipe_test.go b/cmd/recipe_test.go new file mode 100644 index 0000000..36d96d9 --- /dev/null +++ b/cmd/recipe_test.go @@ -0,0 +1,149 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveRecipePathDirectFile(t *testing.T) { + tmpDir := t.TempDir() + recipePath := filepath.Join(tmpDir, "my-recipe.md") + if err := os.WriteFile(recipePath, []byte("# Recipe\n"), 0644); err != nil { + t.Fatal(err) + } + + got, err := resolveRecipePath(recipePath) + if err != nil { + t.Fatalf("resolveRecipePath(%q) failed: %v", recipePath, err) + } + if got != recipePath { + t.Errorf("resolveRecipePath() = %q, want %q", got, recipePath) + } +} + +func TestResolveRecipePathDirectFileMissing(t *testing.T) { + _, err := resolveRecipePath("/nonexistent/recipe.md") + if err == nil { + t.Fatal("expected error for missing recipe file, got nil") + } +} + +func TestResolveRecipePathProjectLocal(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Create .grokkit/recipes with a recipe, and a .git dir for project root detection + recipeDir := filepath.Join(tmpDir, ".grokkit", "recipes") + if err := os.MkdirAll(recipeDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0755); err != nil { + t.Fatal(err) + } + recipePath := filepath.Join(recipeDir, "refactor.md") + if err := os.WriteFile(recipePath, []byte("# Refactor\n"), 0644); err != nil { + t.Fatal(err) + } + + got, err := resolveRecipePath("refactor") + if err != nil { + t.Fatalf("resolveRecipePath('refactor') failed: %v", err) + } + if got != recipePath { + t.Errorf("resolveRecipePath() = %q, want %q", got, recipePath) + } +} + +func TestResolveRecipePathNotFound(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Create .git so findProjectRoot works, but no recipes + if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0755); err != nil { + t.Fatal(err) + } + + // Override HOME to empty dir so global fallback also fails + oldHome := os.Getenv("HOME") + defer func() { _ = os.Setenv("HOME", oldHome) }() + _ = os.Setenv("HOME", tmpDir) + + _, err := resolveRecipePath("nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent recipe, got nil") + } +} + +func TestFindProjectRoot(t *testing.T) { + tests := []struct { + name string + marker string + }{ + {"git repo", ".git"}, + {"gitignore", ".gitignore"}, + {"grokkit dir", ".grokkit"}, + {"go module", "go.mod"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + + // Create a subdirectory and chdir into it + subDir := filepath.Join(tmpDir, "sub", "deep") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.Chdir(subDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Place the marker at the root + markerPath := filepath.Join(tmpDir, tt.marker) + if tt.marker == ".git" || tt.marker == ".grokkit" { + if err := os.MkdirAll(markerPath, 0755); err != nil { + t.Fatal(err) + } + } else { + if err := os.WriteFile(markerPath, []byte(""), 0644); err != nil { + t.Fatal(err) + } + } + + root, err := findProjectRoot() + if err != nil { + t.Fatalf("findProjectRoot() failed: %v", err) + } + if root != tmpDir { + t.Errorf("findProjectRoot() = %q, want %q", root, tmpDir) + } + }) + } +} + +func TestFindProjectRootNotInProject(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Empty dir — no markers at all. This will walk up to / and fail. + _, err := findProjectRoot() + if err == nil { + // Might succeed if a parent dir has .git — that's OK in CI + t.Log("findProjectRoot() succeeded (likely found .git in a parent dir)") + } +} diff --git a/cmd/scaffold_test.go b/cmd/scaffold_test.go index 339331f..28cedf3 100644 --- a/cmd/scaffold_test.go +++ b/cmd/scaffold_test.go @@ -3,6 +3,8 @@ package cmd import ( "os" + "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -16,6 +18,83 @@ func TestScaffoldCmd(t *testing.T) { assert.True(t, true, "command is registered and basic structure is intact") } +func TestDetectLanguage(t *testing.T) { + tests := []struct { + name string + path string + override string + expected string + }{ + {"Go file", "main.go", "", "Go"}, + {"Python file", "script.py", "", "Python"}, + {"JS file", "app.js", "", "TypeScript"}, + {"TS file", "app.ts", "", "TypeScript"}, + {"C file", "main.c", "", "C"}, + {"C++ file", "main.cpp", "", "C++"}, + {"Java file", "Main.java", "", "Java"}, + {"unknown extension", "data.xyz", "", "code"}, + {"override takes precedence", "main.go", "Rust", "Rust"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectLanguage(tt.path, tt.override) + if got != tt.expected { + t.Errorf("detectLanguage(%q, %q) = %q, want %q", tt.path, tt.override, got, tt.expected) + } + }) + } +} + +func TestHarvestContext(t *testing.T) { + tmpDir := t.TempDir() + + // Create the target file + targetPath := filepath.Join(tmpDir, "target.go") + if err := os.WriteFile(targetPath, []byte("package main\n\nfunc Target() {}\n"), 0644); err != nil { + t.Fatal(err) + } + + // Create a sibling .go file (should be included) + siblingPath := filepath.Join(tmpDir, "sibling.go") + if err := os.WriteFile(siblingPath, []byte("package main\n\nfunc Sibling() {}\n"), 0644); err != nil { + t.Fatal(err) + } + + // Create a non-.go sibling (should not be included) + if err := os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("notes"), 0644); err != nil { + t.Fatal(err) + } + + ctx := harvestContext(targetPath, "Go") + + if !strings.Contains(ctx, "sibling.go") { + t.Error("expected sibling.go in context") + } + if !strings.Contains(ctx, "func Sibling()") { + t.Error("expected sibling content in context") + } + if strings.Contains(ctx, "target.go") { + t.Error("target file should not appear in its own context") + } + if strings.Contains(ctx, "notes.txt") { + t.Error("non-matching extension should not appear in context") + } +} + +func TestHarvestContextEmptyDir(t *testing.T) { + tmpDir := t.TempDir() + targetPath := filepath.Join(tmpDir, "lonely.go") + if err := os.WriteFile(targetPath, []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + + ctx := harvestContext(targetPath, "Go") + if ctx != "" { + t.Errorf("expected empty context for solo file, got: %q", ctx) + } +} + // TestScaffoldCmd_Live — LIVE INTEGRATION TEST (only runs when you ask) func TestScaffoldCmd_Live(t *testing.T) { if !testing.Short() { diff --git a/cmd/workon_test.go b/cmd/workon_test.go new file mode 100644 index 0000000..dc67315 --- /dev/null +++ b/cmd/workon_test.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "os" + "testing" +) + +// TestWorkonCmd — FAST DEFAULT TEST (runs on make test / go test ./cmd) +func TestWorkonCmd(t *testing.T) { + t.Log("✓ Fast workon unit test (no Grok API call)") + + // Verify the command is registered + found := false + for _, c := range rootCmd.Commands() { + if c.Use == "workon " { + found = true + break + } + } + if !found { + t.Fatal("workon command not registered on rootCmd") + } +} + +func TestWorkonFlagRegistration(t *testing.T) { + tests := []struct { + name string + flagName string + shorthand string + }{ + {"message flag", "message", "M"}, + {"fix flag", "fix", "f"}, + {"complete flag", "complete", "c"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flag := workonCmd.Flags().Lookup(tt.flagName) + if flag == nil { + t.Fatalf("flag %q not found on workon command", tt.flagName) + } + if flag.Shorthand != tt.shorthand { + t.Errorf("flag %q shorthand = %q, want %q", tt.flagName, flag.Shorthand, tt.shorthand) + } + }) + } +} + +func TestWorkonFlagExclusivity(t *testing.T) { + // Run in a temp dir so todo.Bootstrap() doesn't leave orphan dirs in cmd/ + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + tests := []struct { + name string + args []string + expectErr bool + }{ + { + name: "complete alone is valid", + args: []string{"workon", "test-item", "-c"}, + expectErr: false, // will fail at Run level (no git repo), but not at flag validation + }, + { + name: "complete with fix is valid", + args: []string{"workon", "test-item", "-c", "-f"}, + expectErr: false, + }, + { + name: "complete with message is invalid", + args: []string{"workon", "test-item", "-c", "-M", "some msg"}, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rootCmd.SetArgs(tt.args) + err := rootCmd.Execute() + + if tt.expectErr && err == nil { + t.Error("expected error, got nil") + } + // Note: non-error cases may still fail (no git, no API key, etc.) + // We only assert that the flag validation error fires when expected + if tt.expectErr && err != nil { + // Verify it's the right error + if err.Error() != "-c cannot be combined with -m" { + // The error might be wrapped + t.Logf("got error: %v (flag validation may have been triggered)", err) + } + } + }) + } +} + +func TestWorkonRequiresArgs(t *testing.T) { + rootCmd.SetArgs([]string{"workon"}) + err := rootCmd.Execute() + if err == nil { + t.Error("expected error when no args provided, got nil") + } +} diff --git a/config/config_test.go b/config/config_test.go index 22e7c0b..c00d40a 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -136,6 +136,26 @@ func TestGetTimeout(t *testing.T) { } } +func TestGetIDECommand(t *testing.T) { + viper.Reset() + + t.Run("empty by default", func(t *testing.T) { + got := GetIDECommand() + if got != "" { + t.Errorf("GetIDECommand() default = %q, want empty", got) + } + }) + + t.Run("returns configured value", func(t *testing.T) { + viper.Set("commands.workon.ide", "code") + got := GetIDECommand() + if got != "code" { + t.Errorf("GetIDECommand() = %q, want 'code'", got) + } + viper.Set("commands.workon.ide", "") // reset + }) +} + func TestGetLogLevel(t *testing.T) { viper.Reset() viper.SetDefault("log_level", "info") diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 517dd78..4a631c6 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -1,6 +1,9 @@ package git import ( + "os" + "os/exec" + "strings" "testing" ) @@ -64,3 +67,162 @@ func TestGitRunner(t *testing.T) { // Test IsRepo _ = runner.IsRepo() // Just ensure it doesn't panic } + +// initTaggedGitRepo creates a temp git repo with an initial commit and a tag. +func initTaggedGitRepo(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = tmpDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git setup failed (%v): %s", err, out) + } + } + + // Create initial commit and tag + if err := os.WriteFile(tmpDir+"/init.txt", []byte("init"), 0644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"git", "add", "."}, + {"git", "commit", "-m", "initial commit"}, + {"git", "tag", "v0.1.0"}, + } { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = tmpDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git setup (%v) failed: %s", args, out) + } + } + return tmpDir +} + +func TestLatestTag(t *testing.T) { + tmpDir := initTaggedGitRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + tag, err := LatestTag() + if err != nil { + t.Fatalf("LatestTag() failed: %v", err) + } + if tag != "v0.1.0" { + t.Errorf("LatestTag() = %q, want 'v0.1.0'", tag) + } +} + +func TestLatestTagMultiple(t *testing.T) { + tmpDir := initTaggedGitRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Add a second commit and tag + if err := os.WriteFile("second.txt", []byte("second"), 0644); err != nil { + t.Fatal(err) + } + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "second commit").Run() + exec.Command("git", "tag", "v0.2.0").Run() + + tag, err := LatestTag() + if err != nil { + t.Fatalf("LatestTag() failed: %v", err) + } + if tag != "v0.2.0" { + t.Errorf("LatestTag() = %q, want 'v0.2.0'", tag) + } +} + +func TestPreviousTag(t *testing.T) { + tmpDir := initTaggedGitRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Add second commit and tag + if err := os.WriteFile("second.txt", []byte("second"), 0644); err != nil { + t.Fatal(err) + } + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "second commit").Run() + exec.Command("git", "tag", "v0.2.0").Run() + + prev, err := PreviousTag("v0.2.0") + if err != nil { + t.Fatalf("PreviousTag() failed: %v", err) + } + if prev != "v0.1.0" { + t.Errorf("PreviousTag('v0.2.0') = %q, want 'v0.1.0'", prev) + } +} + +func TestLogSince(t *testing.T) { + tmpDir := initTaggedGitRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Add commits after the tag + if err := os.WriteFile("new.txt", []byte("new"), 0644); err != nil { + t.Fatal(err) + } + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "feat: add new feature").Run() + + log, err := LogSince("v0.1.0") + if err != nil { + t.Fatalf("LogSince() failed: %v", err) + } + if !strings.Contains(log, "feat: add new feature") { + t.Errorf("LogSince() missing expected commit message, got: %q", log) + } +} + +func TestDiff(t *testing.T) { + tmpDir := initTaggedGitRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + t.Run("no changes returns empty", func(t *testing.T) { + out, err := Diff([]string{"diff"}) + if err != nil { + t.Fatalf("Diff() failed: %v", err) + } + if out != "" { + t.Errorf("Diff() with no changes = %q, want empty", out) + } + }) + + t.Run("with unstaged changes returns diff", func(t *testing.T) { + if err := os.WriteFile("init.txt", []byte("modified"), 0644); err != nil { + t.Fatal(err) + } + + out, err := Diff([]string{"diff"}) + if err != nil { + t.Fatalf("Diff() failed: %v", err) + } + if !strings.Contains(out, "modified") { + t.Errorf("Diff() missing change content, got: %q", out) + } + }) +} diff --git a/internal/linter/linter_test.go b/internal/linter/linter_test.go index e731468..36929cb 100644 --- a/internal/linter/linter_test.go +++ b/internal/linter/linter_test.go @@ -317,6 +317,91 @@ func TestLanguageStructure(t *testing.T) { } } +func TestDetectPrimaryLanguage(t *testing.T) { + tests := []struct { + name string + files []string + expected string + }{ + { + name: "empty list returns unknown", + files: []string{}, + expected: "unknown", + }, + { + name: "all Go files", + files: []string{"main.go", "handler.go", "util.go"}, + expected: "go", + }, + { + name: "all Python files", + files: []string{"app.py", "utils.py"}, + expected: "python", + }, + { + name: "mixed with Go bias", + files: []string{"main.go", "helper.go", "script.py", "app.js", "style.ts"}, + expected: "go", + }, + { + name: "unsupported files only returns unknown", + files: []string{"notes.txt", "data.csv", "readme.md"}, + expected: "unknown", + }, + { + name: "C files normalized", + files: []string{"main.c", "util.c", "helper.c"}, + expected: "c", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create real temp files so DetectLanguage can stat them + tmpDir := t.TempDir() + var paths []string + for _, f := range tt.files { + path := filepath.Join(tmpDir, f) + if err := os.WriteFile(path, []byte("content"), 0644); err != nil { + t.Fatal(err) + } + paths = append(paths, path) + } + + got := DetectPrimaryLanguage(paths) + if got != tt.expected { + t.Errorf("DetectPrimaryLanguage() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestSupportedLanguages(t *testing.T) { + langs := SupportedLanguages() + if len(langs) == 0 { + t.Fatal("SupportedLanguages() returned empty list") + } + + // Should be lowercase + for _, l := range langs { + if l == "" { + t.Error("SupportedLanguages() contains empty string") + } + } + + // Should contain "go" + foundGo := false + for _, l := range langs { + if l == "go" { + foundGo = true + break + } + } + if !foundGo { + t.Errorf("SupportedLanguages() missing 'go', got: %v", langs) + } +} + // Helper function func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index d1192fa..23f6cf6 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -105,6 +105,24 @@ func TestSetLevel(t *testing.T) { } } +func TestSetLevelAllBranches(t *testing.T) { + tmpDir := t.TempDir() + oldHome := os.Getenv("HOME") + setHome(t, tmpDir) + defer func() { _ = os.Setenv("HOME", oldHome) }() + + if err := Init("info"); err != nil { + t.Fatalf("Init() failed: %v", err) + } + + levels := []string{"debug", "info", "warn", "error"} + for _, lvl := range levels { + t.Run(lvl, func(t *testing.T) { + SetLevel(lvl) // should not panic + }) + } +} + func TestWith(t *testing.T) { tmpDir := t.TempDir() oldHome := os.Getenv("HOME") diff --git a/internal/prompts/analyze_test.go b/internal/prompts/analyze_test.go new file mode 100644 index 0000000..5d82693 --- /dev/null +++ b/internal/prompts/analyze_test.go @@ -0,0 +1,162 @@ +package prompts + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadAnalysisPromptProjectLocal(t *testing.T) { + tmpDir := t.TempDir() + promptDir := filepath.Join(tmpDir, ".grokkit", "prompts") + if err := os.MkdirAll(promptDir, 0755); err != nil { + t.Fatal(err) + } + + expected := "You are a Go expert educator." + if err := os.WriteFile(filepath.Join(promptDir, "go.md"), []byte(expected), 0644); err != nil { + t.Fatal(err) + } + + path, content, err := LoadAnalysisPrompt(tmpDir, "go") + if err != nil { + t.Fatalf("LoadAnalysisPrompt failed: %v", err) + } + if content != expected { + t.Errorf("content = %q, want %q", content, expected) + } + if !filepath.IsAbs(path) || path == "" { + // path should be meaningful + t.Logf("returned path: %s", path) + } +} + +func TestLoadAnalysisPromptGlobalFallback(t *testing.T) { + projectDir := t.TempDir() // no .grokkit/prompts here + globalDir := t.TempDir() + + promptDir := filepath.Join(globalDir, ".config", "grokkit", "prompts") + if err := os.MkdirAll(promptDir, 0755); err != nil { + t.Fatal(err) + } + + expected := "You are a Python expert." + if err := os.WriteFile(filepath.Join(promptDir, "python.md"), []byte(expected), 0644); err != nil { + t.Fatal(err) + } + + // Override HOME to point to our temp global dir + oldHome := os.Getenv("HOME") + defer func() { _ = os.Setenv("HOME", oldHome) }() + _ = os.Setenv("HOME", globalDir) + + path, content, err := LoadAnalysisPrompt(projectDir, "python") + if err != nil { + t.Fatalf("LoadAnalysisPrompt (global fallback) failed: %v", err) + } + if content != expected { + t.Errorf("content = %q, want %q", content, expected) + } + _ = path +} + +func TestLoadAnalysisPromptProjectOverridesGlobal(t *testing.T) { + projectDir := t.TempDir() + globalDir := t.TempDir() + + // Create both project-local and global prompts + localPromptDir := filepath.Join(projectDir, ".grokkit", "prompts") + if err := os.MkdirAll(localPromptDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(localPromptDir, "go.md"), []byte("local prompt"), 0644); err != nil { + t.Fatal(err) + } + + globalPromptDir := filepath.Join(globalDir, ".config", "grokkit", "prompts") + if err := os.MkdirAll(globalPromptDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(globalPromptDir, "go.md"), []byte("global prompt"), 0644); err != nil { + t.Fatal(err) + } + + oldHome := os.Getenv("HOME") + defer func() { _ = os.Setenv("HOME", oldHome) }() + _ = os.Setenv("HOME", globalDir) + + _, content, err := LoadAnalysisPrompt(projectDir, "go") + if err != nil { + t.Fatalf("LoadAnalysisPrompt failed: %v", err) + } + if content != "local prompt" { + t.Errorf("expected project-local to override global, got: %q", content) + } +} + +func TestLoadAnalysisPromptNotFound(t *testing.T) { + projectDir := t.TempDir() + emptyHome := t.TempDir() + + oldHome := os.Getenv("HOME") + defer func() { _ = os.Setenv("HOME", oldHome) }() + _ = os.Setenv("HOME", emptyHome) + + _, _, err := LoadAnalysisPrompt(projectDir, "rust") + if err == nil { + t.Fatal("expected error when no prompt found, got nil") + } + if !os.IsNotExist(err) { + t.Errorf("expected os.ErrNotExist, got: %v", err) + } +} + +func TestLoadAnalysisPromptDefaultsToGo(t *testing.T) { + projectDir := t.TempDir() + promptDir := filepath.Join(projectDir, ".grokkit", "prompts") + if err := os.MkdirAll(promptDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(promptDir, "go.md"), []byte("go prompt"), 0644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + language string + }{ + {"empty string defaults to go", ""}, + {"unknown defaults to go", "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, content, err := LoadAnalysisPrompt(projectDir, tt.language) + if err != nil { + t.Fatalf("LoadAnalysisPrompt(%q) failed: %v", tt.language, err) + } + if content != "go prompt" { + t.Errorf("expected go prompt, got: %q", content) + } + }) + } +} + +func TestLoadAnalysisPromptNormalizesCase(t *testing.T) { + projectDir := t.TempDir() + promptDir := filepath.Join(projectDir, ".grokkit", "prompts") + if err := os.MkdirAll(promptDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(promptDir, "python.md"), []byte("python prompt"), 0644); err != nil { + t.Fatal(err) + } + + _, content, err := LoadAnalysisPrompt(projectDir, "Python") + if err != nil { + t.Fatalf("LoadAnalysisPrompt with uppercase failed: %v", err) + } + if content != "python prompt" { + t.Errorf("expected case-insensitive match, got: %q", content) + } +} diff --git a/internal/todo/todo_test.go b/internal/todo/todo_test.go new file mode 100644 index 0000000..e3809b3 --- /dev/null +++ b/internal/todo/todo_test.go @@ -0,0 +1,123 @@ +package todo + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBootstrapCreatesStructure(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + if err := Bootstrap(); err != nil { + t.Fatalf("Bootstrap failed: %v", err) + } + + // Verify directories + for _, dir := range []string{"todo/queued", "todo/doing", "todo/completed"} { + info, err := os.Stat(dir) + if err != nil { + t.Errorf("expected %s to exist: %v", dir, err) + continue + } + if !info.IsDir() { + t.Errorf("expected %s to be a directory", dir) + } + } + + // Verify README + data, err := os.ReadFile("todo/README.md") + if err != nil { + t.Fatalf("expected todo/README.md to exist: %v", err) + } + content := string(data) + if !strings.Contains(content, "## Queued") { + t.Error("README missing ## Queued heading") + } + if !strings.Contains(content, "## Completed") { + t.Error("README missing ## Completed heading") + } +} + +func TestBootstrapIdempotent(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Run twice + if err := Bootstrap(); err != nil { + t.Fatalf("first Bootstrap failed: %v", err) + } + if err := Bootstrap(); err != nil { + t.Fatalf("second Bootstrap failed: %v", err) + } + + // Verify structure still intact + for _, dir := range []string{"todo/queued", "todo/doing", "todo/completed"} { + if _, err := os.Stat(dir); err != nil { + t.Errorf("expected %s to exist after second Bootstrap: %v", dir, err) + } + } +} + +func TestBootstrapPreservesExistingReadme(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Create structure with custom README + if err := os.MkdirAll("todo", 0755); err != nil { + t.Fatal(err) + } + customContent := "# My Custom TODO\n\nCustom content here.\n" + if err := os.WriteFile("todo/README.md", []byte(customContent), 0644); err != nil { + t.Fatal(err) + } + + if err := Bootstrap(); err != nil { + t.Fatalf("Bootstrap failed: %v", err) + } + + // Verify custom README was not overwritten + data, err := os.ReadFile("todo/README.md") + if err != nil { + t.Fatal(err) + } + if string(data) != customContent { + t.Errorf("Bootstrap overwrote existing README.\ngot: %q\nwant: %q", string(data), customContent) + } +} + +func TestBootstrapCreatesReadmeWithCorrectPermissions(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + if err := Bootstrap(); err != nil { + t.Fatal(err) + } + + info, err := os.Stat(filepath.Join("todo", "README.md")) + if err != nil { + t.Fatal(err) + } + perm := info.Mode().Perm() + if perm != 0644 { + t.Errorf("README permissions = %o, want 0644", perm) + } +} diff --git a/internal/workon/workon_test.go b/internal/workon/workon_test.go new file mode 100644 index 0000000..3f1264d --- /dev/null +++ b/internal/workon/workon_test.go @@ -0,0 +1,474 @@ +package workon + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestRunRejectsEmptyTitle(t *testing.T) { + err := Run("", "", false, false) + if err == nil { + t.Fatal("expected error for empty title, got nil") + } + if !strings.Contains(err.Error(), "todo_item_title is required") { + t.Errorf("unexpected error message: %s", err.Error()) + } +} + +func TestMoveQueuedToDoing(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Set up todo structure + if err := os.MkdirAll("todo/queued", 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll("todo/doing", 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("todo/queued/test-item.md", []byte("# Test Item\n"), 0644); err != nil { + t.Fatal(err) + } + + if err := moveQueuedToDoing("test-item"); err != nil { + t.Fatalf("moveQueuedToDoing failed: %v", err) + } + + // Verify source is gone + if _, err := os.Stat("todo/queued/test-item.md"); !os.IsNotExist(err) { + t.Error("expected queued file to be removed") + } + // Verify destination exists + if _, err := os.Stat("todo/doing/test-item.md"); err != nil { + t.Errorf("expected doing file to exist: %v", err) + } +} + +func TestMoveQueuedToDoingMissingFile(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + if err := os.MkdirAll("todo/queued", 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll("todo/doing", 0755); err != nil { + t.Fatal(err) + } + + err := moveQueuedToDoing("nonexistent") + if err == nil { + t.Fatal("expected error for missing queued file, got nil") + } +} + +func TestCreateFixFile(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "my-fix.md") + + if err := createFixFile(path, "my-fix"); err != nil { + t.Fatalf("createFixFile failed: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read created file: %v", err) + } + + content := string(data) + if !strings.Contains(content, "# my-fix") { + t.Errorf("expected title heading, got: %s", content) + } + if !strings.Contains(content, "## Work Plan") { + t.Errorf("expected Work Plan heading, got: %s", content) + } +} + +func TestReadFileContent(t *testing.T) { + tmpDir := t.TempDir() + + t.Run("existing file", func(t *testing.T) { + path := filepath.Join(tmpDir, "exists.md") + expected := "hello world" + if err := os.WriteFile(path, []byte(expected), 0644); err != nil { + t.Fatal(err) + } + got := readFileContent(path) + if got != expected { + t.Errorf("readFileContent = %q, want %q", got, expected) + } + }) + + t.Run("missing file returns empty string", func(t *testing.T) { + got := readFileContent(filepath.Join(tmpDir, "missing.md")) + if got != "" { + t.Errorf("readFileContent for missing file = %q, want empty", got) + } + }) +} + +func TestCompleteItemMovesFile(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Set up todo structure with a doing item + if err := os.MkdirAll("todo/doing", 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll("todo/completed", 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("todo/doing/test-item.md", []byte("# Test\n"), 0644); err != nil { + t.Fatal(err) + } + + // completeItem will try to git commit — that will fail in a non-git temp dir, + // but we can verify the file move happened before the commit step + _ = completeItem("test-item", true) + + // For a fix, file should be moved regardless of commit outcome + if _, err := os.Stat("todo/completed/test-item.md"); err != nil { + t.Errorf("expected completed file to exist: %v", err) + } + if _, err := os.Stat("todo/doing/test-item.md"); !os.IsNotExist(err) { + t.Error("expected doing file to be removed") + } +} + +func TestUpdateReadmeIndex(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + if err := os.MkdirAll("todo", 0755); err != nil { + t.Fatal(err) + } + + readme := `# Todo Index + +## Queued + +* [1] [my-feature](queued/my-feature.md): My feature +* [2] [other-item](queued/other-item.md): Other item + +## Completed + +* [old-item](./completed/old-item.md) : old-item *(done)* +` + if err := os.WriteFile("todo/README.md", []byte(readme), 0644); err != nil { + t.Fatal(err) + } + + if err := updateReadmeIndex("my-feature"); err != nil { + t.Fatalf("updateReadmeIndex failed: %v", err) + } + + data, err := os.ReadFile("todo/README.md") + if err != nil { + t.Fatal(err) + } + result := string(data) + + // Should have removed the queued entry + if strings.Contains(result, "queued/my-feature.md") { + t.Error("expected my-feature to be removed from Queued section") + } + + // Should still have the other queued item + if !strings.Contains(result, "queued/other-item.md") { + t.Error("expected other-item to remain in Queued section") + } + + // Should have added to Completed section + if !strings.Contains(result, "completed/my-feature.md") { + t.Error("expected my-feature to appear in Completed section") + } + + // Old completed item should still be there + if !strings.Contains(result, "completed/old-item.md") { + t.Error("expected old-item to remain in Completed section") + } +} + +func TestUpdateReadmeIndexMissingReadme(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + err := updateReadmeIndex("anything") + if err == nil { + t.Fatal("expected error when README doesn't exist, got nil") + } +} + +// initTempGitRepo creates a temp dir with a git repo and returns it. +// The caller is responsible for chdir-ing back. +func initTempGitRepo(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = tmpDir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init setup failed (%v): %s", err, out) + } + } + return tmpDir +} + +func TestCreateGitBranchNew(t *testing.T) { + tmpDir := initTempGitRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Need at least one commit for branches to work + if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil { + t.Fatal(err) + } + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "initial").Run() + + if err := createGitBranch("feature/test-branch"); err != nil { + t.Fatalf("createGitBranch failed: %v", err) + } + + // Verify we're on the new branch + out, err := exec.Command("git", "branch", "--show-current").Output() + if err != nil { + t.Fatalf("git branch --show-current failed: %v", err) + } + branch := strings.TrimSpace(string(out)) + if branch != "feature/test-branch" { + t.Errorf("expected branch 'feature/test-branch', got %q", branch) + } +} + +func TestCreateGitBranchExisting(t *testing.T) { + tmpDir := initTempGitRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Initial commit + create branch + if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil { + t.Fatal(err) + } + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "initial").Run() + exec.Command("git", "checkout", "-b", "fix/existing").Run() + exec.Command("git", "checkout", "-").Run() // back to main/master + + // Should checkout existing branch, not error + if err := createGitBranch("fix/existing"); err != nil { + t.Fatalf("createGitBranch for existing branch failed: %v", err) + } + + out, _ := exec.Command("git", "branch", "--show-current").Output() + branch := strings.TrimSpace(string(out)) + if branch != "fix/existing" { + t.Errorf("expected branch 'fix/existing', got %q", branch) + } +} + +func TestCommitChangesInGitRepo(t *testing.T) { + tmpDir := initTempGitRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Initial commit + if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil { + t.Fatal(err) + } + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "initial").Run() + + // Create a todo file to commit + if err := os.MkdirAll("todo/doing", 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("todo/doing/test.md", []byte("# Test\n"), 0644); err != nil { + t.Fatal(err) + } + + if err := commitChanges("test commit message"); err != nil { + t.Fatalf("commitChanges failed: %v", err) + } + + // Verify the commit was created + out, err := exec.Command("git", "log", "--oneline", "-1").Output() + if err != nil { + t.Fatalf("git log failed: %v", err) + } + if !strings.Contains(string(out), "test commit message") { + t.Errorf("expected commit message in log, got: %s", out) + } +} + +func TestCommitChangesNothingToCommit(t *testing.T) { + tmpDir := initTempGitRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Initial commit + if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil { + t.Fatal(err) + } + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "initial").Run() + + // Create empty todo dir but no files to stage + if err := os.MkdirAll("todo", 0755); err != nil { + t.Fatal(err) + } + + // Should fail — nothing to commit + err := commitChanges("empty commit") + if err == nil { + t.Error("expected error when nothing to commit, got nil") + } +} + +func TestCompleteItemWithGit(t *testing.T) { + tmpDir := initTempGitRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Initial commit + if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil { + t.Fatal(err) + } + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "initial").Run() + + // Set up todo structure + for _, d := range []string{"todo/doing", "todo/completed"} { + if err := os.MkdirAll(d, 0755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile("todo/doing/my-fix.md", []byte("# Fix\n"), 0644); err != nil { + t.Fatal(err) + } + // Stage and commit the doing file first so git tracks it + exec.Command("git", "add", "todo/").Run() + exec.Command("git", "commit", "-m", "add doing item").Run() + + // Now complete as a fix (no README update) + if err := completeItem("my-fix", true); err != nil { + t.Fatalf("completeItem failed: %v", err) + } + + // Verify moved + if _, err := os.Stat("todo/completed/my-fix.md"); err != nil { + t.Errorf("expected completed file: %v", err) + } + if _, err := os.Stat("todo/doing/my-fix.md"); !os.IsNotExist(err) { + t.Error("expected doing file to be gone") + } + + // Verify commit + out, _ := exec.Command("git", "log", "--oneline", "-1").Output() + if !strings.Contains(string(out), "Complete work on my-fix") { + t.Errorf("expected completion commit, got: %s", out) + } +} + +func TestCompleteItemNonFixUpdatesReadme(t *testing.T) { + tmpDir := initTempGitRepo(t) + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Initial commit + if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil { + t.Fatal(err) + } + exec.Command("git", "add", ".").Run() + exec.Command("git", "commit", "-m", "initial").Run() + + // Set up todo structure with README + for _, d := range []string{"todo/doing", "todo/completed"} { + if err := os.MkdirAll(d, 0755); err != nil { + t.Fatal(err) + } + } + readme := "# Todo\n\n## Queued\n\n* [my-todo](queued/my-todo.md): My todo\n\n## Completed\n\n" + if err := os.WriteFile("todo/README.md", []byte(readme), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("todo/doing/my-todo.md", []byte("# My Todo\n"), 0644); err != nil { + t.Fatal(err) + } + exec.Command("git", "add", "todo/").Run() + exec.Command("git", "commit", "-m", "add todo item").Run() + + // Complete as a non-fix (should update README) + if err := completeItem("my-todo", false); err != nil { + t.Fatalf("completeItem (non-fix) failed: %v", err) + } + + data, err := os.ReadFile("todo/README.md") + if err != nil { + t.Fatal(err) + } + result := string(data) + + if strings.Contains(result, "queued/my-todo.md") { + t.Error("expected my-todo removed from Queued") + } + if !strings.Contains(result, "completed/my-todo.md") { + t.Error("expected my-todo added to Completed") + } +} + +func TestRunCnaddIfAvailable(t *testing.T) { + // Just exercise the function — it should not panic regardless + // of whether cnadd is installed + runCnaddIfAvailable("test-item") +} + +func TestOpenIDEIfConfigured(t *testing.T) { + // With no IDE configured (default), this should silently skip + openIDEIfConfigured() +}