Skip to main content

devclean/
scanner.rs

1use std::collections::{BTreeMap, HashSet};
2use std::env;
3use std::ffi::OsStr;
4use std::path::{Component, Path, PathBuf};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use anyhow::{Context, Result};
8use directories::BaseDirs;
9use walkdir::WalkDir;
10
11use crate::model::{
12    Candidate, Category, Confidence, CustomRule, LearningObservation, ReviewCandidate, ReviewRule,
13    ScanReport,
14};
15use crate::policy::{ExcludePolicy, GitTrackedGuard};
16
17mod global_caches;
18mod measurement;
19
20pub use global_caches::{expensive_global_cache_paths, global_cache_paths};
21use measurement::{ArtifactStats, measure_pending_artifacts};
22
23/// Scanner configuration.
24#[derive(Debug, Clone)]
25pub struct ScanOptions {
26    /// Filesystem roots to inspect.
27    pub roots: Vec<PathBuf>,
28    /// Artifact categories to include.
29    pub categories: HashSet<Category>,
30    /// Include known global package and tool caches.
31    pub include_global_caches: bool,
32    /// Include large runtime and model caches.
33    pub include_expensive_caches: bool,
34    /// Maximum traversal depth below each root.
35    pub max_depth: usize,
36    /// User-provided path globs to skip.
37    pub excludes: Vec<String>,
38    /// Only include artifacts whose latest modification is at least this old.
39    pub older_than: Option<Duration>,
40    /// Only include artifacts at least this large.
41    pub min_size: u64,
42    /// Refuse candidates containing Git-tracked files.
43    pub protect_git_tracked: bool,
44    /// Observe large cache-like directories without making them cleanable.
45    pub learning_mode: LearningMode,
46    /// Exact review paths approved through a scanner-recognized learning rule.
47    pub approved_review_paths: HashSet<PathBuf>,
48    /// Exact-name, direct-marker rules loaded from configuration.
49    pub custom_rules: Vec<CustomRule>,
50}
51
52/// Controls whether the scanner emits local growth observations.
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
54pub enum LearningMode {
55    /// Return cleanup candidates only.
56    #[default]
57    Disabled,
58    /// Measure active known artifacts and review-only cache-like directories.
59    Enabled,
60}
61
62impl LearningMode {
63    const fn is_enabled(self) -> bool {
64        matches!(self, Self::Enabled)
65    }
66}
67
68#[derive(Debug, Default)]
69struct ScanAccumulator {
70    candidates: Vec<Candidate>,
71    review_candidates: Vec<ReviewCandidate>,
72    learning_observations: Vec<LearningObservation>,
73    warnings: Vec<String>,
74    git_guard: GitTrackedGuard,
75}
76
77#[derive(Debug)]
78enum PendingKind {
79    Candidate(CandidateClassification),
80    Review { reason: &'static str },
81}
82
83#[derive(Debug)]
84struct CandidateClassification {
85    category: Category,
86    reason: String,
87    include_cleanup_candidate: bool,
88    custom_rule: Option<CustomRule>,
89}
90
91/// A classified directory awaiting size and modification-time measurement.
92#[derive(Debug)]
93struct PendingArtifact {
94    path: PathBuf,
95    kind: PendingKind,
96}
97
98/// Returns useful development roots without scanning the entire home directory.
99#[must_use]
100pub fn default_roots() -> Vec<PathBuf> {
101    let mut roots = Vec::new();
102    if let Some(base) = BaseDirs::new() {
103        for relative in ["Dev", "Projects"] {
104            let candidate = base.home_dir().join(relative);
105            if candidate.is_dir() {
106                roots.push(candidate);
107            }
108        }
109    }
110    if roots.is_empty() {
111        if let Ok(current) = env::current_dir() {
112            roots.push(current);
113        }
114    }
115    roots
116}
117
118/// Scans configured roots without modifying the filesystem.
119///
120/// # Errors
121///
122/// Returns an error when no valid root exists, a root cannot be normalized, or an exclude glob
123/// is invalid.
124pub fn scan(options: &ScanOptions) -> Result<ScanReport> {
125    let mut output = ScanAccumulator::default();
126    let normalized_roots = normalize_roots(&options.roots, &mut output.warnings)?;
127
128    let mut effective_options = options.clone();
129    effective_options.approved_review_paths = options
130        .approved_review_paths
131        .iter()
132        .filter_map(|path| path.canonicalize().ok())
133        .collect();
134
135    let excludes = ExcludePolicy::new(&options.excludes)?;
136    let mut pending = Vec::new();
137    for root in &normalized_roots {
138        scan_root(
139            root,
140            &normalized_roots,
141            &effective_options,
142            &excludes,
143            &mut pending,
144            &mut output.warnings,
145        );
146    }
147
148    if effective_options.include_global_caches
149        && effective_options
150            .categories
151            .contains(&Category::GlobalCache)
152    {
153        collect_global_cache_candidates(
154            Category::GlobalCache,
155            global_cache_paths,
156            &normalized_roots,
157            &excludes,
158            &mut pending,
159            &mut output.warnings,
160        );
161    }
162    if effective_options.include_expensive_caches
163        && effective_options
164            .categories
165            .contains(&Category::ExpensiveGlobalCache)
166    {
167        collect_global_cache_candidates(
168            Category::ExpensiveGlobalCache,
169            expensive_global_cache_paths,
170            &normalized_roots,
171            &excludes,
172            &mut pending,
173            &mut output.warnings,
174        );
175    }
176
177    process_pending_artifacts(pending, &effective_options, &mut output);
178
179    output.candidates.sort_by(|left, right| {
180        right
181            .bytes
182            .cmp(&left.bytes)
183            .then_with(|| left.path.cmp(&right.path))
184    });
185    let total_bytes = output
186        .candidates
187        .iter()
188        .map(|candidate| candidate.bytes)
189        .fold(0_u64, u64::saturating_add);
190    output.review_candidates.sort_by(|left, right| {
191        right
192            .bytes
193            .cmp(&left.bytes)
194            .then_with(|| left.path.cmp(&right.path))
195    });
196    let review_total_bytes = output
197        .review_candidates
198        .iter()
199        .map(|candidate| candidate.bytes)
200        .fold(0_u64, u64::saturating_add);
201    output.learning_observations.sort_by(|left, right| {
202        right
203            .bytes
204            .cmp(&left.bytes)
205            .then_with(|| left.path.cmp(&right.path))
206    });
207    let observed_total_bytes = output
208        .learning_observations
209        .iter()
210        .map(|observation| observation.bytes)
211        .fold(0_u64, u64::saturating_add);
212
213    Ok(ScanReport {
214        roots: normalized_roots,
215        candidates: output.candidates,
216        review_candidates: output.review_candidates,
217        learning_observations: output.learning_observations,
218        warnings: output.warnings,
219        total_bytes,
220        review_total_bytes,
221        observed_total_bytes,
222        protect_git_tracked: effective_options.protect_git_tracked,
223    })
224}
225
226fn process_pending_artifacts(
227    pending: Vec<PendingArtifact>,
228    options: &ScanOptions,
229    output: &mut ScanAccumulator,
230) {
231    let stats = measure_pending_artifacts(&pending);
232    for (artifact, stats) in pending.into_iter().zip(stats) {
233        match artifact.kind {
234            PendingKind::Candidate(classification) => {
235                add_candidate(&artifact.path, stats, classification, options, output);
236            }
237            PendingKind::Review { reason } => {
238                add_review_candidate(&artifact.path, stats, reason, options, output);
239            }
240        }
241    }
242}
243
244fn normalize_roots(roots: &[PathBuf], warnings: &mut Vec<String>) -> Result<Vec<PathBuf>> {
245    let mut normalized = Vec::new();
246    for root in roots {
247        if !root.is_dir() {
248            warnings.push(format!("skipped missing root: {}", root.display()));
249            continue;
250        }
251        normalized.push(
252            root.canonicalize()
253                .with_context(|| format!("failed to normalize root {}", root.display()))?,
254        );
255    }
256    if normalized.is_empty() {
257        anyhow::bail!("no valid scan roots were found");
258    }
259    Ok(normalized)
260}
261
262fn scan_root(
263    root: &Path,
264    roots: &[PathBuf],
265    options: &ScanOptions,
266    excludes: &ExcludePolicy,
267    pending: &mut Vec<PendingArtifact>,
268    warnings: &mut Vec<String>,
269) {
270    let mut walker = WalkDir::new(root)
271        .max_depth(options.max_depth)
272        .follow_links(false)
273        .same_file_system(true)
274        .into_iter();
275
276    while let Some(entry_result) = walker.next() {
277        let entry = match entry_result {
278            Ok(entry) => entry,
279            Err(error) => {
280                warnings.push(error.to_string());
281                continue;
282            }
283        };
284        if !entry.file_type().is_dir() {
285            continue;
286        }
287        let path = entry.path();
288        if path != root && (should_prune(path) || excludes.matches(path, roots)) {
289            walker.skip_current_dir();
290            continue;
291        }
292
293        let classified = classify(path)
294            .map(|(category, reason)| (category, reason.to_owned(), None))
295            .or_else(|| {
296                options.custom_rules.iter().find_map(|rule| {
297                    matches_custom_rule(path, rule)
298                        .then(|| (rule.category, rule.reason.clone(), Some(rule.clone())))
299                })
300            });
301        let Some((category, reason, custom_rule)) = classified else {
302            if options.learning_mode.is_enabled() || options.approved_review_paths.contains(path) {
303                if let Some(reason) = classify_review_candidate(path) {
304                    walker.skip_current_dir();
305                    pending.push(PendingArtifact {
306                        path: path.to_path_buf(),
307                        kind: PendingKind::Review { reason },
308                    });
309                }
310            }
311            continue;
312        };
313        walker.skip_current_dir();
314        pending.push(PendingArtifact {
315            path: path.to_path_buf(),
316            kind: PendingKind::Candidate(CandidateClassification {
317                category,
318                reason,
319                include_cleanup_candidate: options.categories.contains(&category),
320                custom_rule,
321            }),
322        });
323    }
324}
325
326fn add_candidate(
327    path: &Path,
328    stats: Result<ArtifactStats>,
329    classification: CandidateClassification,
330    options: &ScanOptions,
331    output: &mut ScanAccumulator,
332) {
333    let CandidateClassification {
334        category,
335        reason,
336        include_cleanup_candidate,
337        custom_rule,
338    } = classification;
339    let stats = match stats {
340        Ok(stats) => stats,
341        Err(error) => {
342            output
343                .warnings
344                .push(format!("{}: {error:#}", path.display()));
345            return;
346        }
347    };
348    if options.protect_git_tracked {
349        match output.git_guard.contains_tracked_files(path) {
350            Ok(true) => {
351                if options.learning_mode.is_enabled() {
352                    output.learning_observations.push(LearningObservation {
353                        path: path.to_path_buf(),
354                        category: Some(category),
355                        bytes: stats.bytes,
356                        reason: "contains Git-tracked files".to_owned(),
357                        modified_at_unix: stats.modified.and_then(system_time_to_unix),
358                        confidence: Confidence::Protected,
359                    });
360                }
361                output.warnings.push(format!(
362                    "protected Git-tracked candidate: {}",
363                    path.display()
364                ));
365                return;
366            }
367            Ok(false) => {}
368            Err(error) => {
369                output.warnings.push(format!(
370                    "skipped {} because tracked-file guard failed: {error:#}",
371                    path.display()
372                ));
373                return;
374            }
375        }
376    }
377    if options.learning_mode.is_enabled() {
378        output.learning_observations.push(LearningObservation {
379            path: path.to_path_buf(),
380            category: Some(category),
381            bytes: stats.bytes,
382            reason: reason.clone(),
383            modified_at_unix: stats.modified.and_then(system_time_to_unix),
384            confidence: Confidence::Safe,
385        });
386    }
387    if !include_cleanup_candidate
388        || stats.bytes < options.min_size
389        || !is_old_enough(stats.modified, options.older_than)
390    {
391        return;
392    }
393    output.candidates.push(Candidate {
394        category,
395        path: path.to_path_buf(),
396        bytes: stats.bytes,
397        reason,
398        modified_at_unix: stats.modified.and_then(system_time_to_unix),
399        confidence: Confidence::Safe,
400        approved_rule: None,
401        custom_rule,
402    });
403}
404
405fn add_review_candidate(
406    path: &Path,
407    stats: Result<ArtifactStats>,
408    reason: &'static str,
409    options: &ScanOptions,
410    output: &mut ScanAccumulator,
411) {
412    let stats = match stats {
413        Ok(stats) => stats,
414        Err(error) => {
415            output
416                .warnings
417                .push(format!("{}: {error:#}", path.display()));
418            return;
419        }
420    };
421    if stats.bytes < options.min_size {
422        return;
423    }
424    if options.protect_git_tracked {
425        match output.git_guard.contains_tracked_files(path) {
426            Ok(true) => {
427                output.learning_observations.push(LearningObservation {
428                    path: path.to_path_buf(),
429                    category: None,
430                    bytes: stats.bytes,
431                    reason: "contains Git-tracked files".to_owned(),
432                    modified_at_unix: stats.modified.and_then(system_time_to_unix),
433                    confidence: Confidence::Protected,
434                });
435                output.warnings.push(format!(
436                    "protected Git-tracked learning candidate: {}",
437                    path.display()
438                ));
439                return;
440            }
441            Ok(false) => {}
442            Err(error) => {
443                output.warnings.push(format!(
444                    "skipped learning candidate {} because tracked-file guard failed: {error:#}",
445                    path.display()
446                ));
447                return;
448            }
449        }
450    }
451    let suggestion = suggested_review_rule(path);
452    let approved_rule = suggestion
453        .as_ref()
454        .map(|(rule, _)| *rule)
455        .filter(|_| options.approved_review_paths.contains(path));
456    let approved = approved_rule.is_some();
457    let category = approved.then_some(Category::BuildOutput);
458    output.learning_observations.push(LearningObservation {
459        path: path.to_path_buf(),
460        category,
461        bytes: stats.bytes,
462        reason: reason.to_owned(),
463        modified_at_unix: stats.modified.and_then(system_time_to_unix),
464        confidence: if approved {
465            Confidence::Safe
466        } else {
467            Confidence::Review
468        },
469    });
470    if let Some(rule) = approved_rule {
471        if stats.bytes >= options.min_size && is_old_enough(stats.modified, options.older_than) {
472            let Some((category, approved_reason)) = classify_approved_review_candidate(path, rule)
473            else {
474                return;
475            };
476            output.candidates.push(Candidate {
477                category,
478                path: path.to_path_buf(),
479                bytes: stats.bytes,
480                reason: approved_reason.to_owned(),
481                modified_at_unix: stats.modified.and_then(system_time_to_unix),
482                confidence: Confidence::Safe,
483                approved_rule: Some(rule),
484                custom_rule: None,
485            });
486            return;
487        }
488    }
489    output.review_candidates.push(ReviewCandidate {
490        path: path.to_path_buf(),
491        bytes: stats.bytes,
492        reason: reason.to_owned(),
493        modified_at_unix: stats.modified.and_then(system_time_to_unix),
494        confidence: Confidence::Review,
495        suggested_rule: suggestion.as_ref().map(|(rule, _)| *rule),
496        project_root: suggestion.map(|(_, root)| root),
497        approved,
498    });
499}
500
501fn suggested_review_rule(path: &Path) -> Option<(ReviewRule, PathBuf)> {
502    let parent = path.parent()?;
503    ReviewRule::all().into_iter().find_map(|rule| {
504        classify_approved_review_candidate(path, rule).map(|_| (rule, parent.to_path_buf()))
505    })
506}
507
508/// Revalidates a user-approved review path against its scanner-owned rule.
509#[must_use]
510pub fn classify_approved_review_candidate(
511    path: &Path,
512    rule: ReviewRule,
513) -> Option<(Category, &'static str)> {
514    if is_protected(path) {
515        return None;
516    }
517    let parent = path.parent()?;
518    match rule {
519        ReviewRule::SwiftPackageBuild => (path.file_name() == Some(OsStr::new(".build"))
520            && parent.join("Package.swift").is_file())
521        .then_some((
522            Category::BuildOutput,
523            "user-approved Swift Package build directory",
524        )),
525        ReviewRule::XcodeDerivedData => (path.file_name() == Some(OsStr::new("DerivedData"))
526            && has_xcode_container(parent))
527        .then_some((
528            Category::BuildOutput,
529            "user-approved Xcode DerivedData directory",
530        )),
531        ReviewRule::GradleBuild => (path.file_name() == Some(OsStr::new(".gradle"))
532            && !is_home_directory(parent)
533            && [
534                "build.gradle",
535                "build.gradle.kts",
536                "settings.gradle",
537                "settings.gradle.kts",
538            ]
539            .iter()
540            .any(|marker| parent.join(marker).is_file()))
541        .then_some((
542            Category::BuildOutput,
543            "user-approved Gradle project cache directory",
544        )),
545        ReviewRule::CocoaPods => (path.file_name() == Some(OsStr::new("Pods"))
546            && parent.join("Podfile").is_file()
547            && parent.join("Podfile.lock").is_file())
548        .then_some((
549            Category::BuildOutput,
550            "user-approved CocoaPods dependency directory",
551        )),
552    }
553}
554
555/// Classifies a directory using conservative, filesystem-verifiable markers.
556#[must_use]
557pub fn classify(path: &Path) -> Option<(Category, &'static str)> {
558    if is_protected(path) {
559        return None;
560    }
561    let name = path.file_name()?;
562
563    if name == OsStr::new("target") && looks_like_rust_target(path) {
564        return Some((
565            Category::RustTarget,
566            "Cargo target directory with Rust build markers",
567        ));
568    }
569    if matches_name(name, &["node_modules", "frontend_node_modules"]) {
570        return Some((Category::NodeModules, "installed JavaScript dependencies"));
571    }
572    if matches_name(
573        name,
574        &[
575            ".next",
576            ".svelte-kit",
577            ".turbo",
578            ".vite",
579            ".parcel-cache",
580            ".nuxt",
581            ".output",
582            ".dart_tool",
583            ".npm-cache",
584        ],
585    ) {
586        return Some((Category::FrameworkCache, "framework-generated cache"));
587    }
588    if matches_name(name, &[".zig-cache", "zig-cache", "zig-out"])
589        && path
590            .parent()
591            .is_some_and(|parent| parent.join("build.zig").is_file())
592    {
593        return Some((Category::BuildOutput, "Zig compiler output"));
594    }
595    if matches_name(
596        name,
597        &[
598            "mutants.out",
599            ".pytest_cache",
600            ".mypy_cache",
601            ".ruff_cache",
602            ".nyc_output",
603        ],
604    ) {
605        return Some((Category::TestCache, "test or analysis cache"));
606    }
607    if name == OsStr::new("__pycache__") {
608        return Some((Category::PythonCache, "regenerable Python bytecode cache"));
609    }
610    if matches_name(name, &[".tox", ".nox"]) && path.parent().is_some_and(has_python_project_marker)
611    {
612        return Some((
613            Category::PythonCache,
614            "project-local Python test environment cache",
615        ));
616    }
617    if matches_name(name, &[".venv", "venv"])
618        && path.parent().is_some_and(has_python_project_marker)
619    {
620        return Some((
621            Category::PythonEnvironment,
622            "project-local Python virtual environment with dependency manifest",
623        ));
624    }
625    if name == OsStr::new("build") && looks_like_project_build(path) {
626        return Some((
627            Category::BuildOutput,
628            "build directory beneath a recognized project",
629        ));
630    }
631    None
632}
633
634/// Revalidates a config-defined rule using exact candidate names and direct sibling markers.
635#[must_use]
636pub fn matches_custom_rule(path: &Path, rule: &CustomRule) -> bool {
637    if is_protected(path) {
638        return false;
639    }
640    let Some(name) = path.file_name().and_then(OsStr::to_str) else {
641        return false;
642    };
643    let Some(parent) = path.parent() else {
644        return false;
645    };
646    rule.directory_names.iter().any(|value| value == name)
647        && rule
648            .required_markers
649            .iter()
650            .all(|marker| parent.join(marker).is_file())
651}
652
653fn classify_review_candidate(path: &Path) -> Option<&'static str> {
654    if is_protected(path) || !has_project_marker(path) {
655        return None;
656    }
657    let name = path.file_name()?;
658    if matches_name(
659        name,
660        &[
661            ".build",
662            ".cache",
663            ".gradle",
664            ".angular",
665            ".expo",
666            "DerivedData",
667            "Pods",
668            "cache",
669            "coverage",
670            "dist",
671            "generated",
672            "out",
673            "temp",
674            "tmp",
675        ],
676    ) {
677        return Some("large cache-like directory beneath a recognized project");
678    }
679    None
680}
681
682fn has_project_marker(path: &Path) -> bool {
683    path.ancestors().skip(1).take(3).any(|ancestor| {
684        [
685            "Cargo.toml",
686            "Package.swift",
687            "package.json",
688            "pyproject.toml",
689            "go.mod",
690            "pubspec.yaml",
691            "build.gradle",
692            "settings.gradle",
693            "build.gradle.kts",
694            "settings.gradle.kts",
695            "Podfile",
696        ]
697        .iter()
698        .any(|marker| ancestor.join(marker).is_file())
699            || has_xcode_container(ancestor)
700    })
701}
702
703fn has_python_project_marker(path: &Path) -> bool {
704    [
705        "pyproject.toml",
706        "requirements.txt",
707        "requirements-dev.txt",
708        "Pipfile",
709        "Pipfile.lock",
710        "poetry.lock",
711        "uv.lock",
712        "setup.py",
713        "setup.cfg",
714        "tox.ini",
715        "noxfile.py",
716    ]
717    .iter()
718    .any(|marker| path.join(marker).is_file())
719}
720
721fn has_xcode_container(path: &Path) -> bool {
722    path.read_dir()
723        .ok()
724        .into_iter()
725        .flatten()
726        .filter_map(Result::ok)
727        .any(|entry| {
728            entry.file_type().is_ok_and(|file_type| file_type.is_dir())
729                && matches!(
730                    entry.path().extension().and_then(OsStr::to_str),
731                    Some("xcodeproj" | "xcworkspace")
732                )
733        })
734}
735
736/// The global `~/.gradle` holds credentials and is never a rebuildable project cache.
737fn is_home_directory(path: &Path) -> bool {
738    BaseDirs::new().is_some_and(|base| {
739        let home = base.home_dir();
740        path == home || home.canonicalize().is_ok_and(|canonical| path == canonical)
741    })
742}
743
744fn should_prune(path: &Path) -> bool {
745    path.file_name().is_some_and(|name| {
746        matches_name(name, &[".git", ".hg", ".svn", "site-packages"])
747            || name.to_string_lossy().starts_with(".devclean-quarantine-")
748    })
749}
750
751fn looks_like_rust_target(path: &Path) -> bool {
752    path.join("CACHEDIR.TAG").is_file()
753        || path.join(".rustc_info.json").is_file()
754        || path.join("debug").is_dir()
755        || path.join("release").is_dir()
756}
757
758fn looks_like_project_build(path: &Path) -> bool {
759    let Some(parent) = path.parent() else {
760        return false;
761    };
762    if [
763        "package.json",
764        "pubspec.yaml",
765        "Cargo.toml",
766        "CMakeLists.txt",
767        "build.gradle",
768        "build.gradle.kts",
769        "settings.gradle",
770        "settings.gradle.kts",
771    ]
772    .iter()
773    .any(|marker| parent.join(marker).is_file())
774    {
775        return true;
776    }
777    parent.file_name() == Some(OsStr::new("ios"))
778        || parent
779            .read_dir()
780            .ok()
781            .into_iter()
782            .flatten()
783            .filter_map(Result::ok)
784            .any(|entry| entry.path().extension() == Some(OsStr::new("xcodeproj")))
785}
786
787fn is_protected(path: &Path) -> bool {
788    path.components().any(|component| {
789        let Component::Normal(name) = component else {
790            return false;
791        };
792        let value = name.to_string_lossy();
793        [
794            ".git",
795            ".hg",
796            ".svn",
797            "backups",
798            "backup",
799            "volumes",
800            "postgres",
801            "postgresql",
802            "mysql",
803            "mariadb",
804            "filestore",
805        ]
806        .iter()
807        .any(|protected| value.eq_ignore_ascii_case(protected))
808    })
809}
810
811fn matches_name(name: &OsStr, values: &[&str]) -> bool {
812    values.iter().any(|value| name == OsStr::new(value))
813}
814
815fn collect_global_cache_candidates(
816    category: Category,
817    paths: fn(&Path) -> Vec<PathBuf>,
818    roots: &[PathBuf],
819    excludes: &ExcludePolicy,
820    pending: &mut Vec<PendingArtifact>,
821    warnings: &mut Vec<String>,
822) {
823    let Some(base) = BaseDirs::new() else {
824        warnings.push("home directory is unavailable; global caches were skipped".to_owned());
825        return;
826    };
827    for path in paths(base.home_dir()) {
828        if path.is_dir() && !excludes.matches(&path, roots) {
829            pending.push(PendingArtifact {
830                path,
831                kind: PendingKind::Candidate(CandidateClassification {
832                    category,
833                    reason: if category == Category::ExpensiveGlobalCache {
834                        "large downloaded runtime or model cache".to_owned()
835                    } else {
836                        "downloaded development-tool cache".to_owned()
837                    },
838                    include_cleanup_candidate: true,
839                    custom_rule: None,
840                }),
841            });
842        }
843    }
844}
845
846fn is_old_enough(modified: Option<SystemTime>, minimum_age: Option<Duration>) -> bool {
847    let Some(minimum_age) = minimum_age else {
848        return true;
849    };
850    modified
851        .and_then(|timestamp| SystemTime::now().duration_since(timestamp).ok())
852        .is_some_and(|age| age >= minimum_age)
853}
854
855fn system_time_to_unix(value: SystemTime) -> Option<u64> {
856    value
857        .duration_since(UNIX_EPOCH)
858        .ok()
859        .map(|age| age.as_secs())
860}
861
862/// Groups candidate bytes by category.
863#[must_use]
864pub fn totals_by_category(report: &ScanReport) -> BTreeMap<Category, u64> {
865    let mut totals = BTreeMap::new();
866    for candidate in &report.candidates {
867        *totals.entry(candidate.category).or_default() += candidate.bytes;
868    }
869    totals
870}
871
872#[cfg(test)]
873mod tests {
874    use std::fs;
875
876    use proptest::prelude::*;
877    use tempfile::tempdir;
878
879    use super::*;
880
881    fn options(root: &Path, categories: HashSet<Category>) -> ScanOptions {
882        ScanOptions {
883            roots: vec![root.to_path_buf()],
884            categories,
885            include_global_caches: false,
886            include_expensive_caches: false,
887            max_depth: 16,
888            excludes: Vec::new(),
889            older_than: None,
890            min_size: 0,
891            protect_git_tracked: false,
892            learning_mode: LearningMode::Disabled,
893            approved_review_paths: HashSet::new(),
894            custom_rules: Vec::new(),
895        }
896    }
897
898    #[test]
899    fn classify_should_accept_rust_target_with_marker() -> Result<()> {
900        let temporary = tempdir()?;
901        let target = temporary.path().join("target");
902        fs::create_dir_all(target.join("debug"))?;
903
904        assert!(matches!(classify(&target), Some((Category::RustTarget, _))));
905        Ok(())
906    }
907
908    #[test]
909    fn classify_should_reject_unmarked_target_directory() -> Result<()> {
910        let temporary = tempdir()?;
911        let target = temporary.path().join("target");
912        fs::create_dir_all(&target)?;
913
914        assert!(classify(&target).is_none());
915        Ok(())
916    }
917
918    #[test]
919    fn classify_should_protect_backup_names_case_insensitively() -> Result<()> {
920        let temporary = tempdir()?;
921        let modules = temporary.path().join("Backups/project/node_modules");
922        fs::create_dir_all(&modules)?;
923
924        assert!(classify(&modules).is_none());
925        Ok(())
926    }
927
928    #[test]
929    fn classify_should_accept_python_virtual_environment_with_direct_manifest() -> Result<()> {
930        let temporary = tempdir()?;
931        fs::write(
932            temporary.path().join("pyproject.toml"),
933            "[project]\nname='demo'\n",
934        )?;
935        let environment = temporary.path().join(".venv");
936        fs::create_dir_all(environment.join("lib/python/site-packages"))?;
937
938        assert!(matches!(
939            classify(&environment),
940            Some((Category::PythonEnvironment, _))
941        ));
942        Ok(())
943    }
944
945    #[test]
946    fn classify_should_reject_unmarked_python_virtual_environment() -> Result<()> {
947        let temporary = tempdir()?;
948        let environment = temporary.path().join(".venv");
949        fs::create_dir_all(&environment)?;
950
951        assert!(classify(&environment).is_none());
952        Ok(())
953    }
954
955    #[test]
956    fn scan_should_find_python_bytecode_and_test_environments() -> Result<()> {
957        let temporary = tempdir()?;
958        fs::write(
959            temporary.path().join("pyproject.toml"),
960            "[project]\nname='demo'\n",
961        )?;
962        fs::create_dir_all(temporary.path().join("src/__pycache__"))?;
963        fs::create_dir_all(temporary.path().join(".tox/py311"))?;
964        let report = scan(&options(
965            temporary.path(),
966            HashSet::from([Category::PythonCache]),
967        ))?;
968
969        assert_eq!(report.candidates.len(), 2);
970        assert!(
971            report
972                .candidates
973                .iter()
974                .all(|candidate| candidate.category == Category::PythonCache)
975        );
976        Ok(())
977    }
978
979    #[test]
980    fn classify_should_accept_gradle_cmake_and_zig_build_outputs() -> Result<()> {
981        let temporary = tempdir()?;
982        let gradle = temporary.path().join("gradle");
983        let cmake = temporary.path().join("cmake");
984        let zig = temporary.path().join("zig");
985        fs::create_dir_all(gradle.join("build"))?;
986        fs::create_dir_all(cmake.join("build"))?;
987        fs::create_dir_all(zig.join("zig-out"))?;
988        fs::write(gradle.join("build.gradle.kts"), "plugins {}")?;
989        fs::write(cmake.join("CMakeLists.txt"), "project(Demo)")?;
990        fs::write(zig.join("build.zig"), "const std = @import(\"std\");")?;
991
992        assert!(matches!(
993            classify(&gradle.join("build")),
994            Some((Category::BuildOutput, _))
995        ));
996        assert!(matches!(
997            classify(&cmake.join("build")),
998            Some((Category::BuildOutput, _))
999        ));
1000        assert!(matches!(
1001            classify(&zig.join("zig-out")),
1002            Some((Category::BuildOutput, _))
1003        ));
1004        Ok(())
1005    }
1006
1007    #[test]
1008    fn custom_rule_should_require_exact_name_and_every_direct_marker() -> Result<()> {
1009        let temporary = tempdir()?;
1010        fs::write(temporary.path().join("project.lock"), "locked")?;
1011        fs::write(temporary.path().join("project.toml"), "configured")?;
1012        let generated = temporary.path().join("generated-cache");
1013        fs::create_dir_all(&generated)?;
1014        let rule = CustomRule {
1015            name: "project-generated-cache".to_owned(),
1016            category: Category::BuildOutput,
1017            directory_names: vec!["generated-cache".to_owned()],
1018            required_markers: vec!["project.toml".to_owned(), "project.lock".to_owned()],
1019            reason: "team-approved generated cache".to_owned(),
1020        };
1021        let mut scan_options = options(temporary.path(), HashSet::from([Category::BuildOutput]));
1022        scan_options.custom_rules.push(rule.clone());
1023
1024        let report = scan(&scan_options)?;
1025
1026        assert_eq!(report.candidates.len(), 1);
1027        assert_eq!(report.candidates[0].custom_rule, Some(rule.clone()));
1028        fs::remove_file(temporary.path().join("project.lock"))?;
1029        assert!(!matches_custom_rule(&generated, &rule));
1030        Ok(())
1031    }
1032
1033    #[test]
1034    fn scan_should_not_descend_into_node_modules() -> Result<()> {
1035        let temporary = tempdir()?;
1036        let modules = temporary.path().join("node_modules");
1037        fs::create_dir_all(modules.join("nested/node_modules"))?;
1038        fs::write(modules.join("package.js"), "content")?;
1039
1040        let report = scan(&options(
1041            temporary.path(),
1042            HashSet::from([Category::NodeModules]),
1043        ))?;
1044
1045        assert_eq!(report.candidates.len(), 1);
1046        Ok(())
1047    }
1048
1049    #[test]
1050    fn scan_should_apply_exclude_glob() -> Result<()> {
1051        let temporary = tempdir()?;
1052        fs::create_dir_all(temporary.path().join("vendor/node_modules"))?;
1053        let mut scan_options = options(temporary.path(), HashSet::from([Category::NodeModules]));
1054        scan_options.excludes.push("vendor/**".to_owned());
1055
1056        let report = scan(&scan_options)?;
1057
1058        assert!(report.candidates.is_empty());
1059        Ok(())
1060    }
1061
1062    #[cfg(unix)]
1063    #[test]
1064    fn scan_should_not_follow_symlink_to_node_modules() -> Result<()> {
1065        use std::os::unix::fs::symlink;
1066
1067        let temporary = tempdir()?;
1068        let outside = tempdir()?;
1069        let modules = outside.path().join("node_modules");
1070        fs::create_dir_all(&modules)?;
1071        symlink(&modules, temporary.path().join("node_modules"))?;
1072
1073        let report = scan(&options(
1074            temporary.path(),
1075            HashSet::from([Category::NodeModules]),
1076        ))?;
1077
1078        assert!(report.candidates.is_empty());
1079        Ok(())
1080    }
1081
1082    #[test]
1083    fn learning_mode_should_observe_unknown_project_cache_as_review_only() -> Result<()> {
1084        let temporary = tempdir()?;
1085        fs::write(temporary.path().join("package.json"), "{}")?;
1086        let cache = temporary.path().join("dist");
1087        fs::create_dir_all(&cache)?;
1088        fs::write(cache.join("bundle.js"), "generated")?;
1089        let mut options = options(temporary.path(), HashSet::from(Category::all()));
1090        options.learning_mode = LearningMode::Enabled;
1091
1092        let report = scan(&options)?;
1093
1094        assert!(report.candidates.is_empty());
1095        assert_eq!(report.review_candidates.len(), 1);
1096        Ok(())
1097    }
1098
1099    #[test]
1100    fn learning_mode_should_measure_active_artifact_without_making_it_cleanable() -> Result<()> {
1101        let temporary = tempdir()?;
1102        let target = temporary.path().join("target/debug");
1103        fs::create_dir_all(&target)?;
1104        fs::write(target.join("artifact"), "fresh")?;
1105        let mut options = options(temporary.path(), HashSet::from([Category::RustTarget]));
1106        options.learning_mode = LearningMode::Enabled;
1107        options.older_than = Some(Duration::from_secs(86_400));
1108
1109        let report = scan(&options)?;
1110
1111        assert!(report.candidates.is_empty());
1112        assert_eq!(report.learning_observations.len(), 1);
1113        Ok(())
1114    }
1115
1116    #[test]
1117    fn learning_mode_should_suggest_swift_package_rule_for_dot_build() -> Result<()> {
1118        let temporary = tempdir()?;
1119        fs::write(
1120            temporary.path().join("Package.swift"),
1121            "// swift-tools-version: 6.0",
1122        )?;
1123        fs::create_dir_all(temporary.path().join(".build"))?;
1124        let mut scan_options = options(temporary.path(), HashSet::from(Category::all()));
1125        scan_options.learning_mode = LearningMode::Enabled;
1126
1127        let report = scan(&scan_options)?;
1128
1129        assert_eq!(
1130            report.review_candidates[0].suggested_rule,
1131            Some(ReviewRule::SwiftPackageBuild)
1132        );
1133        Ok(())
1134    }
1135
1136    #[test]
1137    fn approved_swift_package_rule_should_promote_dot_build_to_candidate() -> Result<()> {
1138        let temporary = tempdir()?;
1139        fs::write(
1140            temporary.path().join("Package.swift"),
1141            "// swift-tools-version: 6.0",
1142        )?;
1143        let build = temporary.path().join(".build");
1144        fs::create_dir_all(&build)?;
1145        fs::write(build.join("artifact"), "generated")?;
1146        let mut scan_options = options(temporary.path(), HashSet::new());
1147        scan_options.learning_mode = LearningMode::Enabled;
1148        scan_options.approved_review_paths.insert(build);
1149
1150        let report = scan(&scan_options)?;
1151
1152        assert_eq!(
1153            report.candidates[0].approved_rule,
1154            Some(ReviewRule::SwiftPackageBuild)
1155        );
1156        Ok(())
1157    }
1158
1159    #[test]
1160    fn recent_approved_swift_package_build_should_wait_for_age_threshold() -> Result<()> {
1161        let temporary = tempdir()?;
1162        fs::write(
1163            temporary.path().join("Package.swift"),
1164            "// swift-tools-version: 6.0",
1165        )?;
1166        let build = temporary.path().join(".build");
1167        fs::create_dir_all(&build)?;
1168        let mut scan_options = options(temporary.path(), HashSet::new());
1169        scan_options.learning_mode = LearningMode::Enabled;
1170        scan_options.older_than = Some(Duration::from_secs(86_400));
1171        scan_options.approved_review_paths.insert(build);
1172
1173        let report = scan(&scan_options)?;
1174
1175        assert!(report.candidates.is_empty() && report.review_candidates[0].approved);
1176        Ok(())
1177    }
1178
1179    #[test]
1180    fn approval_should_not_promote_dot_build_without_direct_package_manifest() -> Result<()> {
1181        let temporary = tempdir()?;
1182        fs::write(
1183            temporary.path().join("Package.swift"),
1184            "// swift-tools-version: 6.0",
1185        )?;
1186        let nested = temporary.path().join("nested");
1187        let build = nested.join(".build");
1188        fs::create_dir_all(&build)?;
1189        let mut scan_options = options(temporary.path(), HashSet::new());
1190        scan_options.learning_mode = LearningMode::Enabled;
1191        scan_options.approved_review_paths.insert(build);
1192
1193        let report = scan(&scan_options)?;
1194
1195        assert!(report.candidates.is_empty() && !report.review_candidates[0].approved);
1196        Ok(())
1197    }
1198
1199    #[test]
1200    fn learning_mode_should_suggest_xcode_rule_for_derived_data() -> Result<()> {
1201        let temporary = tempdir()?;
1202        fs::create_dir_all(temporary.path().join("App.xcodeproj"))?;
1203        let derived = temporary.path().join("DerivedData");
1204        fs::create_dir_all(&derived)?;
1205        let mut scan_options = options(temporary.path(), HashSet::from(Category::all()));
1206        scan_options.learning_mode = LearningMode::Enabled;
1207
1208        let report = scan(&scan_options)?;
1209
1210        assert_eq!(
1211            report.review_candidates[0].suggested_rule,
1212            Some(ReviewRule::XcodeDerivedData)
1213        );
1214        Ok(())
1215    }
1216
1217    #[test]
1218    fn learning_mode_should_suggest_gradle_rule_for_kotlin_dsl_project() -> Result<()> {
1219        let temporary = tempdir()?;
1220        fs::write(temporary.path().join("build.gradle.kts"), "plugins {}")?;
1221        fs::create_dir_all(temporary.path().join(".gradle"))?;
1222        let mut scan_options = options(temporary.path(), HashSet::from(Category::all()));
1223        scan_options.learning_mode = LearningMode::Enabled;
1224
1225        let report = scan(&scan_options)?;
1226
1227        assert_eq!(
1228            report.review_candidates[0].suggested_rule,
1229            Some(ReviewRule::GradleBuild)
1230        );
1231        Ok(())
1232    }
1233
1234    #[test]
1235    fn learning_mode_should_suggest_cocoapods_rule_with_lockfile() -> Result<()> {
1236        let temporary = tempdir()?;
1237        fs::write(temporary.path().join("Podfile"), "platform :ios")?;
1238        fs::write(temporary.path().join("Podfile.lock"), "PODS:\n")?;
1239        fs::create_dir_all(temporary.path().join("Pods"))?;
1240        let mut scan_options = options(temporary.path(), HashSet::from(Category::all()));
1241        scan_options.learning_mode = LearningMode::Enabled;
1242
1243        let report = scan(&scan_options)?;
1244
1245        assert_eq!(
1246            report.review_candidates[0].suggested_rule,
1247            Some(ReviewRule::CocoaPods)
1248        );
1249        Ok(())
1250    }
1251
1252    #[test]
1253    fn cocoapods_rule_should_reject_pods_without_lockfile() -> Result<()> {
1254        let temporary = tempdir()?;
1255        fs::write(temporary.path().join("Podfile"), "platform :ios")?;
1256        let pods = temporary.path().join("Pods");
1257        fs::create_dir_all(&pods)?;
1258        let mut scan_options = options(temporary.path(), HashSet::from(Category::all()));
1259        scan_options.learning_mode = LearningMode::Enabled;
1260
1261        let report = scan(&scan_options)?;
1262
1263        assert_eq!(report.review_candidates.len(), 1);
1264        assert!(report.review_candidates[0].suggested_rule.is_none());
1265        assert!(
1266            classify_approved_review_candidate(&pods, ReviewRule::CocoaPods).is_none(),
1267            "a Podfile alone is not enough evidence for reproducible cleanup"
1268        );
1269        Ok(())
1270    }
1271
1272    #[test]
1273    fn approved_cocoapods_rule_should_promote_locked_pods_to_candidate() -> Result<()> {
1274        let temporary = tempdir()?;
1275        fs::write(temporary.path().join("Podfile"), "platform :ios")?;
1276        let lockfile = temporary.path().join("Podfile.lock");
1277        fs::write(&lockfile, "PODS:\n")?;
1278        let pods = temporary.path().join("Pods");
1279        fs::create_dir_all(&pods)?;
1280        fs::write(pods.join("dependency.m"), "generated")?;
1281        let mut scan_options = options(temporary.path(), HashSet::new());
1282        scan_options.learning_mode = LearningMode::Enabled;
1283        scan_options.approved_review_paths.insert(pods.clone());
1284
1285        let report = scan(&scan_options)?;
1286
1287        assert_eq!(
1288            report.candidates[0].approved_rule,
1289            Some(ReviewRule::CocoaPods)
1290        );
1291        fs::remove_file(lockfile)?;
1292        assert!(classify_approved_review_candidate(&pods, ReviewRule::CocoaPods).is_none());
1293        Ok(())
1294    }
1295
1296    #[test]
1297    fn approved_gradle_rule_should_promote_cache_to_candidate() -> Result<()> {
1298        let temporary = tempdir()?;
1299        fs::write(temporary.path().join("settings.gradle"), "rootProject")?;
1300        let cache = temporary.path().join(".gradle");
1301        fs::create_dir_all(&cache)?;
1302        fs::write(cache.join("checksums.bin"), "generated")?;
1303        let mut scan_options = options(temporary.path(), HashSet::new());
1304        scan_options.learning_mode = LearningMode::Enabled;
1305        scan_options.approved_review_paths.insert(cache);
1306
1307        let report = scan(&scan_options)?;
1308
1309        assert_eq!(
1310            report.candidates[0].approved_rule,
1311            Some(ReviewRule::GradleBuild)
1312        );
1313        Ok(())
1314    }
1315
1316    #[test]
1317    fn gradle_rule_should_reject_the_global_gradle_directory_in_home() -> Result<()> {
1318        let temporary = tempdir()?;
1319        assert!(!is_home_directory(temporary.path()));
1320        let home = BaseDirs::new().map(|base| base.home_dir().to_path_buf());
1321        if let Some(home) = home {
1322            assert!(is_home_directory(&home));
1323            assert!(
1324                classify_approved_review_candidate(&home.join(".gradle"), ReviewRule::GradleBuild)
1325                    .is_none()
1326            );
1327        }
1328        Ok(())
1329    }
1330
1331    #[test]
1332    fn derived_data_without_xcode_container_should_stay_unsuggested() -> Result<()> {
1333        let temporary = tempdir()?;
1334        fs::write(temporary.path().join("package.json"), "{}")?;
1335        fs::create_dir_all(temporary.path().join("DerivedData"))?;
1336        let mut scan_options = options(temporary.path(), HashSet::from(Category::all()));
1337        scan_options.learning_mode = LearningMode::Enabled;
1338
1339        let report = scan(&scan_options)?;
1340
1341        assert!(
1342            report.review_candidates[0].suggested_rule.is_none()
1343                && !report.review_candidates[0].approved
1344        );
1345        Ok(())
1346    }
1347
1348    proptest! {
1349        #[test]
1350        fn protected_names_are_case_insensitive(uppercase in any::<bool>()) {
1351            let name = if uppercase { "POSTGRES" } else { "postgres" };
1352            let path = PathBuf::from("root").join(name).join("node_modules");
1353            prop_assert!(is_protected(&path));
1354        }
1355    }
1356}