From dcca4baa20ec6189ede67442146c6cbe45525637 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Dec 2025 05:20:06 +0000 Subject: [PATCH] Add CI workflow and comprehensive test suite for homebrew-core PR - Add .github/workflows/ci.yml with tests running on pull requests - Unit tests across Go 1.21, 1.22, 1.23 on ubuntu and macos - Build verification on ubuntu, macos, and windows - Linting with staticcheck and gofmt - Homebrew formula syntax validation - Integration tests for CLI functionality - Add unit tests for scanner package: - walker_test.go: file scanning, gitignore, directory filtering - deps_test.go: manifest parsing (go.mod, requirements.txt, package.json, etc) - git_test.go: diff info, filtering, impact analysis - Add unit tests for render package: - colors_test.go: ANSI colors, file color mapping, asset detection - tree_test.go: tree building, size formatting, top large files - Add integration tests in main_test.go: - CLI flag validation (--help, --json, --skyline, --deps, --diff) - Path handling (subdirs, relative paths, nonexistent paths) - JSON output validation --- .github/workflows/ci.yml | 183 +++++++++++++++++++++++ main_test.go | 263 ++++++++++++++++++++++++++++++++ render/colors_test.go | 218 +++++++++++++++++++++++++++ render/depgraph.go | 1 - render/tree_test.go | 256 +++++++++++++++++++++++++++++++ scanner/deps_test.go | 314 +++++++++++++++++++++++++++++++++++++++ scanner/git_test.go | 259 ++++++++++++++++++++++++++++++++ scanner/walker_test.go | 241 ++++++++++++++++++++++++++++++ 8 files changed, 1734 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 main_test.go create mode 100644 render/colors_test.go create mode 100644 render/tree_test.go create mode 100644 scanner/deps_test.go create mode 100644 scanner/git_test.go create mode 100644 scanner/walker_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..45512ca --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,183 @@ +name: CI + +on: + pull_request: + branches: [main, master] + push: + branches: [main, master] + +jobs: + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + go-version: ['1.21', '1.22', '1.23'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Download dependencies + run: go mod download + + - name: Verify dependencies + run: go mod verify + + - name: Run go vet + run: go vet ./... + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.23' + uses: codecov/codecov-action@v4 + with: + files: coverage.out + fail_ci_if_error: false + + build: + name: Build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Build binary + run: go build -v -o codemap${{ matrix.os == 'windows-latest' && '.exe' || '' }} . + + - name: Test binary runs + shell: bash + run: ./codemap${{ matrix.os == 'windows-latest' && '.exe' || '' }} --help + + - name: Test basic tree output + shell: bash + run: ./codemap${{ matrix.os == 'windows-latest' && '.exe' || '' }} . + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Run staticcheck + uses: dominikh/staticcheck-action@v1 + with: + version: "latest" + install-go: false + + - name: Check formatting + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "Go files are not formatted:" + gofmt -d . + exit 1 + fi + + homebrew-audit: + name: Homebrew Formula Audit + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Audit formula syntax + run: | + # Install homebrew-core tap for comparison + brew tap homebrew/core --force 2>/dev/null || true + + # Run basic Ruby syntax check on formula + ruby -c codemap.rb + + # Check formula follows Homebrew style guidelines + brew style --formula codemap.rb || true + + # Validate required formula components + echo "Checking formula has required components..." + grep -q 'class Codemap < Formula' codemap.rb + grep -q 'desc' codemap.rb + grep -q 'homepage' codemap.rb + grep -q 'url' codemap.rb + grep -q 'sha256' codemap.rb + grep -q 'license' codemap.rb + grep -q 'def install' codemap.rb + grep -q 'test do' codemap.rb + echo "Formula has all required components" + + integration: + name: Integration Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Build binary + run: go build -o codemap . + + - name: Test tree mode + run: | + output=$(./codemap .) + if [ -z "$output" ]; then + echo "Tree mode produced no output" + exit 1 + fi + echo "Tree mode output:" + echo "$output" | head -20 + + - name: Test help flag + run: | + output=$(./codemap --help) + echo "$output" | grep -q "codemap" + echo "$output" | grep -q "Usage:" + echo "$output" | grep -q "\-\-skyline" + echo "$output" | grep -q "\-\-deps" + echo "$output" | grep -q "\-\-diff" + + - name: Test JSON output + run: | + output=$(./codemap --json .) + echo "$output" | python3 -c "import sys, json; json.load(sys.stdin)" + echo "JSON output is valid" + + - name: Test diff mode (should work in git repo) + run: | + # This should either show changes or say "No files changed" + ./codemap --diff . || true + + - name: Test with subdirectory + run: | + ./codemap scanner + ./codemap render + + - name: Test nonexistent path handling + run: | + # Should fail gracefully + if ./codemap /nonexistent/path 2>&1; then + echo "Should have failed for nonexistent path" + exit 1 + fi + echo "Correctly failed for nonexistent path" diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..8b71e51 --- /dev/null +++ b/main_test.go @@ -0,0 +1,263 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "codemap/scanner" +) + +// TestMain runs before all tests +func TestMain(m *testing.M) { + // Build the binary for integration tests + cmd := exec.Command("go", "build", "-o", "codemap_test_binary", ".") + if err := cmd.Run(); err != nil { + os.Exit(1) + } + code := m.Run() + os.Remove("codemap_test_binary") + os.Exit(code) +} + +func runCodemap(args ...string) (string, error) { + cmd := exec.Command("./codemap_test_binary", args...) + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + return stderr.String(), err + } + return out.String(), nil +} + +func TestHelpFlag(t *testing.T) { + output, err := runCodemap("--help") + if err != nil { + t.Fatalf("--help failed: %v", err) + } + + expectedStrings := []string{ + "codemap", + "Usage:", + "--skyline", + "--deps", + "--diff", + "--ref", + } + + for _, expected := range expectedStrings { + if !strings.Contains(output, expected) { + t.Errorf("Help output should contain %q", expected) + } + } +} + +func TestBasicTreeOutput(t *testing.T) { + output, err := runCodemap(".") + if err != nil { + t.Fatalf("Basic tree failed: %v", err) + } + + // Should contain project name and file stats + if !strings.Contains(output, "Files:") && !strings.Contains(output, "Changed:") { + t.Error("Output should contain file stats") + } + + // Should contain some Go files from this project + if !strings.Contains(output, ".go") { + t.Error("Output should show .go files for this Go project") + } +} + +func TestJSONOutput(t *testing.T) { + output, err := runCodemap("--json", ".") + if err != nil { + t.Fatalf("JSON output failed: %v", err) + } + + // Should be valid JSON + var project scanner.Project + if err := json.Unmarshal([]byte(output), &project); err != nil { + t.Errorf("Output should be valid JSON: %v", err) + } + + // Verify structure + if project.Root == "" { + t.Error("JSON should have root field") + } + if project.Mode != "tree" { + t.Errorf("Expected mode 'tree', got %q", project.Mode) + } + if len(project.Files) == 0 { + t.Error("JSON should have files") + } + + // Verify file info structure + for _, f := range project.Files { + if f.Path == "" { + t.Error("File path should not be empty") + } + } +} + +func TestSubdirectoryPath(t *testing.T) { + // Test scanning a subdirectory + output, err := runCodemap("scanner") + if err != nil { + t.Fatalf("Subdirectory scan failed: %v", err) + } + + // Should contain files from scanner directory + if !strings.Contains(output, ".go") { + t.Error("Should show .go files in scanner directory") + } +} + +func TestNonexistentPath(t *testing.T) { + _, err := runCodemap("/nonexistent/path/that/does/not/exist") + if err == nil { + t.Error("Should fail for nonexistent path") + } +} + +func TestDiffModeInGitRepo(t *testing.T) { + // This should either work or say "No files changed" + output, err := runCodemap("--diff", ".") + // Even if there's an error, we expect some output + if err != nil && output == "" { + // Check if it's a "no changes" scenario which is fine + stderrOutput, _ := runCodemap("--diff", ".") + if !strings.Contains(stderrOutput, "No files changed") { + t.Logf("Diff mode output: %s", output) + } + } +} + +func TestSkylineFlag(t *testing.T) { + // Create a temp dir with some files to test skyline mode + tmpDir := t.TempDir() + for _, name := range []string{"a.go", "b.go", "c.py"} { + f, _ := os.Create(filepath.Join(tmpDir, name)) + f.WriteString("content\n") + f.Close() + } + + output, err := runCodemap("--skyline", tmpDir) + if err != nil { + t.Fatalf("Skyline mode failed: %v", err) + } + + // Skyline mode should produce some output (could be minimal for small projects) + if output == "" { + t.Error("Skyline mode should produce output") + } +} + +func TestDepsModeFallback(t *testing.T) { + // Without grammars installed, --deps should give a helpful error message + output, err := runCodemap("--deps", ".") + + // This might succeed if grammars are installed, or fail with a message + if err != nil { + // Should have a helpful error message about grammars + if !strings.Contains(output, "grammar") && !strings.Contains(output, "Grammar") { + // It's okay if it just fails differently in CI + t.Logf("Deps mode output: %s", output) + } + } +} + +func TestJSONDepsOutput(t *testing.T) { + // Test JSON output for deps mode (if grammars are available) + output, err := runCodemap("--deps", "--json", ".") + + if err != nil { + // Expected if no grammars + return + } + + // Should be valid JSON + var depsProject scanner.DepsProject + if err := json.Unmarshal([]byte(output), &depsProject); err != nil { + t.Errorf("Deps JSON should be valid: %v", err) + } + + if depsProject.Mode != "deps" { + t.Errorf("Expected mode 'deps', got %q", depsProject.Mode) + } +} + +func TestEmptyDirectory(t *testing.T) { + tmpDir := t.TempDir() + + output, err := runCodemap(tmpDir) + if err != nil { + // Empty directory might be handled differently + t.Logf("Empty dir output: %s", output) + } + // Main thing is it shouldn't panic +} + +func TestDebugFlag(t *testing.T) { + output, err := runCodemap("--debug", ".") + + // Debug should produce output on stderr about paths and gitignore + // We can't easily capture stderr here, but at least check it doesn't fail + if err != nil { + t.Logf("Debug mode produced error: %v", err) + } + // Should still produce tree output on stdout + if output == "" { + t.Error("Debug mode should still produce output") + } +} + +func TestMultipleFlags(t *testing.T) { + // Test combining flags + output, err := runCodemap("--json", ".") + if err != nil { + t.Fatalf("Multiple flags failed: %v", err) + } + + if !strings.HasPrefix(strings.TrimSpace(output), "{") { + t.Error("JSON flag should produce JSON output starting with {") + } +} + +func TestRelativePath(t *testing.T) { + // Test with relative path + output, err := runCodemap("./scanner") + if err != nil { + t.Fatalf("Relative path failed: %v", err) + } + + if output == "" { + t.Error("Should produce output for relative path") + } +} + +func TestCurrentDirectory(t *testing.T) { + // Test default (current directory) + output1, err1 := runCodemap() + output2, err2 := runCodemap(".") + + if err1 != nil { + t.Fatalf("No arg failed: %v", err1) + } + if err2 != nil { + t.Fatalf("Dot arg failed: %v", err2) + } + + // Both should produce similar output + // (Not checking exact equality as timing might differ) + if (output1 == "") != (output2 == "") { + t.Error("No arg and '.' should produce similar results") + } +} diff --git a/render/colors_test.go b/render/colors_test.go new file mode 100644 index 0000000..e55e499 --- /dev/null +++ b/render/colors_test.go @@ -0,0 +1,218 @@ +package render + +import ( + "strings" + "testing" +) + +func TestGetFileColor(t *testing.T) { + tests := []struct { + ext string + expected string + }{ + // Go files + {".go", Cyan}, + {".mod", Cyan}, + {".dart", Cyan}, + // Python/JS + {".py", Yellow}, + {".js", Yellow}, + {".ts", Yellow}, + {".jsx", Yellow}, + {".tsx", Yellow}, + // HTML/CSS + {".html", Magenta}, + {".css", Magenta}, + {".scss", Magenta}, + {".php", Magenta}, + // Documentation + {".md", Green}, + {".txt", Green}, + {".rst", Green}, + // Config files + {".json", Red}, + {".yaml", Red}, + {".yml", Red}, + {".toml", Red}, + {".xml", Red}, + {".rb", Red}, + // Shell scripts + {".sh", BoldWhite}, + {".bat", BoldWhite}, + // Swift/Kotlin/Java/Rust + {".swift", BoldRed}, + {".kt", BoldRed}, + {".java", BoldRed}, + {".rs", BoldRed}, + // C/C++ + {".c", BoldBlue}, + {".cpp", BoldBlue}, + {".h", BoldBlue}, + {".hpp", BoldBlue}, + {".cs", BoldBlue}, + // Other + {".lua", Blue}, + {".r", Blue}, + // Git files + {".gitignore", DimWhite}, + {".dockerignore", DimWhite}, + // Unknown + {".unknown", White}, + {"", White}, + } + + for _, tt := range tests { + t.Run(tt.ext, func(t *testing.T) { + got := GetFileColor(tt.ext) + if got != tt.expected { + t.Errorf("GetFileColor(%q) = %q, want %q", tt.ext, got, tt.expected) + } + }) + } +} + +func TestGetFileColorCaseInsensitive(t *testing.T) { + // Test that color detection is case-insensitive + tests := []string{".GO", ".Go", ".go", ".PY", ".Py", ".py"} + + for _, ext := range tests { + color := GetFileColor(ext) + if color == White && (strings.ToLower(ext) == ".go" || strings.ToLower(ext) == ".py") { + t.Errorf("GetFileColor(%q) returned White, expected colored output", ext) + } + } + + // Verify case variants return same color + if GetFileColor(".GO") != GetFileColor(".go") { + t.Error(".GO and .go should return same color") + } + if GetFileColor(".PY") != GetFileColor(".py") { + t.Error(".PY and .py should return same color") + } +} + +func TestIsAssetExtension(t *testing.T) { + assetExts := []string{ + ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp", + ".ttf", ".otf", ".woff", ".woff2", + ".mp3", ".wav", ".mp4", ".mov", + ".zip", ".tar", ".gz", ".7z", + ".pdf", ".doc", ".docx", + ".exe", ".dll", ".so", ".dylib", + ".lock", ".sum", ".map", + } + + for _, ext := range assetExts { + if !IsAssetExtension(ext) { + t.Errorf("IsAssetExtension(%q) = false, want true", ext) + } + } + + // Test case insensitivity + if !IsAssetExtension(".PNG") { + t.Error("IsAssetExtension should be case-insensitive for .PNG") + } + if !IsAssetExtension(".Jpg") { + t.Error("IsAssetExtension should be case-insensitive for .Jpg") + } +} + +func TestIsAssetExtensionSourceFiles(t *testing.T) { + sourceExts := []string{ + ".go", ".py", ".js", ".ts", ".rs", ".c", ".cpp", + ".java", ".swift", ".kt", ".rb", ".php", ".html", ".css", + } + + for _, ext := range sourceExts { + if IsAssetExtension(ext) { + t.Errorf("IsAssetExtension(%q) = true, want false for source file", ext) + } + } +} + +func TestCenterString(t *testing.T) { + tests := []struct { + s string + width int + expected string + }{ + {"test", 10, " test "}, + {"test", 11, " test "}, + {"test", 4, "test"}, + {"test", 3, "test"}, // String longer than width + {"ab", 6, " ab "}, + {"", 4, " "}, + } + + for _, tt := range tests { + t.Run(tt.s, func(t *testing.T) { + got := CenterString(tt.s, tt.width) + if got != tt.expected { + t.Errorf("CenterString(%q, %d) = %q, want %q", tt.s, tt.width, got, tt.expected) + } + }) + } +} + +func TestCenterStringLength(t *testing.T) { + // Verify centered string has correct length when string fits + s := "hello" + width := 20 + centered := CenterString(s, width) + + if len(centered) != width { + t.Errorf("CenterString(%q, %d) length = %d, want %d", s, width, len(centered), width) + } + + // When string is longer than width, it should be unchanged + s = "very long string" + width = 5 + centered = CenterString(s, width) + if centered != s { + t.Errorf("CenterString should return original string when width < len(s)") + } +} + +func TestGetTerminalWidth(t *testing.T) { + // This test just ensures GetTerminalWidth doesn't panic + // and returns a reasonable value + width := GetTerminalWidth() + if width <= 0 { + t.Errorf("GetTerminalWidth() = %d, want positive value", width) + } + // Default should be 80 when not running in a terminal + // or the actual terminal width + if width != 80 && width < 40 { + t.Errorf("GetTerminalWidth() = %d, expected >= 40 or 80", width) + } +} + +func TestANSIConstants(t *testing.T) { + // Verify ANSI constants are properly defined escape sequences + constants := map[string]string{ + "Reset": Reset, + "Bold": Bold, + "Dim": Dim, + "White": White, + "Cyan": Cyan, + "Yellow": Yellow, + "Magenta": Magenta, + "Green": Green, + "Red": Red, + "Blue": Blue, + "BoldWhite": BoldWhite, + "BoldRed": BoldRed, + "BoldBlue": BoldBlue, + "DimWhite": DimWhite, + "BoldGreen": BoldGreen, + } + + for name, value := range constants { + if !strings.HasPrefix(value, "\033[") { + t.Errorf("%s should start with ANSI escape sequence, got %q", name, value) + } + if !strings.HasSuffix(value, "m") { + t.Errorf("%s should end with 'm', got %q", name, value) + } + } +} diff --git a/render/depgraph.go b/render/depgraph.go index 92d235f..8866fb2 100644 --- a/render/depgraph.go +++ b/render/depgraph.go @@ -45,7 +45,6 @@ var stdlibNames = map[string]bool{ "react": true, "filepath": true, "embed": true, } - // normalizeImport normalizes an import string func normalizeImport(imp, lang string) string { imp = strings.Trim(imp, "\"'") diff --git a/render/tree_test.go b/render/tree_test.go new file mode 100644 index 0000000..91beaed --- /dev/null +++ b/render/tree_test.go @@ -0,0 +1,256 @@ +package render + +import ( + "testing" + + "codemap/scanner" +) + +func TestBuildTreeStructure(t *testing.T) { + files := []scanner.FileInfo{ + {Path: "main.go", Size: 100}, + {Path: "go.mod", Size: 50}, + {Path: "src/app.go", Size: 200}, + {Path: "src/util/helper.go", Size: 150}, + {Path: "test/main_test.go", Size: 80}, + } + + root := buildTreeStructure(files) + + // Root should have children + if len(root.children) == 0 { + t.Error("Root should have children") + } + + // Check main.go exists at root + if child, ok := root.children["main.go"]; !ok { + t.Error("Expected main.go at root") + } else if !child.isFile { + t.Error("main.go should be a file") + } + + // Check src directory exists + if child, ok := root.children["src"]; !ok { + t.Error("Expected src directory") + } else if child.isFile { + t.Error("src should be a directory") + } +} + +func TestBuildTreeStructureEmpty(t *testing.T) { + files := []scanner.FileInfo{} + root := buildTreeStructure(files) + + if root == nil { + t.Error("Root should not be nil") + } + if len(root.children) != 0 { + t.Error("Empty file list should produce empty tree") + } +} + +func TestBuildTreeStructureDeepNesting(t *testing.T) { + files := []scanner.FileInfo{ + {Path: "a/b/c/d/e/file.go", Size: 100}, + } + + root := buildTreeStructure(files) + + // Navigate to the file + current := root + for _, dir := range []string{"a", "b", "c", "d", "e"} { + child, ok := current.children[dir] + if !ok { + t.Errorf("Expected directory %s", dir) + return + } + if child.isFile { + t.Errorf("%s should be a directory", dir) + } + current = child + } + + // Check file exists + if file, ok := current.children["file.go"]; !ok { + t.Error("Expected file.go") + } else if !file.isFile { + t.Error("file.go should be a file") + } +} + +func TestFormatSize(t *testing.T) { + tests := []struct { + size int64 + expected string + }{ + {0, "0.0B"}, + {100, "100.0B"}, + {1023, "1023.0B"}, + {1024, "1.0KB"}, + {1536, "1.5KB"}, + {1024 * 1024, "1.0MB"}, + {1024 * 1024 * 1024, "1.0GB"}, + {1024 * 1024 * 1024 * 1024, "1.0TB"}, + {5 * 1024 * 1024, "5.0MB"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + got := formatSize(tt.size) + if got != tt.expected { + t.Errorf("formatSize(%d) = %q, want %q", tt.size, got, tt.expected) + } + }) + } +} + +func TestGetDirStats(t *testing.T) { + // Create a tree with known sizes + root := &treeNode{ + children: map[string]*treeNode{ + "dir1": { + children: map[string]*treeNode{ + "file1.go": {isFile: true, file: &scanner.FileInfo{Size: 100}}, + "file2.go": {isFile: true, file: &scanner.FileInfo{Size: 200}}, + }, + }, + "file3.go": {isFile: true, file: &scanner.FileInfo{Size: 50}}, + }, + } + + count, size := getDirStats(root) + + if count != 3 { + t.Errorf("Expected 3 files, got %d", count) + } + if size != 350 { + t.Errorf("Expected total size 350, got %d", size) + } +} + +func TestGetDirStatsSingleFile(t *testing.T) { + node := &treeNode{ + isFile: true, + file: &scanner.FileInfo{Size: 123}, + } + + count, size := getDirStats(node) + + if count != 1 { + t.Errorf("Expected 1 file, got %d", count) + } + if size != 123 { + t.Errorf("Expected size 123, got %d", size) + } +} + +func TestGetDirStatsEmptyDir(t *testing.T) { + node := &treeNode{ + children: map[string]*treeNode{}, + } + + count, size := getDirStats(node) + + if count != 0 { + t.Errorf("Expected 0 files, got %d", count) + } + if size != 0 { + t.Errorf("Expected size 0, got %d", size) + } +} + +func TestGetTopLargeFiles(t *testing.T) { + files := []scanner.FileInfo{ + {Path: "small.go", Size: 100, Ext: ".go"}, + {Path: "medium.go", Size: 500, Ext: ".go"}, + {Path: "large.go", Size: 1000, Ext: ".go"}, + {Path: "huge.go", Size: 2000, Ext: ".go"}, + {Path: "giant.go", Size: 3000, Ext: ".go"}, + {Path: "tiny.go", Size: 50, Ext: ".go"}, + {Path: "massive.go", Size: 5000, Ext: ".go"}, + } + + top := getTopLargeFiles(files) + + // Should have 5 entries + if len(top) != 5 { + t.Errorf("Expected 5 top files, got %d", len(top)) + } + + // The 5 largest should be: massive, giant, huge, large, medium + expectedLarge := []string{"massive.go", "giant.go", "huge.go", "large.go", "medium.go"} + for _, path := range expectedLarge { + if !top[path] { + t.Errorf("Expected %s to be in top large files", path) + } + } + + // Smaller files should not be included + if top["small.go"] { + t.Error("small.go should not be in top large files") + } + if top["tiny.go"] { + t.Error("tiny.go should not be in top large files") + } +} + +func TestGetTopLargeFilesExcludesAssets(t *testing.T) { + files := []scanner.FileInfo{ + {Path: "code.go", Size: 100, Ext: ".go"}, + {Path: "huge_image.png", Size: 10000000, Ext: ".png"}, + {Path: "big_video.mp4", Size: 50000000, Ext: ".mp4"}, + } + + top := getTopLargeFiles(files) + + // Assets should be excluded + if top["huge_image.png"] { + t.Error("PNG should be excluded from top large files") + } + if top["big_video.mp4"] { + t.Error("MP4 should be excluded from top large files") + } + + // code.go should be the only "large" file + if !top["code.go"] { + t.Error("code.go should be in top large files") + } +} + +func TestGetTopLargeFilesFewerThan5(t *testing.T) { + files := []scanner.FileInfo{ + {Path: "one.go", Size: 100, Ext: ".go"}, + {Path: "two.go", Size: 200, Ext: ".go"}, + } + + top := getTopLargeFiles(files) + + if len(top) != 2 { + t.Errorf("Expected 2 files, got %d", len(top)) + } +} + +func TestTreeNodeStructure(t *testing.T) { + // Test treeNode creation + node := &treeNode{ + name: "test", + isFile: false, + children: map[string]*treeNode{ + "child.go": { + name: "child.go", + isFile: true, + file: &scanner.FileInfo{Path: "test/child.go", Size: 100}, + }, + }, + } + + if node.name != "test" { + t.Errorf("Expected name 'test', got %s", node.name) + } + if node.isFile { + t.Error("Node should not be a file") + } + if len(node.children) != 1 { + t.Errorf("Expected 1 child, got %d", len(node.children)) + } +} diff --git a/scanner/deps_test.go b/scanner/deps_test.go new file mode 100644 index 0000000..695ec35 --- /dev/null +++ b/scanner/deps_test.go @@ -0,0 +1,314 @@ +package scanner + +import ( + "os" + "path/filepath" + "reflect" + "sort" + "testing" +) + +func TestParseGoMod(t *testing.T) { + gomod := `module example.com/myapp + +go 1.21 + +require ( + github.com/foo/bar v1.0.0 + github.com/baz/qux v2.0.0 + // This is a comment + golang.org/x/text v0.3.0 +) + +require github.com/indirect/dep v1.0.0 // indirect +` + + deps := parseGoMod(gomod) + + expected := []string{ + "github.com/foo/bar", + "github.com/baz/qux", + "golang.org/x/text", + } + + if len(deps) != len(expected) { + t.Errorf("Expected %d deps, got %d: %v", len(expected), len(deps), deps) + } + + for i, exp := range expected { + if i < len(deps) && deps[i] != exp { + t.Errorf("Dep %d: expected %q, got %q", i, exp, deps[i]) + } + } +} + +func TestParseGoModEmpty(t *testing.T) { + gomod := `module example.com/myapp + +go 1.21 +` + deps := parseGoMod(gomod) + if len(deps) != 0 { + t.Errorf("Expected no deps, got %v", deps) + } +} + +func TestParseRequirements(t *testing.T) { + requirements := `# Python dependencies +flask==2.0.0 +requests>=2.25.0 +numpy~=1.21.0 +pandas +scikit-learn[extra] +pytest<7.0.0 + +# Comment line +django>3.0,<4.0 +` + + deps := parseRequirements(requirements) + + expected := []string{ + "flask", + "requests", + "numpy", + "pandas", + "scikit-learn", + "pytest", + "django", + } + + if len(deps) != len(expected) { + t.Errorf("Expected %d deps, got %d: %v", len(expected), len(deps), deps) + } + + for i, exp := range expected { + if i < len(deps) && deps[i] != exp { + t.Errorf("Dep %d: expected %q, got %q", i, exp, deps[i]) + } + } +} + +func TestParseRequirementsEmpty(t *testing.T) { + requirements := `# Just comments +# No actual deps +` + deps := parseRequirements(requirements) + if len(deps) != 0 { + t.Errorf("Expected no deps, got %v", deps) + } +} + +func TestParsePackageJson(t *testing.T) { + packageJson := `{ + "name": "my-app", + "version": "1.0.0", + "dependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + "axios": "^1.0.0" + }, + "devDependencies": { + "typescript": "^5.0.0", + "jest": "^29.0.0" + } +}` + + deps := parsePackageJson(packageJson) + + expected := []string{"react", "react-dom", "axios", "typescript", "jest"} + + if len(deps) != len(expected) { + t.Errorf("Expected %d deps, got %d: %v", len(expected), len(deps), deps) + } + + // Check all expected deps are present (order may vary) + depsMap := make(map[string]bool) + for _, d := range deps { + depsMap[d] = true + } + + for _, exp := range expected { + if !depsMap[exp] { + t.Errorf("Expected dep %q not found in %v", exp, deps) + } + } +} + +func TestParsePackageJsonEmpty(t *testing.T) { + packageJson := `{ + "name": "my-app", + "version": "1.0.0" +}` + deps := parsePackageJson(packageJson) + if len(deps) != 0 { + t.Errorf("Expected no deps, got %v", deps) + } +} + +func TestParsePodfile(t *testing.T) { + podfile := `platform :ios, '14.0' + +target 'MyApp' do + use_frameworks! + + pod 'Alamofire', '~> 5.0' + pod 'SwiftyJSON' + pod "Kingfisher", "~> 7.0" + pod 'SnapKit', :git => 'https://github.com/SnapKit/SnapKit.git' + +end +` + + deps := parsePodfile(podfile) + + expected := []string{"Alamofire", "SwiftyJSON", "Kingfisher", "SnapKit"} + + if len(deps) != len(expected) { + t.Errorf("Expected %d deps, got %d: %v", len(expected), len(deps), deps) + } + + depsMap := make(map[string]bool) + for _, d := range deps { + depsMap[d] = true + } + + for _, exp := range expected { + if !depsMap[exp] { + t.Errorf("Expected dep %q not found in %v", exp, deps) + } + } +} + +func TestParsePackageSwift(t *testing.T) { + packageSwift := `// swift-tools-version:5.5 +import PackageDescription + +let package = Package( + name: "MyPackage", + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"), + .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), + ], + targets: [ + .target(name: "MyTarget", dependencies: ["ArgumentParser", "Vapor"]), + ] +) +` + + deps := parsePackageSwift(packageSwift) + + expected := []string{"swift-argument-parser", "vapor"} + + if len(deps) != len(expected) { + t.Errorf("Expected %d deps, got %d: %v", len(expected), len(deps), deps) + } + + depsMap := make(map[string]bool) + for _, d := range deps { + depsMap[d] = true + } + + for _, exp := range expected { + if !depsMap[exp] { + t.Errorf("Expected dep %q not found in %v", exp, deps) + } + } +} + +func TestReadExternalDeps(t *testing.T) { + tmpDir := t.TempDir() + + // Create a go.mod file + gomod := `module example.com/test + +go 1.21 + +require ( + github.com/test/dep v1.0.0 +) +` + if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(gomod), 0644); err != nil { + t.Fatal(err) + } + + // Create a requirements.txt + requirements := `flask==2.0.0 +requests +` + if err := os.WriteFile(filepath.Join(tmpDir, "requirements.txt"), []byte(requirements), 0644); err != nil { + t.Fatal(err) + } + + deps := ReadExternalDeps(tmpDir) + + // Check Go deps + if goDeps, ok := deps["go"]; !ok { + t.Error("Expected go deps") + } else if len(goDeps) != 1 || goDeps[0] != "github.com/test/dep" { + t.Errorf("Unexpected go deps: %v", goDeps) + } + + // Check Python deps + if pyDeps, ok := deps["python"]; !ok { + t.Error("Expected python deps") + } else { + sort.Strings(pyDeps) + expected := []string{"flask", "requests"} + sort.Strings(expected) + if !reflect.DeepEqual(pyDeps, expected) { + t.Errorf("Expected python deps %v, got %v", expected, pyDeps) + } + } +} + +func TestReadExternalDepsIgnoresNodeModules(t *testing.T) { + tmpDir := t.TempDir() + + // Create package.json in node_modules (should be ignored) + nodeModules := filepath.Join(tmpDir, "node_modules", "some-pkg") + if err := os.MkdirAll(nodeModules, 0755); err != nil { + t.Fatal(err) + } + ignoredPackageJson := `{ + "dependencies": { + "ignored": "1.0.0" + } +}` + if err := os.WriteFile(filepath.Join(nodeModules, "package.json"), []byte(ignoredPackageJson), 0644); err != nil { + t.Fatal(err) + } + + // Create a real package.json at root (multi-line for parser compatibility) + rootPackageJson := `{ + "dependencies": { + "real-dep": "1.0.0" + } +}` + if err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(rootPackageJson), 0644); err != nil { + t.Fatal(err) + } + + deps := ReadExternalDeps(tmpDir) + + // Should only have the root package.json deps + if jsDeps, ok := deps["javascript"]; ok { + for _, d := range jsDeps { + if d == "ignored" { + t.Error("node_modules/package.json should be ignored") + } + } + found := false + for _, d := range jsDeps { + if d == "real-dep" { + found = true + break + } + } + if !found { + t.Errorf("Expected real-dep from root package.json, got: %v", jsDeps) + } + } else { + t.Errorf("Expected javascript deps, got: %v", deps) + } +} diff --git a/scanner/git_test.go b/scanner/git_test.go new file mode 100644 index 0000000..adce6a0 --- /dev/null +++ b/scanner/git_test.go @@ -0,0 +1,259 @@ +package scanner + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// setupGitRepo creates a temporary git repository for testing +func setupGitRepo(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Skipf("git not available: %v", err) + } + + // Configure git user for commits + cmd = exec.Command("git", "config", "user.email", "test@test.com") + cmd.Dir = tmpDir + cmd.Run() + + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = tmpDir + cmd.Run() + + return tmpDir +} + +func TestDiffStat(t *testing.T) { + // Test DiffStat struct + stat := DiffStat{Added: 10, Removed: 5} + if stat.Added != 10 { + t.Errorf("Expected Added=10, got %d", stat.Added) + } + if stat.Removed != 5 { + t.Errorf("Expected Removed=5, got %d", stat.Removed) + } +} + +func TestDiffInfo(t *testing.T) { + // Test DiffInfo struct initialization + info := &DiffInfo{ + Changed: make(map[string]bool), + Untracked: make(map[string]bool), + Stats: make(map[string]DiffStat), + } + + info.Changed["test.go"] = true + info.Untracked["new.go"] = true + info.Stats["test.go"] = DiffStat{Added: 5, Removed: 2} + + if !info.Changed["test.go"] { + t.Error("Expected test.go in Changed") + } + if !info.Untracked["new.go"] { + t.Error("Expected new.go in Untracked") + } + if info.Stats["test.go"].Added != 5 { + t.Error("Expected Added=5 for test.go") + } +} + +func TestGitDiffFilesInRepo(t *testing.T) { + tmpDir := setupGitRepo(t) + + // Create initial file and commit + initialFile := filepath.Join(tmpDir, "initial.go") + if err := os.WriteFile(initialFile, []byte("package main\n"), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command("git", "add", ".") + cmd.Dir = tmpDir + cmd.Run() + + cmd = exec.Command("git", "commit", "-m", "initial") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Skipf("Could not create initial commit: %v", err) + } + + // Create main branch reference + cmd = exec.Command("git", "branch", "-M", "main") + cmd.Dir = tmpDir + cmd.Run() + + // Modify the file + if err := os.WriteFile(initialFile, []byte("package main\n\nfunc main() {}\n"), 0644); err != nil { + t.Fatal(err) + } + + // Create a new untracked file + newFile := filepath.Join(tmpDir, "new.go") + if err := os.WriteFile(newFile, []byte("package main\n"), 0644); err != nil { + t.Fatal(err) + } + + // Get diff info + info, err := GitDiffInfo(tmpDir, "main") + if err != nil { + t.Fatalf("GitDiffInfo failed: %v", err) + } + + // Should have both files as changed + if !info.Changed["initial.go"] { + t.Error("Expected initial.go in changed files") + } + if !info.Changed["new.go"] { + t.Error("Expected new.go in changed files") + } + + // Only new.go should be untracked + if info.Untracked["initial.go"] { + t.Error("initial.go should not be untracked") + } + if !info.Untracked["new.go"] { + t.Error("new.go should be untracked") + } + + // Check stats for modified file + if stat, ok := info.Stats["initial.go"]; ok { + if stat.Added == 0 { + t.Error("Expected some added lines for initial.go") + } + } +} + +func TestGitDiffFilesHelper(t *testing.T) { + tmpDir := setupGitRepo(t) + + // Create and commit a file + if err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command("git", "add", ".") + cmd.Dir = tmpDir + cmd.Run() + + cmd = exec.Command("git", "commit", "-m", "test") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Skip("Could not create commit") + } + + cmd = exec.Command("git", "branch", "-M", "main") + cmd.Dir = tmpDir + cmd.Run() + + // Use GitDiffFiles helper + changed, err := GitDiffFiles(tmpDir, "main") + if err != nil { + t.Fatalf("GitDiffFiles failed: %v", err) + } + + // No changes since last commit + if len(changed) != 0 { + t.Errorf("Expected no changes, got %v", changed) + } + + // Modify file + if err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte("modified"), 0644); err != nil { + t.Fatal(err) + } + + changed, err = GitDiffFiles(tmpDir, "main") + if err != nil { + t.Fatalf("GitDiffFiles failed: %v", err) + } + + if !changed["test.go"] { + t.Error("Expected test.go in changed files") + } +} + +func TestGitDiffStatsHelper(t *testing.T) { + tmpDir := setupGitRepo(t) + + // Create and commit a file + if err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte("line1\nline2\nline3\n"), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command("git", "add", ".") + cmd.Dir = tmpDir + cmd.Run() + + cmd = exec.Command("git", "commit", "-m", "initial") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Skip("Could not create commit") + } + + cmd = exec.Command("git", "branch", "-M", "main") + cmd.Dir = tmpDir + cmd.Run() + + // Modify file (add and remove lines) + if err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte("line1\nmodified\nline3\nnew line\n"), 0644); err != nil { + t.Fatal(err) + } + + stats, err := GitDiffStats(tmpDir, "main") + if err != nil { + t.Fatalf("GitDiffStats failed: %v", err) + } + + if stat, ok := stats["test.go"]; ok { + if stat.Added == 0 && stat.Removed == 0 { + t.Error("Expected some diff stats for test.go") + } + } else { + t.Error("Expected test.go in stats") + } +} + +func TestGitDiffInfoInvalidRef(t *testing.T) { + tmpDir := setupGitRepo(t) + + // Try to diff against nonexistent ref + _, err := GitDiffInfo(tmpDir, "nonexistent-branch-xyz") + if err == nil { + // It's okay if this returns empty results instead of error + // but we're checking it doesn't panic + } +} + +func TestImpactInfo(t *testing.T) { + // Test ImpactInfo struct + impact := ImpactInfo{ + File: "util.go", + UsedBy: 5, + } + + if impact.File != "util.go" { + t.Errorf("Expected File=util.go, got %s", impact.File) + } + if impact.UsedBy != 5 { + t.Errorf("Expected UsedBy=5, got %d", impact.UsedBy) + } +} + +func TestAnalyzeImpactEmpty(t *testing.T) { + // Test with empty changed files + impacts := AnalyzeImpact(".", nil) + if impacts != nil { + t.Errorf("Expected nil impacts for empty input, got %v", impacts) + } + + impacts = AnalyzeImpact(".", []FileInfo{}) + if impacts != nil { + t.Errorf("Expected nil impacts for empty slice, got %v", impacts) + } +} diff --git a/scanner/walker_test.go b/scanner/walker_test.go new file mode 100644 index 0000000..7a3c470 --- /dev/null +++ b/scanner/walker_test.go @@ -0,0 +1,241 @@ +package scanner + +import ( + "os" + "path/filepath" + "testing" +) + +func TestIgnoredDirs(t *testing.T) { + // Verify common directories are in the ignored list + expectedIgnored := []string{ + ".git", "node_modules", "vendor", "__pycache__", + ".venv", "dist", "target", ".gradle", + } + + for _, dir := range expectedIgnored { + if !IgnoredDirs[dir] { + t.Errorf("Expected %q to be in IgnoredDirs", dir) + } + } +} + +func TestLoadGitignore(t *testing.T) { + // Test loading from current directory (should have .gitignore) + gitignore := LoadGitignore("..") + // This test is conditional - may or may not have .gitignore + // Just ensure it doesn't panic + + // Test loading from nonexistent directory + gitignore = LoadGitignore("/nonexistent/path") + if gitignore != nil { + t.Error("Expected nil gitignore for nonexistent path") + } +} + +func TestScanFiles(t *testing.T) { + // Create a temporary directory structure for testing + tmpDir := t.TempDir() + + // Create test files + files := []string{ + "main.go", + "README.md", + "src/app.go", + "src/util/helper.go", + } + + for _, f := range files { + path := filepath.Join(tmpDir, f) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + if err := os.WriteFile(path, []byte("test content"), 0644); err != nil { + t.Fatalf("Failed to create file: %v", err) + } + } + + // Scan the directory + result, err := ScanFiles(tmpDir, nil) + if err != nil { + t.Fatalf("ScanFiles failed: %v", err) + } + + if len(result) != len(files) { + t.Errorf("Expected %d files, got %d", len(files), len(result)) + } + + // Verify file info + for _, fi := range result { + if fi.Size == 0 { + t.Errorf("File %s has zero size", fi.Path) + } + } +} + +func TestScanFilesIgnoresDirs(t *testing.T) { + tmpDir := t.TempDir() + + // Create files including one in an ignored directory + if err := os.MkdirAll(filepath.Join(tmpDir, "node_modules"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "node_modules", "package.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil { + t.Fatal(err) + } + + result, err := ScanFiles(tmpDir, nil) + if err != nil { + t.Fatalf("ScanFiles failed: %v", err) + } + + // Should only have main.go, not node_modules/package.json + if len(result) != 1 { + t.Errorf("Expected 1 file (main.go), got %d files", len(result)) + } + + if len(result) > 0 && result[0].Path != "main.go" { + t.Errorf("Expected main.go, got %s", result[0].Path) + } +} + +func TestScanFilesExtensions(t *testing.T) { + tmpDir := t.TempDir() + + testFiles := map[string]string{ + "main.go": ".go", + "app.py": ".py", + "index.js": ".js", + "style.css": ".css", + "Makefile": "", + "README": "", + "config.json": ".json", + } + + for name := range testFiles { + if err := os.WriteFile(filepath.Join(tmpDir, name), []byte("content"), 0644); err != nil { + t.Fatal(err) + } + } + + result, err := ScanFiles(tmpDir, nil) + if err != nil { + t.Fatal(err) + } + + extMap := make(map[string]string) + for _, f := range result { + extMap[filepath.Base(f.Path)] = f.Ext + } + + for name, expectedExt := range testFiles { + if got := extMap[name]; got != expectedExt { + t.Errorf("File %s: expected ext %q, got %q", name, expectedExt, got) + } + } +} + +func TestFilterToChanged(t *testing.T) { + files := []FileInfo{ + {Path: "main.go", Size: 100}, + {Path: "util.go", Size: 200}, + {Path: "test.go", Size: 300}, + } + + changed := map[string]bool{ + "main.go": true, + "test.go": true, + } + + result := FilterToChanged(files, changed) + + if len(result) != 2 { + t.Errorf("Expected 2 changed files, got %d", len(result)) + } + + // Verify correct files are included + resultPaths := make(map[string]bool) + for _, f := range result { + resultPaths[f.Path] = true + } + + if !resultPaths["main.go"] { + t.Error("Expected main.go in results") + } + if !resultPaths["test.go"] { + t.Error("Expected test.go in results") + } + if resultPaths["util.go"] { + t.Error("Did not expect util.go in results") + } +} + +func TestFilterToChangedWithInfo(t *testing.T) { + files := []FileInfo{ + {Path: "main.go", Size: 100}, + {Path: "new_file.go", Size: 50}, + {Path: "unchanged.go", Size: 200}, + } + + info := &DiffInfo{ + Changed: map[string]bool{ + "main.go": true, + "new_file.go": true, + }, + Untracked: map[string]bool{ + "new_file.go": true, + }, + Stats: map[string]DiffStat{ + "main.go": {Added: 10, Removed: 5}, + "new_file.go": {Added: 50, Removed: 0}, + }, + } + + result := FilterToChangedWithInfo(files, info) + + if len(result) != 2 { + t.Errorf("Expected 2 files, got %d", len(result)) + } + + // Check annotations + for _, f := range result { + switch f.Path { + case "main.go": + if f.IsNew { + t.Error("main.go should not be marked as new") + } + if f.Added != 10 || f.Removed != 5 { + t.Errorf("main.go: expected +10 -5, got +%d -%d", f.Added, f.Removed) + } + case "new_file.go": + if !f.IsNew { + t.Error("new_file.go should be marked as new") + } + if f.Added != 50 { + t.Errorf("new_file.go: expected +50, got +%d", f.Added) + } + } + } +} + +func TestFilterAnalysisToChanged(t *testing.T) { + analyses := []FileAnalysis{ + {Path: "main.go", Language: "go", Functions: []string{"main"}}, + {Path: "util.go", Language: "go", Functions: []string{"helper"}}, + } + + changed := map[string]bool{"main.go": true} + + result := FilterAnalysisToChanged(analyses, changed) + + if len(result) != 1 { + t.Errorf("Expected 1 analysis, got %d", len(result)) + } + + if result[0].Path != "main.go" { + t.Errorf("Expected main.go, got %s", result[0].Path) + } +}