Skip to main content

devclean/
scanner.rs

1use std::collections::{HashMap, HashSet};
2use std::env;
3use std::ffi::OsStr;
4use std::fs;
5use std::path::{Component, Path, PathBuf};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use anyhow::{Context, Result};
9use directories::BaseDirs;
10use walkdir::WalkDir;
11
12use crate::model::{
13    Candidate, Category, Confidence, LearningObservation, ReviewCandidate, ReviewRule, ScanReport,
14};
15use crate::policy::{ExcludePolicy, contains_git_tracked_files};
16
17/// Scanner configuration.
18#[derive(Debug, Clone)]
19pub struct ScanOptions {
20    /// Filesystem roots to inspect.
21    pub roots: Vec<PathBuf>,
22    /// Artifact categories to include.
23    pub categories: HashSet<Category>,
24    /// Include known global package and tool caches.
25    pub include_global_caches: bool,
26    /// Include large runtime and model caches.
27    pub include_expensive_caches: bool,
28    /// Maximum traversal depth below each root.
29    pub max_depth: usize,
30    /// User-provided path globs to skip.
31    pub excludes: Vec<String>,
32    /// Only include artifacts whose latest modification is at least this old.
33    pub older_than: Option<Duration>,
34    /// Only include artifacts at least this large.
35    pub min_size: u64,
36    /// Refuse candidates containing Git-tracked files.
37    pub protect_git_tracked: bool,
38    /// Observe large cache-like directories without making them cleanable.
39    pub learning_mode: LearningMode,
40    /// Exact review paths approved through a scanner-recognized learning rule.
41    pub approved_review_paths: HashSet<PathBuf>,
42}
43
44/// Controls whether the scanner emits local growth observations.
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub enum LearningMode {
47    /// Return cleanup candidates only.
48    #[default]
49    Disabled,
50    /// Measure active known artifacts and review-only cache-like directories.
51    Enabled,
52}
53
54impl LearningMode {
55    const fn is_enabled(self) -> bool {
56        matches!(self, Self::Enabled)
57    }
58}
59
60#[derive(Debug, Default)]
61struct ScanAccumulator {
62    candidates: Vec<Candidate>,
63    review_candidates: Vec<ReviewCandidate>,
64    learning_observations: Vec<LearningObservation>,
65    warnings: Vec<String>,
66}
67
68/// Returns useful development roots without scanning the entire home directory.
69#[must_use]
70pub fn default_roots() -> Vec<PathBuf> {
71    let mut roots = Vec::new();
72    if let Some(base) = BaseDirs::new() {
73        for relative in ["Dev", "Projects", "Documents/Codex"] {
74            let candidate = base.home_dir().join(relative);
75            if candidate.is_dir() {
76                roots.push(candidate);
77            }
78        }
79    }
80    if roots.is_empty() {
81        if let Ok(current) = env::current_dir() {
82            roots.push(current);
83        }
84    }
85    roots
86}
87
88/// Scans configured roots without modifying the filesystem.
89///
90/// # Errors
91///
92/// Returns an error when no valid root exists, a root cannot be normalized, or an exclude glob
93/// is invalid.
94pub fn scan(options: &ScanOptions) -> Result<ScanReport> {
95    let mut output = ScanAccumulator::default();
96    let normalized_roots = normalize_roots(&options.roots, &mut output.warnings)?;
97
98    let mut effective_options = options.clone();
99    effective_options.approved_review_paths = options
100        .approved_review_paths
101        .iter()
102        .filter_map(|path| path.canonicalize().ok())
103        .collect();
104
105    let excludes = ExcludePolicy::new(&options.excludes)?;
106    for root in &normalized_roots {
107        scan_root(
108            root,
109            &normalized_roots,
110            &effective_options,
111            &excludes,
112            &mut output,
113        );
114    }
115
116    if effective_options.include_global_caches
117        && effective_options
118            .categories
119            .contains(&Category::GlobalCache)
120    {
121        add_global_cache_candidates(
122            Category::GlobalCache,
123            global_cache_paths,
124            &normalized_roots,
125            &effective_options,
126            &excludes,
127            &mut output,
128        );
129    }
130    if effective_options.include_expensive_caches
131        && effective_options
132            .categories
133            .contains(&Category::ExpensiveGlobalCache)
134    {
135        add_global_cache_candidates(
136            Category::ExpensiveGlobalCache,
137            expensive_global_cache_paths,
138            &normalized_roots,
139            &effective_options,
140            &excludes,
141            &mut output,
142        );
143    }
144
145    output.candidates.sort_by(|left, right| {
146        right
147            .bytes
148            .cmp(&left.bytes)
149            .then_with(|| left.path.cmp(&right.path))
150    });
151    let total_bytes = output
152        .candidates
153        .iter()
154        .map(|candidate| candidate.bytes)
155        .fold(0_u64, u64::saturating_add);
156    output.review_candidates.sort_by(|left, right| {
157        right
158            .bytes
159            .cmp(&left.bytes)
160            .then_with(|| left.path.cmp(&right.path))
161    });
162    let review_total_bytes = output
163        .review_candidates
164        .iter()
165        .map(|candidate| candidate.bytes)
166        .fold(0_u64, u64::saturating_add);
167    output.learning_observations.sort_by(|left, right| {
168        right
169            .bytes
170            .cmp(&left.bytes)
171            .then_with(|| left.path.cmp(&right.path))
172    });
173    let observed_total_bytes = output
174        .learning_observations
175        .iter()
176        .map(|observation| observation.bytes)
177        .fold(0_u64, u64::saturating_add);
178
179    Ok(ScanReport {
180        roots: normalized_roots,
181        candidates: output.candidates,
182        review_candidates: output.review_candidates,
183        learning_observations: output.learning_observations,
184        warnings: output.warnings,
185        total_bytes,
186        review_total_bytes,
187        observed_total_bytes,
188        protect_git_tracked: effective_options.protect_git_tracked,
189    })
190}
191
192fn normalize_roots(roots: &[PathBuf], warnings: &mut Vec<String>) -> Result<Vec<PathBuf>> {
193    let mut normalized = Vec::new();
194    for root in roots {
195        if !root.is_dir() {
196            warnings.push(format!("skipped missing root: {}", root.display()));
197            continue;
198        }
199        normalized.push(
200            root.canonicalize()
201                .with_context(|| format!("failed to normalize root {}", root.display()))?,
202        );
203    }
204    if normalized.is_empty() {
205        anyhow::bail!("no valid scan roots were found");
206    }
207    Ok(normalized)
208}
209
210fn scan_root(
211    root: &Path,
212    roots: &[PathBuf],
213    options: &ScanOptions,
214    excludes: &ExcludePolicy,
215    output: &mut ScanAccumulator,
216) {
217    let mut walker = WalkDir::new(root)
218        .max_depth(options.max_depth)
219        .follow_links(false)
220        .same_file_system(true)
221        .into_iter();
222
223    while let Some(entry_result) = walker.next() {
224        let entry = match entry_result {
225            Ok(entry) => entry,
226            Err(error) => {
227                output.warnings.push(error.to_string());
228                continue;
229            }
230        };
231        if !entry.file_type().is_dir() {
232            continue;
233        }
234        let path = entry.path();
235        if path != root && (should_prune(path) || excludes.matches(path, roots)) {
236            walker.skip_current_dir();
237            continue;
238        }
239
240        let Some((category, reason)) = classify(path) else {
241            if options.learning_mode.is_enabled() || options.approved_review_paths.contains(path) {
242                if let Some(reason) = classify_review_candidate(path) {
243                    walker.skip_current_dir();
244                    add_review_candidate(path, reason, options, output);
245                }
246            }
247            continue;
248        };
249        walker.skip_current_dir();
250        add_candidate(
251            path,
252            category,
253            reason,
254            options.categories.contains(&category),
255            options,
256            output,
257        );
258    }
259}
260
261fn add_candidate(
262    path: &Path,
263    category: Category,
264    reason: &'static str,
265    include_cleanup_candidate: bool,
266    options: &ScanOptions,
267    output: &mut ScanAccumulator,
268) {
269    let stats = match artifact_stats(path) {
270        Ok(stats) => stats,
271        Err(error) => {
272            output
273                .warnings
274                .push(format!("{}: {error:#}", path.display()));
275            return;
276        }
277    };
278    if options.protect_git_tracked {
279        match contains_git_tracked_files(path) {
280            Ok(true) => {
281                if options.learning_mode.is_enabled() {
282                    output.learning_observations.push(LearningObservation {
283                        path: path.to_path_buf(),
284                        category: Some(category),
285                        bytes: stats.bytes,
286                        reason: "contains Git-tracked files".to_owned(),
287                        modified_at_unix: stats.modified.and_then(system_time_to_unix),
288                        confidence: Confidence::Protected,
289                    });
290                }
291                output.warnings.push(format!(
292                    "protected Git-tracked candidate: {}",
293                    path.display()
294                ));
295                return;
296            }
297            Ok(false) => {}
298            Err(error) => {
299                output.warnings.push(format!(
300                    "skipped {} because tracked-file guard failed: {error:#}",
301                    path.display()
302                ));
303                return;
304            }
305        }
306    }
307    if options.learning_mode.is_enabled() {
308        output.learning_observations.push(LearningObservation {
309            path: path.to_path_buf(),
310            category: Some(category),
311            bytes: stats.bytes,
312            reason: reason.to_owned(),
313            modified_at_unix: stats.modified.and_then(system_time_to_unix),
314            confidence: Confidence::Safe,
315        });
316    }
317    if !include_cleanup_candidate
318        || stats.bytes < options.min_size
319        || !is_old_enough(stats.modified, options.older_than)
320    {
321        return;
322    }
323    output.candidates.push(Candidate {
324        category,
325        path: path.to_path_buf(),
326        bytes: stats.bytes,
327        reason: reason.to_owned(),
328        modified_at_unix: stats.modified.and_then(system_time_to_unix),
329        confidence: Confidence::Safe,
330        approved_rule: None,
331    });
332}
333
334fn add_review_candidate(
335    path: &Path,
336    reason: &'static str,
337    options: &ScanOptions,
338    output: &mut ScanAccumulator,
339) {
340    let stats = match artifact_stats(path) {
341        Ok(stats) => stats,
342        Err(error) => {
343            output
344                .warnings
345                .push(format!("{}: {error:#}", path.display()));
346            return;
347        }
348    };
349    if stats.bytes < options.min_size {
350        return;
351    }
352    if options.protect_git_tracked {
353        match contains_git_tracked_files(path) {
354            Ok(true) => {
355                output.learning_observations.push(LearningObservation {
356                    path: path.to_path_buf(),
357                    category: None,
358                    bytes: stats.bytes,
359                    reason: "contains Git-tracked files".to_owned(),
360                    modified_at_unix: stats.modified.and_then(system_time_to_unix),
361                    confidence: Confidence::Protected,
362                });
363                output.warnings.push(format!(
364                    "protected Git-tracked learning candidate: {}",
365                    path.display()
366                ));
367                return;
368            }
369            Ok(false) => {}
370            Err(error) => {
371                output.warnings.push(format!(
372                    "skipped learning candidate {} because tracked-file guard failed: {error:#}",
373                    path.display()
374                ));
375                return;
376            }
377        }
378    }
379    let suggestion = suggested_review_rule(path);
380    let approved_rule = suggestion
381        .as_ref()
382        .map(|(rule, _)| *rule)
383        .filter(|_| options.approved_review_paths.contains(path));
384    let approved = approved_rule.is_some();
385    let category = approved.then_some(Category::BuildOutput);
386    output.learning_observations.push(LearningObservation {
387        path: path.to_path_buf(),
388        category,
389        bytes: stats.bytes,
390        reason: reason.to_owned(),
391        modified_at_unix: stats.modified.and_then(system_time_to_unix),
392        confidence: if approved {
393            Confidence::Safe
394        } else {
395            Confidence::Review
396        },
397    });
398    if let Some(rule) = approved_rule {
399        if stats.bytes >= options.min_size && is_old_enough(stats.modified, options.older_than) {
400            let Some((category, approved_reason)) = classify_approved_review_candidate(path, rule)
401            else {
402                return;
403            };
404            output.candidates.push(Candidate {
405                category,
406                path: path.to_path_buf(),
407                bytes: stats.bytes,
408                reason: approved_reason.to_owned(),
409                modified_at_unix: stats.modified.and_then(system_time_to_unix),
410                confidence: Confidence::Safe,
411                approved_rule: Some(rule),
412            });
413            return;
414        }
415    }
416    output.review_candidates.push(ReviewCandidate {
417        path: path.to_path_buf(),
418        bytes: stats.bytes,
419        reason: reason.to_owned(),
420        modified_at_unix: stats.modified.and_then(system_time_to_unix),
421        confidence: Confidence::Review,
422        suggested_rule: suggestion.as_ref().map(|(rule, _)| *rule),
423        project_root: suggestion.map(|(_, root)| root),
424        approved,
425    });
426}
427
428fn suggested_review_rule(path: &Path) -> Option<(ReviewRule, PathBuf)> {
429    let parent = path.parent()?;
430    classify_approved_review_candidate(path, ReviewRule::SwiftPackageBuild)
431        .map(|_| (ReviewRule::SwiftPackageBuild, parent.to_path_buf()))
432}
433
434/// Revalidates a user-approved review path against its scanner-owned rule.
435#[must_use]
436pub fn classify_approved_review_candidate(
437    path: &Path,
438    rule: ReviewRule,
439) -> Option<(Category, &'static str)> {
440    if is_protected(path) {
441        return None;
442    }
443    match rule {
444        ReviewRule::SwiftPackageBuild => {
445            let parent = path.parent()?;
446            (path.file_name() == Some(OsStr::new(".build"))
447                && parent.join("Package.swift").is_file())
448            .then_some((
449                Category::BuildOutput,
450                "user-approved Swift Package build directory",
451            ))
452        }
453    }
454}
455
456/// Classifies a directory using conservative, filesystem-verifiable markers.
457#[must_use]
458pub fn classify(path: &Path) -> Option<(Category, &'static str)> {
459    if is_protected(path) {
460        return None;
461    }
462    let name = path.file_name()?;
463
464    if name == OsStr::new("target") && looks_like_rust_target(path) {
465        return Some((
466            Category::RustTarget,
467            "Cargo target directory with Rust build markers",
468        ));
469    }
470    if matches_name(name, &["node_modules", "frontend_node_modules"]) {
471        return Some((Category::NodeModules, "installed JavaScript dependencies"));
472    }
473    if matches_name(
474        name,
475        &[
476            ".next",
477            ".svelte-kit",
478            ".turbo",
479            ".vite",
480            ".parcel-cache",
481            ".nuxt",
482            ".output",
483            ".dart_tool",
484            ".npm-cache",
485        ],
486    ) {
487        return Some((Category::FrameworkCache, "framework-generated cache"));
488    }
489    if matches_name(
490        name,
491        &[
492            "mutants.out",
493            ".pytest_cache",
494            ".mypy_cache",
495            ".ruff_cache",
496            ".nyc_output",
497        ],
498    ) {
499        return Some((Category::TestCache, "test or analysis cache"));
500    }
501    if name == OsStr::new("build") && looks_like_project_build(path) {
502        return Some((
503            Category::BuildOutput,
504            "build directory beneath a recognized project",
505        ));
506    }
507    None
508}
509
510fn classify_review_candidate(path: &Path) -> Option<&'static str> {
511    if is_protected(path) || !has_project_marker(path) {
512        return None;
513    }
514    let name = path.file_name()?;
515    if matches_name(
516        name,
517        &[
518            ".build",
519            ".cache",
520            ".gradle",
521            ".angular",
522            ".expo",
523            "DerivedData",
524            "Pods",
525            "cache",
526            "coverage",
527            "dist",
528            "generated",
529            "out",
530            "temp",
531            "tmp",
532        ],
533    ) {
534        return Some("large cache-like directory beneath a recognized project");
535    }
536    None
537}
538
539fn has_project_marker(path: &Path) -> bool {
540    path.ancestors().skip(1).take(3).any(|ancestor| {
541        [
542            "Cargo.toml",
543            "Package.swift",
544            "package.json",
545            "pyproject.toml",
546            "go.mod",
547            "pubspec.yaml",
548            "build.gradle",
549            "settings.gradle",
550        ]
551        .iter()
552        .any(|marker| ancestor.join(marker).is_file())
553            || ancestor
554                .read_dir()
555                .ok()
556                .into_iter()
557                .flatten()
558                .filter_map(Result::ok)
559                .any(|entry| entry.path().extension() == Some(OsStr::new("xcodeproj")))
560    })
561}
562
563fn should_prune(path: &Path) -> bool {
564    path.file_name().is_some_and(|name| {
565        matches_name(name, &[".git", ".hg", ".svn", ".venv", "site-packages"])
566            || name.to_string_lossy().starts_with(".devclean-quarantine-")
567    })
568}
569
570fn looks_like_rust_target(path: &Path) -> bool {
571    path.join("CACHEDIR.TAG").is_file()
572        || path.join(".rustc_info.json").is_file()
573        || path.join("debug").is_dir()
574        || path.join("release").is_dir()
575}
576
577fn looks_like_project_build(path: &Path) -> bool {
578    let Some(parent) = path.parent() else {
579        return false;
580    };
581    if ["package.json", "pubspec.yaml", "Cargo.toml"]
582        .iter()
583        .any(|marker| parent.join(marker).is_file())
584    {
585        return true;
586    }
587    parent.file_name() == Some(OsStr::new("ios"))
588        || parent
589            .read_dir()
590            .ok()
591            .into_iter()
592            .flatten()
593            .filter_map(Result::ok)
594            .any(|entry| entry.path().extension() == Some(OsStr::new("xcodeproj")))
595}
596
597fn is_protected(path: &Path) -> bool {
598    path.components().any(|component| {
599        let Component::Normal(name) = component else {
600            return false;
601        };
602        let value = name.to_string_lossy();
603        [
604            ".git",
605            ".hg",
606            ".svn",
607            "backups",
608            "backup",
609            "volumes",
610            "postgres",
611            "postgresql",
612            "mysql",
613            "mariadb",
614            "filestore",
615        ]
616        .iter()
617        .any(|protected| value.eq_ignore_ascii_case(protected))
618    })
619}
620
621fn matches_name(name: &OsStr, values: &[&str]) -> bool {
622    values.iter().any(|value| name == OsStr::new(value))
623}
624
625fn add_global_cache_candidates(
626    category: Category,
627    paths: fn(&Path) -> Vec<PathBuf>,
628    roots: &[PathBuf],
629    options: &ScanOptions,
630    excludes: &ExcludePolicy,
631    output: &mut ScanAccumulator,
632) {
633    let Some(base) = BaseDirs::new() else {
634        output
635            .warnings
636            .push("home directory is unavailable; global caches were skipped".to_owned());
637        return;
638    };
639    for path in paths(base.home_dir()) {
640        if path.is_dir() && !excludes.matches(&path, roots) {
641            add_candidate(
642                &path,
643                category,
644                if category == Category::ExpensiveGlobalCache {
645                    "large downloaded runtime or model cache"
646                } else {
647                    "downloaded development-tool cache"
648                },
649                true,
650                options,
651                output,
652            );
653        }
654    }
655}
656
657/// Returns the exact allowlist for package and tool caches that are cheap to restore.
658#[must_use]
659pub fn global_cache_paths(home: &Path) -> Vec<PathBuf> {
660    let mut paths = [
661        ".npm/_cacache",
662        ".npm/_npx",
663        ".cargo/registry/cache",
664        ".cargo/registry/src",
665        ".cargo/registry/index",
666        ".cargo/git/db",
667        "go/pkg/mod",
668        ".cache/uv",
669        ".cache/pip",
670        ".cache/puppeteer",
671        ".cache/gem",
672        ".pub-cache/hosted",
673        ".pub-cache/hosted-hashes",
674        ".pub-cache/git",
675    ]
676    .into_iter()
677    .map(|relative| home.join(relative))
678    .collect::<Vec<_>>();
679    if cfg!(target_os = "macos") {
680        paths.extend(
681            [
682                "Library/pnpm",
683                "Library/Caches/ms-playwright",
684                "Library/Caches/node-gyp",
685            ]
686            .into_iter()
687            .map(|relative| home.join(relative)),
688        );
689    }
690    if cfg!(target_os = "windows") {
691        paths.extend(
692            [
693                "AppData/Local/npm-cache",
694                "AppData/Local/pnpm/store",
695                "AppData/Local/ms-playwright",
696                "AppData/Local/node-gyp/Cache",
697            ]
698            .into_iter()
699            .map(|relative| home.join(relative)),
700        );
701    }
702    paths
703}
704
705/// Returns the exact allowlist for caches that can be expensive to download again.
706#[must_use]
707pub fn expensive_global_cache_paths(home: &Path) -> Vec<PathBuf> {
708    [
709        ".cache/codex-runtimes",
710        ".cache/huggingface",
711        ".cache/whisper",
712    ]
713    .into_iter()
714    .map(|relative| home.join(relative))
715    .collect()
716}
717
718#[derive(Debug, Clone, Copy)]
719struct ArtifactStats {
720    bytes: u64,
721    modified: Option<SystemTime>,
722}
723
724fn artifact_stats(path: &Path) -> Result<ArtifactStats> {
725    let mut bytes = 0_u64;
726    let mut modified = None;
727    #[cfg(unix)]
728    let mut seen = HashSet::new();
729
730    for entry in WalkDir::new(path)
731        .follow_links(false)
732        .same_file_system(true)
733    {
734        let entry = entry.with_context(|| format!("failed to walk {}", path.display()))?;
735        let metadata = fs::symlink_metadata(entry.path())
736            .with_context(|| format!("failed to inspect {}", entry.path().display()))?;
737        if let Ok(timestamp) = metadata.modified() {
738            if modified.is_none_or(|current| timestamp > current) {
739                modified = Some(timestamp);
740            }
741        }
742
743        #[cfg(unix)]
744        {
745            use std::os::unix::fs::MetadataExt;
746            if !seen.insert((metadata.dev(), metadata.ino())) {
747                continue;
748            }
749            bytes = bytes.saturating_add(metadata.blocks().saturating_mul(512));
750        }
751        #[cfg(not(unix))]
752        {
753            bytes = bytes.saturating_add(metadata.len());
754        }
755    }
756    Ok(ArtifactStats { bytes, modified })
757}
758
759fn is_old_enough(modified: Option<SystemTime>, minimum_age: Option<Duration>) -> bool {
760    let Some(minimum_age) = minimum_age else {
761        return true;
762    };
763    modified
764        .and_then(|timestamp| SystemTime::now().duration_since(timestamp).ok())
765        .is_some_and(|age| age >= minimum_age)
766}
767
768fn system_time_to_unix(value: SystemTime) -> Option<u64> {
769    value
770        .duration_since(UNIX_EPOCH)
771        .ok()
772        .map(|age| age.as_secs())
773}
774
775/// Groups candidate bytes by category.
776#[must_use]
777pub fn totals_by_category(report: &ScanReport) -> HashMap<Category, u64> {
778    let mut totals = HashMap::new();
779    for candidate in &report.candidates {
780        *totals.entry(candidate.category).or_default() += candidate.bytes;
781    }
782    totals
783}
784
785#[cfg(test)]
786mod tests {
787    use std::fs;
788
789    use proptest::prelude::*;
790    use tempfile::tempdir;
791
792    use super::*;
793
794    fn options(root: &Path, categories: HashSet<Category>) -> ScanOptions {
795        ScanOptions {
796            roots: vec![root.to_path_buf()],
797            categories,
798            include_global_caches: false,
799            include_expensive_caches: false,
800            max_depth: 16,
801            excludes: Vec::new(),
802            older_than: None,
803            min_size: 0,
804            protect_git_tracked: false,
805            learning_mode: LearningMode::Disabled,
806            approved_review_paths: HashSet::new(),
807        }
808    }
809
810    #[test]
811    fn classify_should_accept_rust_target_with_marker() -> Result<()> {
812        let temporary = tempdir()?;
813        let target = temporary.path().join("target");
814        fs::create_dir_all(target.join("debug"))?;
815
816        assert!(matches!(classify(&target), Some((Category::RustTarget, _))));
817        Ok(())
818    }
819
820    #[test]
821    fn classify_should_reject_unmarked_target_directory() -> Result<()> {
822        let temporary = tempdir()?;
823        let target = temporary.path().join("target");
824        fs::create_dir_all(&target)?;
825
826        assert!(classify(&target).is_none());
827        Ok(())
828    }
829
830    #[test]
831    fn classify_should_protect_backup_names_case_insensitively() -> Result<()> {
832        let temporary = tempdir()?;
833        let modules = temporary.path().join("Backups/project/node_modules");
834        fs::create_dir_all(&modules)?;
835
836        assert!(classify(&modules).is_none());
837        Ok(())
838    }
839
840    #[test]
841    fn scan_should_not_descend_into_node_modules() -> Result<()> {
842        let temporary = tempdir()?;
843        let modules = temporary.path().join("node_modules");
844        fs::create_dir_all(modules.join("nested/node_modules"))?;
845        fs::write(modules.join("package.js"), "content")?;
846
847        let report = scan(&options(
848            temporary.path(),
849            HashSet::from([Category::NodeModules]),
850        ))?;
851
852        assert_eq!(report.candidates.len(), 1);
853        Ok(())
854    }
855
856    #[test]
857    fn scan_should_apply_exclude_glob() -> Result<()> {
858        let temporary = tempdir()?;
859        fs::create_dir_all(temporary.path().join("vendor/node_modules"))?;
860        let mut scan_options = options(temporary.path(), HashSet::from([Category::NodeModules]));
861        scan_options.excludes.push("vendor/**".to_owned());
862
863        let report = scan(&scan_options)?;
864
865        assert!(report.candidates.is_empty());
866        Ok(())
867    }
868
869    #[cfg(unix)]
870    #[test]
871    fn scan_should_not_follow_symlink_to_node_modules() -> Result<()> {
872        use std::os::unix::fs::symlink;
873
874        let temporary = tempdir()?;
875        let outside = tempdir()?;
876        let modules = outside.path().join("node_modules");
877        fs::create_dir_all(&modules)?;
878        symlink(&modules, temporary.path().join("node_modules"))?;
879
880        let report = scan(&options(
881            temporary.path(),
882            HashSet::from([Category::NodeModules]),
883        ))?;
884
885        assert!(report.candidates.is_empty());
886        Ok(())
887    }
888
889    #[test]
890    fn learning_mode_should_observe_unknown_project_cache_as_review_only() -> Result<()> {
891        let temporary = tempdir()?;
892        fs::write(temporary.path().join("package.json"), "{}")?;
893        let cache = temporary.path().join("dist");
894        fs::create_dir_all(&cache)?;
895        fs::write(cache.join("bundle.js"), "generated")?;
896        let mut options = options(temporary.path(), HashSet::from(Category::all()));
897        options.learning_mode = LearningMode::Enabled;
898
899        let report = scan(&options)?;
900
901        assert!(report.candidates.is_empty());
902        assert_eq!(report.review_candidates.len(), 1);
903        Ok(())
904    }
905
906    #[test]
907    fn learning_mode_should_measure_active_artifact_without_making_it_cleanable() -> Result<()> {
908        let temporary = tempdir()?;
909        let target = temporary.path().join("target/debug");
910        fs::create_dir_all(&target)?;
911        fs::write(target.join("artifact"), "fresh")?;
912        let mut options = options(temporary.path(), HashSet::from([Category::RustTarget]));
913        options.learning_mode = LearningMode::Enabled;
914        options.older_than = Some(Duration::from_secs(86_400));
915
916        let report = scan(&options)?;
917
918        assert!(report.candidates.is_empty());
919        assert_eq!(report.learning_observations.len(), 1);
920        Ok(())
921    }
922
923    #[test]
924    fn learning_mode_should_suggest_swift_package_rule_for_dot_build() -> Result<()> {
925        let temporary = tempdir()?;
926        fs::write(
927            temporary.path().join("Package.swift"),
928            "// swift-tools-version: 6.0",
929        )?;
930        fs::create_dir_all(temporary.path().join(".build"))?;
931        let mut scan_options = options(temporary.path(), HashSet::from(Category::all()));
932        scan_options.learning_mode = LearningMode::Enabled;
933
934        let report = scan(&scan_options)?;
935
936        assert_eq!(
937            report.review_candidates[0].suggested_rule,
938            Some(ReviewRule::SwiftPackageBuild)
939        );
940        Ok(())
941    }
942
943    #[test]
944    fn approved_swift_package_rule_should_promote_dot_build_to_candidate() -> Result<()> {
945        let temporary = tempdir()?;
946        fs::write(
947            temporary.path().join("Package.swift"),
948            "// swift-tools-version: 6.0",
949        )?;
950        let build = temporary.path().join(".build");
951        fs::create_dir_all(&build)?;
952        fs::write(build.join("artifact"), "generated")?;
953        let mut scan_options = options(temporary.path(), HashSet::new());
954        scan_options.learning_mode = LearningMode::Enabled;
955        scan_options.approved_review_paths.insert(build);
956
957        let report = scan(&scan_options)?;
958
959        assert_eq!(
960            report.candidates[0].approved_rule,
961            Some(ReviewRule::SwiftPackageBuild)
962        );
963        Ok(())
964    }
965
966    #[test]
967    fn recent_approved_swift_package_build_should_wait_for_age_threshold() -> Result<()> {
968        let temporary = tempdir()?;
969        fs::write(
970            temporary.path().join("Package.swift"),
971            "// swift-tools-version: 6.0",
972        )?;
973        let build = temporary.path().join(".build");
974        fs::create_dir_all(&build)?;
975        let mut scan_options = options(temporary.path(), HashSet::new());
976        scan_options.learning_mode = LearningMode::Enabled;
977        scan_options.older_than = Some(Duration::from_secs(86_400));
978        scan_options.approved_review_paths.insert(build);
979
980        let report = scan(&scan_options)?;
981
982        assert!(report.candidates.is_empty() && report.review_candidates[0].approved);
983        Ok(())
984    }
985
986    #[test]
987    fn approval_should_not_promote_dot_build_without_direct_package_manifest() -> Result<()> {
988        let temporary = tempdir()?;
989        fs::write(
990            temporary.path().join("Package.swift"),
991            "// swift-tools-version: 6.0",
992        )?;
993        let nested = temporary.path().join("nested");
994        let build = nested.join(".build");
995        fs::create_dir_all(&build)?;
996        let mut scan_options = options(temporary.path(), HashSet::new());
997        scan_options.learning_mode = LearningMode::Enabled;
998        scan_options.approved_review_paths.insert(build);
999
1000        let report = scan(&scan_options)?;
1001
1002        assert!(report.candidates.is_empty() && !report.review_candidates[0].approved);
1003        Ok(())
1004    }
1005
1006    proptest! {
1007        #[test]
1008        fn protected_names_are_case_insensitive(uppercase in any::<bool>()) {
1009            let name = if uppercase { "POSTGRES" } else { "postgres" };
1010            let path = PathBuf::from("root").join(name).join("node_modules");
1011            prop_assert!(is_protected(&path));
1012        }
1013    }
1014}