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::{Candidate, Category, ScanReport};
13use crate::policy::{ExcludePolicy, contains_git_tracked_files};
14
15/// Scanner configuration.
16#[derive(Debug, Clone)]
17pub struct ScanOptions {
18    /// Filesystem roots to inspect.
19    pub roots: Vec<PathBuf>,
20    /// Artifact categories to include.
21    pub categories: HashSet<Category>,
22    /// Include known global package and tool caches.
23    pub include_global_caches: bool,
24    /// Include large runtime and model caches.
25    pub include_expensive_caches: bool,
26    /// Maximum traversal depth below each root.
27    pub max_depth: usize,
28    /// User-provided path globs to skip.
29    pub excludes: Vec<String>,
30    /// Only include artifacts whose latest modification is at least this old.
31    pub older_than: Option<Duration>,
32    /// Only include artifacts at least this large.
33    pub min_size: u64,
34    /// Refuse candidates containing Git-tracked files.
35    pub protect_git_tracked: bool,
36}
37
38/// Returns useful development roots without scanning the entire home directory.
39#[must_use]
40pub fn default_roots() -> Vec<PathBuf> {
41    let mut roots = Vec::new();
42    if let Some(base) = BaseDirs::new() {
43        for relative in ["Dev", "Projects", "Documents/Codex"] {
44            let candidate = base.home_dir().join(relative);
45            if candidate.is_dir() {
46                roots.push(candidate);
47            }
48        }
49    }
50    if roots.is_empty() {
51        if let Ok(current) = env::current_dir() {
52            roots.push(current);
53        }
54    }
55    roots
56}
57
58/// Scans configured roots without modifying the filesystem.
59///
60/// # Errors
61///
62/// Returns an error when no valid root exists, a root cannot be normalized, or an exclude glob
63/// is invalid.
64pub fn scan(options: &ScanOptions) -> Result<ScanReport> {
65    let mut candidates = Vec::new();
66    let mut warnings = Vec::new();
67    let mut normalized_roots = Vec::new();
68
69    for root in &options.roots {
70        if !root.is_dir() {
71            warnings.push(format!("skipped missing root: {}", root.display()));
72            continue;
73        }
74        let normalized = root
75            .canonicalize()
76            .with_context(|| format!("failed to normalize root {}", root.display()))?;
77        normalized_roots.push(normalized);
78    }
79    if normalized_roots.is_empty() {
80        anyhow::bail!("no valid scan roots were found");
81    }
82
83    let excludes = ExcludePolicy::new(&options.excludes)?;
84    for root in &normalized_roots {
85        scan_root(
86            root,
87            &normalized_roots,
88            options,
89            &excludes,
90            &mut candidates,
91            &mut warnings,
92        );
93    }
94
95    if options.include_global_caches && options.categories.contains(&Category::GlobalCache) {
96        candidates.extend(global_cache_candidates(
97            Category::GlobalCache,
98            global_cache_paths,
99            &normalized_roots,
100            options,
101            &excludes,
102            &mut warnings,
103        ));
104    }
105    if options.include_expensive_caches
106        && options.categories.contains(&Category::ExpensiveGlobalCache)
107    {
108        candidates.extend(global_cache_candidates(
109            Category::ExpensiveGlobalCache,
110            expensive_global_cache_paths,
111            &normalized_roots,
112            options,
113            &excludes,
114            &mut warnings,
115        ));
116    }
117
118    candidates.sort_by(|left, right| {
119        right
120            .bytes
121            .cmp(&left.bytes)
122            .then_with(|| left.path.cmp(&right.path))
123    });
124    let total_bytes = candidates.iter().map(|candidate| candidate.bytes).sum();
125
126    Ok(ScanReport {
127        roots: normalized_roots,
128        candidates,
129        warnings,
130        total_bytes,
131        protect_git_tracked: options.protect_git_tracked,
132    })
133}
134
135fn scan_root(
136    root: &Path,
137    roots: &[PathBuf],
138    options: &ScanOptions,
139    excludes: &ExcludePolicy,
140    candidates: &mut Vec<Candidate>,
141    warnings: &mut Vec<String>,
142) {
143    let mut walker = WalkDir::new(root)
144        .max_depth(options.max_depth)
145        .follow_links(false)
146        .same_file_system(true)
147        .into_iter();
148
149    while let Some(entry_result) = walker.next() {
150        let entry = match entry_result {
151            Ok(entry) => entry,
152            Err(error) => {
153                warnings.push(error.to_string());
154                continue;
155            }
156        };
157        if !entry.file_type().is_dir() {
158            continue;
159        }
160        let path = entry.path();
161        if path != root && (should_prune(path) || excludes.matches(path, roots)) {
162            walker.skip_current_dir();
163            continue;
164        }
165
166        let Some((category, reason)) = classify(path) else {
167            continue;
168        };
169        walker.skip_current_dir();
170        if !options.categories.contains(&category) {
171            continue;
172        }
173        add_candidate(path, category, reason, options, candidates, warnings);
174    }
175}
176
177fn add_candidate(
178    path: &Path,
179    category: Category,
180    reason: &'static str,
181    options: &ScanOptions,
182    candidates: &mut Vec<Candidate>,
183    warnings: &mut Vec<String>,
184) {
185    let stats = match artifact_stats(path) {
186        Ok(stats) => stats,
187        Err(error) => {
188            warnings.push(format!("{}: {error:#}", path.display()));
189            return;
190        }
191    };
192    if stats.bytes < options.min_size || !is_old_enough(stats.modified, options.older_than) {
193        return;
194    }
195    if options.protect_git_tracked {
196        match contains_git_tracked_files(path) {
197            Ok(true) => {
198                warnings.push(format!(
199                    "protected Git-tracked candidate: {}",
200                    path.display()
201                ));
202                return;
203            }
204            Ok(false) => {}
205            Err(error) => {
206                warnings.push(format!(
207                    "skipped {} because tracked-file guard failed: {error:#}",
208                    path.display()
209                ));
210                return;
211            }
212        }
213    }
214    candidates.push(Candidate {
215        category,
216        path: path.to_path_buf(),
217        bytes: stats.bytes,
218        reason: reason.to_owned(),
219        modified_at_unix: stats.modified.and_then(system_time_to_unix),
220    });
221}
222
223/// Classifies a directory using conservative, filesystem-verifiable markers.
224#[must_use]
225pub fn classify(path: &Path) -> Option<(Category, &'static str)> {
226    if is_protected(path) {
227        return None;
228    }
229    let name = path.file_name()?;
230
231    if name == OsStr::new("target") && looks_like_rust_target(path) {
232        return Some((
233            Category::RustTarget,
234            "Cargo target directory with Rust build markers",
235        ));
236    }
237    if matches_name(name, &["node_modules", "frontend_node_modules"]) {
238        return Some((Category::NodeModules, "installed JavaScript dependencies"));
239    }
240    if matches_name(
241        name,
242        &[
243            ".next",
244            ".svelte-kit",
245            ".turbo",
246            ".vite",
247            ".parcel-cache",
248            ".nuxt",
249            ".output",
250            ".dart_tool",
251            ".npm-cache",
252        ],
253    ) {
254        return Some((Category::FrameworkCache, "framework-generated cache"));
255    }
256    if matches_name(
257        name,
258        &[
259            "mutants.out",
260            ".pytest_cache",
261            ".mypy_cache",
262            ".ruff_cache",
263            ".nyc_output",
264        ],
265    ) {
266        return Some((Category::TestCache, "test or analysis cache"));
267    }
268    if name == OsStr::new("build") && looks_like_project_build(path) {
269        return Some((
270            Category::BuildOutput,
271            "build directory beneath a recognized project",
272        ));
273    }
274    None
275}
276
277fn should_prune(path: &Path) -> bool {
278    path.file_name()
279        .is_some_and(|name| matches_name(name, &[".git", ".hg", ".svn", ".venv", "site-packages"]))
280}
281
282fn looks_like_rust_target(path: &Path) -> bool {
283    path.join("CACHEDIR.TAG").is_file()
284        || path.join(".rustc_info.json").is_file()
285        || path.join("debug").is_dir()
286        || path.join("release").is_dir()
287}
288
289fn looks_like_project_build(path: &Path) -> bool {
290    let Some(parent) = path.parent() else {
291        return false;
292    };
293    if ["package.json", "pubspec.yaml", "Cargo.toml"]
294        .iter()
295        .any(|marker| parent.join(marker).is_file())
296    {
297        return true;
298    }
299    parent.file_name() == Some(OsStr::new("ios"))
300        || parent
301            .read_dir()
302            .ok()
303            .into_iter()
304            .flatten()
305            .filter_map(Result::ok)
306            .any(|entry| entry.path().extension() == Some(OsStr::new("xcodeproj")))
307}
308
309fn is_protected(path: &Path) -> bool {
310    path.components().any(|component| {
311        let Component::Normal(name) = component else {
312            return false;
313        };
314        let value = name.to_string_lossy();
315        [
316            ".git",
317            ".hg",
318            ".svn",
319            "backups",
320            "backup",
321            "volumes",
322            "postgres",
323            "postgresql",
324            "mysql",
325            "mariadb",
326            "filestore",
327        ]
328        .iter()
329        .any(|protected| value.eq_ignore_ascii_case(protected))
330    })
331}
332
333fn matches_name(name: &OsStr, values: &[&str]) -> bool {
334    values.iter().any(|value| name == OsStr::new(value))
335}
336
337fn global_cache_candidates(
338    category: Category,
339    paths: fn(&Path) -> Vec<PathBuf>,
340    roots: &[PathBuf],
341    options: &ScanOptions,
342    excludes: &ExcludePolicy,
343    warnings: &mut Vec<String>,
344) -> Vec<Candidate> {
345    let Some(base) = BaseDirs::new() else {
346        warnings.push("home directory is unavailable; global caches were skipped".to_owned());
347        return Vec::new();
348    };
349    let mut candidates = Vec::new();
350    for path in paths(base.home_dir()) {
351        if path.is_dir() && !excludes.matches(&path, roots) {
352            add_candidate(
353                &path,
354                category,
355                if category == Category::ExpensiveGlobalCache {
356                    "large downloaded runtime or model cache"
357                } else {
358                    "downloaded development-tool cache"
359                },
360                options,
361                &mut candidates,
362                warnings,
363            );
364        }
365    }
366    candidates
367}
368
369/// Returns the exact allowlist for package and tool caches that are cheap to restore.
370#[must_use]
371pub fn global_cache_paths(home: &Path) -> Vec<PathBuf> {
372    let mut paths = [
373        ".npm/_cacache",
374        ".npm/_npx",
375        ".cargo/registry/cache",
376        ".cargo/registry/src",
377        ".cargo/registry/index",
378        ".cargo/git/db",
379        "go/pkg/mod",
380        ".cache/uv",
381        ".cache/pip",
382        ".cache/puppeteer",
383        ".cache/gem",
384        ".pub-cache/hosted",
385        ".pub-cache/hosted-hashes",
386        ".pub-cache/git",
387    ]
388    .into_iter()
389    .map(|relative| home.join(relative))
390    .collect::<Vec<_>>();
391    if cfg!(target_os = "macos") {
392        paths.extend(
393            [
394                "Library/pnpm",
395                "Library/Caches/ms-playwright",
396                "Library/Caches/node-gyp",
397            ]
398            .into_iter()
399            .map(|relative| home.join(relative)),
400        );
401    }
402    if cfg!(target_os = "windows") {
403        paths.extend(
404            [
405                "AppData/Local/npm-cache",
406                "AppData/Local/pnpm/store",
407                "AppData/Local/ms-playwright",
408                "AppData/Local/node-gyp/Cache",
409            ]
410            .into_iter()
411            .map(|relative| home.join(relative)),
412        );
413    }
414    paths
415}
416
417/// Returns the exact allowlist for caches that can be expensive to download again.
418#[must_use]
419pub fn expensive_global_cache_paths(home: &Path) -> Vec<PathBuf> {
420    [
421        ".cache/codex-runtimes",
422        ".cache/huggingface",
423        ".cache/whisper",
424    ]
425    .into_iter()
426    .map(|relative| home.join(relative))
427    .collect()
428}
429
430#[derive(Debug, Clone, Copy)]
431struct ArtifactStats {
432    bytes: u64,
433    modified: Option<SystemTime>,
434}
435
436fn artifact_stats(path: &Path) -> Result<ArtifactStats> {
437    let mut bytes = 0_u64;
438    let mut modified = None;
439    #[cfg(unix)]
440    let mut seen = HashSet::new();
441
442    for entry in WalkDir::new(path)
443        .follow_links(false)
444        .same_file_system(true)
445    {
446        let entry = entry.with_context(|| format!("failed to walk {}", path.display()))?;
447        let metadata = fs::symlink_metadata(entry.path())
448            .with_context(|| format!("failed to inspect {}", entry.path().display()))?;
449        if let Ok(timestamp) = metadata.modified() {
450            if modified.is_none_or(|current| timestamp > current) {
451                modified = Some(timestamp);
452            }
453        }
454
455        #[cfg(unix)]
456        {
457            use std::os::unix::fs::MetadataExt;
458            if !seen.insert((metadata.dev(), metadata.ino())) {
459                continue;
460            }
461            bytes = bytes.saturating_add(metadata.blocks().saturating_mul(512));
462        }
463        #[cfg(not(unix))]
464        {
465            bytes = bytes.saturating_add(metadata.len());
466        }
467    }
468    Ok(ArtifactStats { bytes, modified })
469}
470
471fn is_old_enough(modified: Option<SystemTime>, minimum_age: Option<Duration>) -> bool {
472    let Some(minimum_age) = minimum_age else {
473        return true;
474    };
475    modified
476        .and_then(|timestamp| SystemTime::now().duration_since(timestamp).ok())
477        .is_some_and(|age| age >= minimum_age)
478}
479
480fn system_time_to_unix(value: SystemTime) -> Option<u64> {
481    value
482        .duration_since(UNIX_EPOCH)
483        .ok()
484        .map(|age| age.as_secs())
485}
486
487/// Groups candidate bytes by category.
488#[must_use]
489pub fn totals_by_category(report: &ScanReport) -> HashMap<Category, u64> {
490    let mut totals = HashMap::new();
491    for candidate in &report.candidates {
492        *totals.entry(candidate.category).or_default() += candidate.bytes;
493    }
494    totals
495}
496
497#[cfg(test)]
498mod tests {
499    use std::fs;
500
501    use proptest::prelude::*;
502    use tempfile::tempdir;
503
504    use super::*;
505
506    fn options(root: &Path, categories: HashSet<Category>) -> ScanOptions {
507        ScanOptions {
508            roots: vec![root.to_path_buf()],
509            categories,
510            include_global_caches: false,
511            include_expensive_caches: false,
512            max_depth: 16,
513            excludes: Vec::new(),
514            older_than: None,
515            min_size: 0,
516            protect_git_tracked: false,
517        }
518    }
519
520    #[test]
521    fn classify_should_accept_rust_target_with_marker() -> Result<()> {
522        let temporary = tempdir()?;
523        let target = temporary.path().join("target");
524        fs::create_dir_all(target.join("debug"))?;
525
526        assert!(matches!(classify(&target), Some((Category::RustTarget, _))));
527        Ok(())
528    }
529
530    #[test]
531    fn classify_should_reject_unmarked_target_directory() -> Result<()> {
532        let temporary = tempdir()?;
533        let target = temporary.path().join("target");
534        fs::create_dir_all(&target)?;
535
536        assert!(classify(&target).is_none());
537        Ok(())
538    }
539
540    #[test]
541    fn classify_should_protect_backup_names_case_insensitively() -> Result<()> {
542        let temporary = tempdir()?;
543        let modules = temporary.path().join("Backups/project/node_modules");
544        fs::create_dir_all(&modules)?;
545
546        assert!(classify(&modules).is_none());
547        Ok(())
548    }
549
550    #[test]
551    fn scan_should_not_descend_into_node_modules() -> Result<()> {
552        let temporary = tempdir()?;
553        let modules = temporary.path().join("node_modules");
554        fs::create_dir_all(modules.join("nested/node_modules"))?;
555        fs::write(modules.join("package.js"), "content")?;
556
557        let report = scan(&options(
558            temporary.path(),
559            HashSet::from([Category::NodeModules]),
560        ))?;
561
562        assert_eq!(report.candidates.len(), 1);
563        Ok(())
564    }
565
566    #[test]
567    fn scan_should_apply_exclude_glob() -> Result<()> {
568        let temporary = tempdir()?;
569        fs::create_dir_all(temporary.path().join("vendor/node_modules"))?;
570        let mut scan_options = options(temporary.path(), HashSet::from([Category::NodeModules]));
571        scan_options.excludes.push("vendor/**".to_owned());
572
573        let report = scan(&scan_options)?;
574
575        assert!(report.candidates.is_empty());
576        Ok(())
577    }
578
579    #[cfg(unix)]
580    #[test]
581    fn scan_should_not_follow_symlink_to_node_modules() -> Result<()> {
582        use std::os::unix::fs::symlink;
583
584        let temporary = tempdir()?;
585        let outside = tempdir()?;
586        let modules = outside.path().join("node_modules");
587        fs::create_dir_all(&modules)?;
588        symlink(&modules, temporary.path().join("node_modules"))?;
589
590        let report = scan(&options(
591            temporary.path(),
592            HashSet::from([Category::NodeModules]),
593        ))?;
594
595        assert!(report.candidates.is_empty());
596        Ok(())
597    }
598
599    proptest! {
600        #[test]
601        fn protected_names_are_case_insensitive(uppercase in any::<bool>()) {
602            let name = if uppercase { "POSTGRES" } else { "postgres" };
603            let path = PathBuf::from("root").join(name).join("node_modules");
604            prop_assert!(is_protected(&path));
605        }
606    }
607}