diff --git a/dev-cache/README.md b/dev-cache/README.md index 7f3b31f..d6fa8f9 100644 --- a/dev-cache/README.md +++ b/dev-cache/README.md @@ -79,6 +79,10 @@ This creates a config file at `~/.config/dev-cache/config.yaml`. | `--scan PATH` | Directory to scan (overrides config default) | | `--depth N` | Max scan depth (overrides config default, 0 = use config) | | `--languages LIST` | Comma-separated list of languages to scan (e.g., `node,python,go`) | +| `--exclude LIST` | Comma-separated paths/patterns to exclude | +| `--include-only LIST` | Comma-separated paths/patterns to include only | +| `--min-age DURATION` | Only include caches older than duration (e.g., `30d`, `48h`) | +| `--max-age DURATION` | Only include caches newer than duration (e.g., `7d`, `24h`) | | `--clean` | Delete found cache directories | | `--yes` | Skip confirmation prompt for cleanup | | `--json` | Output results as JSON | @@ -92,6 +96,10 @@ version: 1 options: defaultScanPath: ~/src maxDepth: 1 # How many levels deep to scan + excludePaths: [] + includeOnly: [] + minAge: "" + maxAge: "" languages: - name: node diff --git a/dev-cache/main.go b/dev-cache/main.go index 4fb7b18..d3d3493 100644 --- a/dev-cache/main.go +++ b/dev-cache/main.go @@ -35,6 +35,10 @@ var ( flagScan = flag.String("scan", "", "Directory to scan (overrides config default)") flagDepth = flag.Int("depth", 0, "Max scan depth (0 = use config default, overrides config)") flagLangs = flag.String("languages", "", "Comma-separated list of languages to scan") + flagExclude = flag.String("exclude", "", "Comma-separated path patterns to exclude from scan") + flagInclude = flag.String("include-only", "", "Comma-separated path patterns to include only") + flagMinAge = flag.String("min-age", "", "Only include caches older than this (e.g. 30d, 48h)") + flagMaxAge = flag.String("max-age", "", "Only include caches newer than this (e.g. 7d, 24h)") ) // ----- Config types ----- @@ -49,6 +53,10 @@ type Options struct { DefaultScanPath string `yaml:"defaultScanPath"` MaxDepth int `yaml:"maxDepth"` DetectLanguage bool `yaml:"detectLanguage"` // If true, detect language per directory and search only relevant patterns + ExcludePaths []string `yaml:"excludePaths"` + IncludeOnly []string `yaml:"includeOnly"` + MinAge string `yaml:"minAge"` + MaxAge string `yaml:"maxAge"` } type Language struct { @@ -85,6 +93,14 @@ type Report struct { Warnings []string `json:"warnings"` } +type ScanFilters struct { + ExcludePaths []string + IncludeOnly []string + MinAge time.Duration + MaxAge time.Duration + Now time.Time +} + // ----- Utilities ----- func defaultConfigPath() string { @@ -100,6 +116,104 @@ func ensureDir(p string) error { return os.MkdirAll(filepath.Dir(p), 0o755) } func home() string { h, _ := os.UserHomeDir(); return h } func expand(p string) string { return os.ExpandEnv(strings.ReplaceAll(p, "~", home())) } +func splitCSV(v string) []string { + if strings.TrimSpace(v) == "" { + return nil + } + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + s := strings.TrimSpace(p) + if s != "" { + out = append(out, s) + } + } + return out +} + +func expandAndCleanPatterns(patterns []string) []string { + out := make([]string, 0, len(patterns)) + for _, p := range patterns { + if strings.TrimSpace(p) == "" { + continue + } + out = append(out, filepath.Clean(expand(strings.TrimSpace(p)))) + } + return out +} + +func matchesPathPattern(path, pattern string) bool { + path = filepath.Clean(path) + pattern = filepath.Clean(pattern) + + if strings.ContainsAny(pattern, "*?[") { + ok, err := filepath.Match(pattern, path) + return err == nil && ok + } + + if path == pattern { + return true + } + return strings.HasPrefix(path, pattern+string(filepath.Separator)) +} + +func matchesAnyPattern(path string, patterns []string) bool { + for _, p := range patterns { + if matchesPathPattern(path, p) { + return true + } + } + return false +} + +func parseAgeDuration(s string) (time.Duration, error) { + if strings.TrimSpace(s) == "" { + return 0, nil + } + s = strings.TrimSpace(strings.ToLower(s)) + unit := s[len(s)-1] + num := strings.TrimSpace(s[:len(s)-1]) + if num == "" { + return 0, fmt.Errorf("invalid duration: %q", s) + } + var mult time.Duration + switch unit { + case 'm': + mult = time.Minute + case 'h': + mult = time.Hour + case 'd': + mult = 24 * time.Hour + case 'w': + mult = 7 * 24 * time.Hour + default: + return 0, fmt.Errorf("unsupported duration unit in %q (use m/h/d/w)", s) + } + var n int64 + if _, err := fmt.Sscanf(num, "%d", &n); err != nil || n < 0 { + return 0, fmt.Errorf("invalid duration value: %q", s) + } + return time.Duration(n) * mult, nil +} + +func passesAgeFilter(f Finding, filters ScanFilters) bool { + if !isCacheDirectory(f) || f.ModMax.IsZero() { + return true + } + now := filters.Now + if now.IsZero() { + now = time.Now() + } + age := now.Sub(f.ModMax) + if filters.MinAge > 0 && age < filters.MinAge { + return false + } + if filters.MaxAge > 0 && age > filters.MaxAge { + return false + } + return true +} + func checkVersionFlag() bool { for _, arg := range os.Args[1:] { if arg == "-version" || arg == "--version" { @@ -201,6 +315,10 @@ func writeStarterConfig(path string, force bool) error { DefaultScanPath: "~/src", MaxDepth: 1, DetectLanguage: true, + ExcludePaths: []string{}, + IncludeOnly: []string{}, + MinAge: "", + MaxAge: "", }, Languages: []Language{ {Name: "node", Enabled: true, Priority: 10, Patterns: []string{"node_modules", ".npm", ".yarn", ".pnpm-store"}, Signatures: []string{"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"}}, @@ -278,6 +396,41 @@ func main() { maxDepth = *flagDepth } + minAgeStr := cfg.Options.MinAge + if *flagMinAge != "" { + minAgeStr = *flagMinAge + } + maxAgeStr := cfg.Options.MaxAge + if *flagMaxAge != "" { + maxAgeStr = *flagMaxAge + } + minAge, err := parseAgeDuration(minAgeStr) + if err != nil { + fmt.Println("invalid --min-age:", err) + os.Exit(1) + } + maxAge, err := parseAgeDuration(maxAgeStr) + if err != nil { + fmt.Println("invalid --max-age:", err) + os.Exit(1) + } + + excludePatterns := cfg.Options.ExcludePaths + if *flagExclude != "" { + excludePatterns = splitCSV(*flagExclude) + } + includePatterns := cfg.Options.IncludeOnly + if *flagInclude != "" { + includePatterns = splitCSV(*flagInclude) + } + filters := ScanFilters{ + ExcludePaths: expandAndCleanPatterns(excludePatterns), + IncludeOnly: expandAndCleanPatterns(includePatterns), + MinAge: minAge, + MaxAge: maxAge, + Now: time.Now(), + } + // Filter languages selectedLangs := map[string]bool{} if *flagLangs != "" { @@ -343,7 +496,7 @@ func main() { if cfg.Options.DetectLanguage { fmt.Printf("Language detection enabled - scanning with language-specific patterns\n") } - findings := scanDirectory(scanPath, maxDepth, allPatterns, patternToLang, cfg.Options.DetectLanguage, langSignatures, langPriorities, langToPatterns) + findings := scanDirectory(scanPath, maxDepth, allPatterns, patternToLang, cfg.Options.DetectLanguage, langSignatures, langPriorities, langToPatterns, filters) rep.Findings = findings var total int64 @@ -424,7 +577,7 @@ func main() { // Re-scan to verify fmt.Println("Re-scanning after cleanup...") - afterFindings := scanDirectory(scanPath, maxDepth, allPatterns, patternToLang, cfg.Options.DetectLanguage, langSignatures, langPriorities, langToPatterns) + afterFindings := scanDirectory(scanPath, maxDepth, allPatterns, patternToLang, cfg.Options.DetectLanguage, langSignatures, langPriorities, langToPatterns, filters) afterTotal := totalCacheBytes(afterFindings) freed := bytesFreed(beforeTotal, afterTotal) fmt.Printf("\nDeleted %d directories", deletedCount) @@ -542,7 +695,7 @@ func detectLanguage(dirPath string, langSignatures map[string][]string, langPrio } // scanDirectory walks through the directory tree up to maxDepth and matches directory names against patterns -func scanDirectory(root string, maxDepth int, patterns []string, patternToLang map[string]string, detectLang bool, langSignatures map[string][]string, langPriorities map[string]int, langToPatterns map[string][]string) []Finding { +func scanDirectory(root string, maxDepth int, patterns []string, patternToLang map[string]string, detectLang bool, langSignatures map[string][]string, langPriorities map[string]int, langToPatterns map[string][]string, filters ScanFilters) []Finding { var findings []Finding // Directories that should not have language detection performed @@ -568,6 +721,8 @@ func scanDirectory(root string, maxDepth int, patterns []string, patternToLang m findingsByPath := make(map[string]bool) // Track excluded directories (will be reported with empty language) excludedDirs := make(map[string]bool) + // Track whether depth-0 roots are included/excluded by path filters. + allowedDepth0 := make(map[string]bool) if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { @@ -607,10 +762,33 @@ func scanDirectory(root string, maxDepth int, patterns []string, patternToLang m return filepath.SkipDir } - // Determine which patterns to check for this directory - patternsToCheck := patterns - var detectedLang string - var projectRoot string + parts := strings.Split(relPath, string(filepath.Separator)) + projectRootPath := cleanPath + if len(parts) > 0 && parts[0] != "." && parts[0] != "" { + projectRootPath = filepath.Join(root, parts[0]) + } + + // Apply include/exclude path filters at project-root granularity. + if depth == 0 { + allowed := true + if len(filters.IncludeOnly) > 0 { + allowed = matchesAnyPattern(projectRootPath, filters.IncludeOnly) + } + if allowed && len(filters.ExcludePaths) > 0 && matchesAnyPattern(projectRootPath, filters.ExcludePaths) { + allowed = false + } + allowedDepth0[projectRootPath] = allowed + if !allowed { + return filepath.SkipDir + } + } else if ok, seen := allowedDepth0[projectRootPath]; seen && !ok { + return filepath.SkipDir + } + + // Determine which patterns to check for this directory + patternsToCheck := patterns + var detectedLang string + var projectRoot string // First, check if this directory matches a cache pattern // Cache directories should not be treated as project roots @@ -646,7 +824,7 @@ func scanDirectory(root string, maxDepth int, patterns []string, patternToLang m } else { // We're deeper in the tree - walk up to find the project root // Find the project root (first directory at depth 0 from root) - parts := strings.Split(relPath, string(filepath.Separator)) + parts := strings.Split(relPath, string(filepath.Separator)) if len(parts) > 0 { projectRootAbs, err := filepath.Abs(filepath.Join(root, parts[0])) if err == nil { @@ -783,14 +961,17 @@ func scanDirectory(root string, maxDepth int, patterns []string, patternToLang m } else { f.Language = patternToLang[pattern] } - // Set project root to where language was detected, or parent if no language detection - if detectLang && projectRoot != "" { - f.ProjectRoot = projectRoot - } else { - // If no language detection, use parent directory as project root - f.ProjectRoot = filepath.Dir(cleanPath) - } - findings = append(findings, f) + // Set project root to where language was detected, or parent if no language detection + if detectLang && projectRoot != "" { + f.ProjectRoot = projectRoot + } else { + // If no language detection, use parent directory as project root + f.ProjectRoot = filepath.Dir(cleanPath) + } + if !passesAgeFilter(f, filters) { + continue + } + findings = append(findings, f) // Track this path in the map for O(1) lookups findingsByPath[cleanPath] = true diff --git a/dev-cache/main_test.go b/dev-cache/main_test.go index 5c04c1c..9288fd6 100644 --- a/dev-cache/main_test.go +++ b/dev-cache/main_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func TestCheckVersionFlag(t *testing.T) { @@ -173,13 +174,13 @@ func TestScanDirectory(t *testing.T) { } // Test with maxDepth 1 - findings := scanDirectory(root, 1, patterns, patternToLang, false, nil, nil, nil) + findings := scanDirectory(root, 1, patterns, patternToLang, false, nil, nil, nil, ScanFilters{}) if len(findings) != 2 { t.Fatalf("expected 2 findings with maxDepth=1, got %d", len(findings)) } // Test with maxDepth 2 - findings2 := scanDirectory(root, 2, patterns, patternToLang, false, nil, nil, nil) + findings2 := scanDirectory(root, 2, patterns, patternToLang, false, nil, nil, nil, ScanFilters{}) if len(findings2) != 3 { t.Fatalf("expected 3 findings with maxDepth=2, got %d", len(findings2)) } @@ -265,7 +266,7 @@ func TestScanDirectoryMapLookup(t *testing.T) { // 2. proj1 - "no language found" report (no signature, no cache) // 3. proj2 - node language report (has signature) // 4. proj2/node_modules - cache directory finding (depth 1) - findings := scanDirectory(root, 1, patterns, patternToLang, true, langSignatures, langPriorities, langToPatterns) + findings := scanDirectory(root, 1, patterns, patternToLang, true, langSignatures, langPriorities, langToPatterns, ScanFilters{}) // Count findings by type var cacheFindings int @@ -615,6 +616,92 @@ func TestHumanLarge(t *testing.T) { } } +func TestParseAgeDuration(t *testing.T) { + tests := []struct { + in string + want time.Duration + wantErr bool + }{ + {"30d", 30 * 24 * time.Hour, false}, + {"2w", 14 * 24 * time.Hour, false}, + {"48h", 48 * time.Hour, false}, + {"90m", 90 * time.Minute, false}, + {"", 0, false}, + {"-1d", 0, true}, + {"7x", 0, true}, + } + for _, tt := range tests { + got, err := parseAgeDuration(tt.in) + if tt.wantErr && err == nil { + t.Fatalf("parseAgeDuration(%q) expected error", tt.in) + } + if !tt.wantErr { + if err != nil { + t.Fatalf("parseAgeDuration(%q) unexpected error: %v", tt.in, err) + } + if got != tt.want { + t.Fatalf("parseAgeDuration(%q) = %v, want %v", tt.in, got, tt.want) + } + } + } +} + +func TestScanDirectoryPathFilters(t *testing.T) { + root := t.TempDir() + includeProj := filepath.Join(root, "include-me") + excludeProj := filepath.Join(root, "exclude-me") + if err := os.MkdirAll(filepath.Join(includeProj, "node_modules"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(excludeProj, "node_modules"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(includeProj, "node_modules", "a.txt"), []byte("ok"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(excludeProj, "node_modules", "b.txt"), []byte("skip"), 0o644); err != nil { + t.Fatal(err) + } + + patterns := []string{"node_modules"} + patternToLang := map[string]string{"node_modules": "node"} + filters := ScanFilters{ + IncludeOnly: []string{includeProj}, + ExcludePaths: []string{excludeProj}, + } + findings := scanDirectory(root, 2, patterns, patternToLang, false, nil, nil, nil, filters) + if len(findings) != 1 { + t.Fatalf("expected 1 finding, got %d", len(findings)) + } + if !strings.Contains(findings[0].Path, "include-me") { + t.Fatalf("unexpected finding path: %s", findings[0].Path) + } +} + +func TestPassesAgeFilter(t *testing.T) { + now := time.Now() + base := Finding{ + Path: "/tmp/x", + Pattern: "node_modules", + SizeBytes: 10, + ModMax: now.Add(-48 * time.Hour), + } + filters := ScanFilters{Now: now, MinAge: 24 * time.Hour, MaxAge: 72 * time.Hour} + if !passesAgeFilter(base, filters) { + t.Fatal("expected finding to pass age filters") + } + tooNew := base + tooNew.ModMax = now.Add(-2 * time.Hour) + if passesAgeFilter(tooNew, filters) { + t.Fatal("expected too-new finding to fail min-age filter") + } + tooOld := base + tooOld.ModMax = now.Add(-10 * 24 * time.Hour) + if passesAgeFilter(tooOld, filters) { + t.Fatal("expected too-old finding to fail max-age filter") + } +} + func TestScanDirectoryWithExcludedDir(t *testing.T) { // Test that .git directories are excluded from language detection root := t.TempDir() @@ -638,7 +725,7 @@ func TestScanDirectoryWithExcludedDir(t *testing.T) { langPriorities := map[string]int{"node": 10} langToPatterns := map[string][]string{"node": {"node_modules"}} - findings := scanDirectory(root, 2, patterns, patternToLang, true, langSignatures, langPriorities, langToPatterns) + findings := scanDirectory(root, 2, patterns, patternToLang, true, langSignatures, langPriorities, langToPatterns, ScanFilters{}) if len(findings) < 1 { t.Fatalf("expected at least 1 finding, got %d", len(findings)) } @@ -698,7 +785,7 @@ func TestScanDirectoryWalkError(t *testing.T) { patterns := []string{"node_modules"} patternToLang := map[string]string{"node_modules": "node"} - findings := scanDirectory(root, 2, patterns, patternToLang, false, nil, nil, nil) + findings := scanDirectory(root, 2, patterns, patternToLang, false, nil, nil, nil, ScanFilters{}) // Should not panic; may or may not find things depending on walk behavior _ = findings }