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, ScanReport,
14};
15use crate::policy::{ExcludePolicy, contains_git_tracked_files};
16
17#[derive(Debug, Clone)]
19pub struct ScanOptions {
20 pub roots: Vec<PathBuf>,
22 pub categories: HashSet<Category>,
24 pub include_global_caches: bool,
26 pub include_expensive_caches: bool,
28 pub max_depth: usize,
30 pub excludes: Vec<String>,
32 pub older_than: Option<Duration>,
34 pub min_size: u64,
36 pub protect_git_tracked: bool,
38 pub learning_mode: LearningMode,
40}
41
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44pub enum LearningMode {
45 #[default]
47 Disabled,
48 Enabled,
50}
51
52impl LearningMode {
53 const fn is_enabled(self) -> bool {
54 matches!(self, Self::Enabled)
55 }
56}
57
58#[derive(Debug, Default)]
59struct ScanAccumulator {
60 candidates: Vec<Candidate>,
61 review_candidates: Vec<ReviewCandidate>,
62 learning_observations: Vec<LearningObservation>,
63 warnings: Vec<String>,
64}
65
66#[must_use]
68pub fn default_roots() -> Vec<PathBuf> {
69 let mut roots = Vec::new();
70 if let Some(base) = BaseDirs::new() {
71 for relative in ["Dev", "Projects", "Documents/Codex"] {
72 let candidate = base.home_dir().join(relative);
73 if candidate.is_dir() {
74 roots.push(candidate);
75 }
76 }
77 }
78 if roots.is_empty() {
79 if let Ok(current) = env::current_dir() {
80 roots.push(current);
81 }
82 }
83 roots
84}
85
86pub fn scan(options: &ScanOptions) -> Result<ScanReport> {
93 let mut output = ScanAccumulator::default();
94 let mut normalized_roots = Vec::new();
95
96 for root in &options.roots {
97 if !root.is_dir() {
98 output
99 .warnings
100 .push(format!("skipped missing root: {}", root.display()));
101 continue;
102 }
103 let normalized = root
104 .canonicalize()
105 .with_context(|| format!("failed to normalize root {}", root.display()))?;
106 normalized_roots.push(normalized);
107 }
108 if normalized_roots.is_empty() {
109 anyhow::bail!("no valid scan roots were found");
110 }
111
112 let excludes = ExcludePolicy::new(&options.excludes)?;
113 for root in &normalized_roots {
114 scan_root(root, &normalized_roots, options, &excludes, &mut output);
115 }
116
117 if options.include_global_caches && options.categories.contains(&Category::GlobalCache) {
118 add_global_cache_candidates(
119 Category::GlobalCache,
120 global_cache_paths,
121 &normalized_roots,
122 options,
123 &excludes,
124 &mut output,
125 );
126 }
127 if options.include_expensive_caches
128 && options.categories.contains(&Category::ExpensiveGlobalCache)
129 {
130 add_global_cache_candidates(
131 Category::ExpensiveGlobalCache,
132 expensive_global_cache_paths,
133 &normalized_roots,
134 options,
135 &excludes,
136 &mut output,
137 );
138 }
139
140 output.candidates.sort_by(|left, right| {
141 right
142 .bytes
143 .cmp(&left.bytes)
144 .then_with(|| left.path.cmp(&right.path))
145 });
146 let total_bytes = output
147 .candidates
148 .iter()
149 .map(|candidate| candidate.bytes)
150 .fold(0_u64, u64::saturating_add);
151 output.review_candidates.sort_by(|left, right| {
152 right
153 .bytes
154 .cmp(&left.bytes)
155 .then_with(|| left.path.cmp(&right.path))
156 });
157 let review_total_bytes = output
158 .review_candidates
159 .iter()
160 .map(|candidate| candidate.bytes)
161 .fold(0_u64, u64::saturating_add);
162 output.learning_observations.sort_by(|left, right| {
163 right
164 .bytes
165 .cmp(&left.bytes)
166 .then_with(|| left.path.cmp(&right.path))
167 });
168 let observed_total_bytes = output
169 .learning_observations
170 .iter()
171 .map(|observation| observation.bytes)
172 .fold(0_u64, u64::saturating_add);
173
174 Ok(ScanReport {
175 roots: normalized_roots,
176 candidates: output.candidates,
177 review_candidates: output.review_candidates,
178 learning_observations: output.learning_observations,
179 warnings: output.warnings,
180 total_bytes,
181 review_total_bytes,
182 observed_total_bytes,
183 protect_git_tracked: options.protect_git_tracked,
184 })
185}
186
187fn scan_root(
188 root: &Path,
189 roots: &[PathBuf],
190 options: &ScanOptions,
191 excludes: &ExcludePolicy,
192 output: &mut ScanAccumulator,
193) {
194 let mut walker = WalkDir::new(root)
195 .max_depth(options.max_depth)
196 .follow_links(false)
197 .same_file_system(true)
198 .into_iter();
199
200 while let Some(entry_result) = walker.next() {
201 let entry = match entry_result {
202 Ok(entry) => entry,
203 Err(error) => {
204 output.warnings.push(error.to_string());
205 continue;
206 }
207 };
208 if !entry.file_type().is_dir() {
209 continue;
210 }
211 let path = entry.path();
212 if path != root && (should_prune(path) || excludes.matches(path, roots)) {
213 walker.skip_current_dir();
214 continue;
215 }
216
217 let Some((category, reason)) = classify(path) else {
218 if options.learning_mode.is_enabled() {
219 if let Some(reason) = classify_review_candidate(path) {
220 walker.skip_current_dir();
221 add_review_candidate(path, reason, options, output);
222 }
223 }
224 continue;
225 };
226 walker.skip_current_dir();
227 add_candidate(
228 path,
229 category,
230 reason,
231 options.categories.contains(&category),
232 options,
233 output,
234 );
235 }
236}
237
238fn add_candidate(
239 path: &Path,
240 category: Category,
241 reason: &'static str,
242 include_cleanup_candidate: bool,
243 options: &ScanOptions,
244 output: &mut ScanAccumulator,
245) {
246 let stats = match artifact_stats(path) {
247 Ok(stats) => stats,
248 Err(error) => {
249 output
250 .warnings
251 .push(format!("{}: {error:#}", path.display()));
252 return;
253 }
254 };
255 if options.protect_git_tracked {
256 match contains_git_tracked_files(path) {
257 Ok(true) => {
258 if options.learning_mode.is_enabled() {
259 output.learning_observations.push(LearningObservation {
260 path: path.to_path_buf(),
261 category: Some(category),
262 bytes: stats.bytes,
263 reason: "contains Git-tracked files".to_owned(),
264 modified_at_unix: stats.modified.and_then(system_time_to_unix),
265 confidence: Confidence::Protected,
266 });
267 }
268 output.warnings.push(format!(
269 "protected Git-tracked candidate: {}",
270 path.display()
271 ));
272 return;
273 }
274 Ok(false) => {}
275 Err(error) => {
276 output.warnings.push(format!(
277 "skipped {} because tracked-file guard failed: {error:#}",
278 path.display()
279 ));
280 return;
281 }
282 }
283 }
284 if options.learning_mode.is_enabled() {
285 output.learning_observations.push(LearningObservation {
286 path: path.to_path_buf(),
287 category: Some(category),
288 bytes: stats.bytes,
289 reason: reason.to_owned(),
290 modified_at_unix: stats.modified.and_then(system_time_to_unix),
291 confidence: Confidence::Safe,
292 });
293 }
294 if !include_cleanup_candidate
295 || stats.bytes < options.min_size
296 || !is_old_enough(stats.modified, options.older_than)
297 {
298 return;
299 }
300 output.candidates.push(Candidate {
301 category,
302 path: path.to_path_buf(),
303 bytes: stats.bytes,
304 reason: reason.to_owned(),
305 modified_at_unix: stats.modified.and_then(system_time_to_unix),
306 confidence: Confidence::Safe,
307 });
308}
309
310fn add_review_candidate(
311 path: &Path,
312 reason: &'static str,
313 options: &ScanOptions,
314 output: &mut ScanAccumulator,
315) {
316 let stats = match artifact_stats(path) {
317 Ok(stats) => stats,
318 Err(error) => {
319 output
320 .warnings
321 .push(format!("{}: {error:#}", path.display()));
322 return;
323 }
324 };
325 if stats.bytes < options.min_size {
326 return;
327 }
328 if options.protect_git_tracked {
329 match contains_git_tracked_files(path) {
330 Ok(true) => {
331 output.learning_observations.push(LearningObservation {
332 path: path.to_path_buf(),
333 category: None,
334 bytes: stats.bytes,
335 reason: "contains Git-tracked files".to_owned(),
336 modified_at_unix: stats.modified.and_then(system_time_to_unix),
337 confidence: Confidence::Protected,
338 });
339 output.warnings.push(format!(
340 "protected Git-tracked learning candidate: {}",
341 path.display()
342 ));
343 return;
344 }
345 Ok(false) => {}
346 Err(error) => {
347 output.warnings.push(format!(
348 "skipped learning candidate {} because tracked-file guard failed: {error:#}",
349 path.display()
350 ));
351 return;
352 }
353 }
354 }
355 output.learning_observations.push(LearningObservation {
356 path: path.to_path_buf(),
357 category: None,
358 bytes: stats.bytes,
359 reason: reason.to_owned(),
360 modified_at_unix: stats.modified.and_then(system_time_to_unix),
361 confidence: Confidence::Review,
362 });
363 output.review_candidates.push(ReviewCandidate {
364 path: path.to_path_buf(),
365 bytes: stats.bytes,
366 reason: reason.to_owned(),
367 modified_at_unix: stats.modified.and_then(system_time_to_unix),
368 confidence: Confidence::Review,
369 });
370}
371
372#[must_use]
374pub fn classify(path: &Path) -> Option<(Category, &'static str)> {
375 if is_protected(path) {
376 return None;
377 }
378 let name = path.file_name()?;
379
380 if name == OsStr::new("target") && looks_like_rust_target(path) {
381 return Some((
382 Category::RustTarget,
383 "Cargo target directory with Rust build markers",
384 ));
385 }
386 if matches_name(name, &["node_modules", "frontend_node_modules"]) {
387 return Some((Category::NodeModules, "installed JavaScript dependencies"));
388 }
389 if matches_name(
390 name,
391 &[
392 ".next",
393 ".svelte-kit",
394 ".turbo",
395 ".vite",
396 ".parcel-cache",
397 ".nuxt",
398 ".output",
399 ".dart_tool",
400 ".npm-cache",
401 ],
402 ) {
403 return Some((Category::FrameworkCache, "framework-generated cache"));
404 }
405 if matches_name(
406 name,
407 &[
408 "mutants.out",
409 ".pytest_cache",
410 ".mypy_cache",
411 ".ruff_cache",
412 ".nyc_output",
413 ],
414 ) {
415 return Some((Category::TestCache, "test or analysis cache"));
416 }
417 if name == OsStr::new("build") && looks_like_project_build(path) {
418 return Some((
419 Category::BuildOutput,
420 "build directory beneath a recognized project",
421 ));
422 }
423 None
424}
425
426fn classify_review_candidate(path: &Path) -> Option<&'static str> {
427 if is_protected(path) || !has_project_marker(path) {
428 return None;
429 }
430 let name = path.file_name()?;
431 if matches_name(
432 name,
433 &[
434 ".build",
435 ".cache",
436 ".gradle",
437 ".angular",
438 ".expo",
439 "DerivedData",
440 "Pods",
441 "cache",
442 "coverage",
443 "dist",
444 "generated",
445 "out",
446 "temp",
447 "tmp",
448 ],
449 ) {
450 return Some("large cache-like directory beneath a recognized project");
451 }
452 None
453}
454
455fn has_project_marker(path: &Path) -> bool {
456 path.ancestors().skip(1).take(3).any(|ancestor| {
457 [
458 "Cargo.toml",
459 "Package.swift",
460 "package.json",
461 "pyproject.toml",
462 "go.mod",
463 "pubspec.yaml",
464 "build.gradle",
465 "settings.gradle",
466 ]
467 .iter()
468 .any(|marker| ancestor.join(marker).is_file())
469 || ancestor
470 .read_dir()
471 .ok()
472 .into_iter()
473 .flatten()
474 .filter_map(Result::ok)
475 .any(|entry| entry.path().extension() == Some(OsStr::new("xcodeproj")))
476 })
477}
478
479fn should_prune(path: &Path) -> bool {
480 path.file_name().is_some_and(|name| {
481 matches_name(name, &[".git", ".hg", ".svn", ".venv", "site-packages"])
482 || name.to_string_lossy().starts_with(".devclean-quarantine-")
483 })
484}
485
486fn looks_like_rust_target(path: &Path) -> bool {
487 path.join("CACHEDIR.TAG").is_file()
488 || path.join(".rustc_info.json").is_file()
489 || path.join("debug").is_dir()
490 || path.join("release").is_dir()
491}
492
493fn looks_like_project_build(path: &Path) -> bool {
494 let Some(parent) = path.parent() else {
495 return false;
496 };
497 if ["package.json", "pubspec.yaml", "Cargo.toml"]
498 .iter()
499 .any(|marker| parent.join(marker).is_file())
500 {
501 return true;
502 }
503 parent.file_name() == Some(OsStr::new("ios"))
504 || parent
505 .read_dir()
506 .ok()
507 .into_iter()
508 .flatten()
509 .filter_map(Result::ok)
510 .any(|entry| entry.path().extension() == Some(OsStr::new("xcodeproj")))
511}
512
513fn is_protected(path: &Path) -> bool {
514 path.components().any(|component| {
515 let Component::Normal(name) = component else {
516 return false;
517 };
518 let value = name.to_string_lossy();
519 [
520 ".git",
521 ".hg",
522 ".svn",
523 "backups",
524 "backup",
525 "volumes",
526 "postgres",
527 "postgresql",
528 "mysql",
529 "mariadb",
530 "filestore",
531 ]
532 .iter()
533 .any(|protected| value.eq_ignore_ascii_case(protected))
534 })
535}
536
537fn matches_name(name: &OsStr, values: &[&str]) -> bool {
538 values.iter().any(|value| name == OsStr::new(value))
539}
540
541fn add_global_cache_candidates(
542 category: Category,
543 paths: fn(&Path) -> Vec<PathBuf>,
544 roots: &[PathBuf],
545 options: &ScanOptions,
546 excludes: &ExcludePolicy,
547 output: &mut ScanAccumulator,
548) {
549 let Some(base) = BaseDirs::new() else {
550 output
551 .warnings
552 .push("home directory is unavailable; global caches were skipped".to_owned());
553 return;
554 };
555 for path in paths(base.home_dir()) {
556 if path.is_dir() && !excludes.matches(&path, roots) {
557 add_candidate(
558 &path,
559 category,
560 if category == Category::ExpensiveGlobalCache {
561 "large downloaded runtime or model cache"
562 } else {
563 "downloaded development-tool cache"
564 },
565 true,
566 options,
567 output,
568 );
569 }
570 }
571}
572
573#[must_use]
575pub fn global_cache_paths(home: &Path) -> Vec<PathBuf> {
576 let mut paths = [
577 ".npm/_cacache",
578 ".npm/_npx",
579 ".cargo/registry/cache",
580 ".cargo/registry/src",
581 ".cargo/registry/index",
582 ".cargo/git/db",
583 "go/pkg/mod",
584 ".cache/uv",
585 ".cache/pip",
586 ".cache/puppeteer",
587 ".cache/gem",
588 ".pub-cache/hosted",
589 ".pub-cache/hosted-hashes",
590 ".pub-cache/git",
591 ]
592 .into_iter()
593 .map(|relative| home.join(relative))
594 .collect::<Vec<_>>();
595 if cfg!(target_os = "macos") {
596 paths.extend(
597 [
598 "Library/pnpm",
599 "Library/Caches/ms-playwright",
600 "Library/Caches/node-gyp",
601 ]
602 .into_iter()
603 .map(|relative| home.join(relative)),
604 );
605 }
606 if cfg!(target_os = "windows") {
607 paths.extend(
608 [
609 "AppData/Local/npm-cache",
610 "AppData/Local/pnpm/store",
611 "AppData/Local/ms-playwright",
612 "AppData/Local/node-gyp/Cache",
613 ]
614 .into_iter()
615 .map(|relative| home.join(relative)),
616 );
617 }
618 paths
619}
620
621#[must_use]
623pub fn expensive_global_cache_paths(home: &Path) -> Vec<PathBuf> {
624 [
625 ".cache/codex-runtimes",
626 ".cache/huggingface",
627 ".cache/whisper",
628 ]
629 .into_iter()
630 .map(|relative| home.join(relative))
631 .collect()
632}
633
634#[derive(Debug, Clone, Copy)]
635struct ArtifactStats {
636 bytes: u64,
637 modified: Option<SystemTime>,
638}
639
640fn artifact_stats(path: &Path) -> Result<ArtifactStats> {
641 let mut bytes = 0_u64;
642 let mut modified = None;
643 #[cfg(unix)]
644 let mut seen = HashSet::new();
645
646 for entry in WalkDir::new(path)
647 .follow_links(false)
648 .same_file_system(true)
649 {
650 let entry = entry.with_context(|| format!("failed to walk {}", path.display()))?;
651 let metadata = fs::symlink_metadata(entry.path())
652 .with_context(|| format!("failed to inspect {}", entry.path().display()))?;
653 if let Ok(timestamp) = metadata.modified() {
654 if modified.is_none_or(|current| timestamp > current) {
655 modified = Some(timestamp);
656 }
657 }
658
659 #[cfg(unix)]
660 {
661 use std::os::unix::fs::MetadataExt;
662 if !seen.insert((metadata.dev(), metadata.ino())) {
663 continue;
664 }
665 bytes = bytes.saturating_add(metadata.blocks().saturating_mul(512));
666 }
667 #[cfg(not(unix))]
668 {
669 bytes = bytes.saturating_add(metadata.len());
670 }
671 }
672 Ok(ArtifactStats { bytes, modified })
673}
674
675fn is_old_enough(modified: Option<SystemTime>, minimum_age: Option<Duration>) -> bool {
676 let Some(minimum_age) = minimum_age else {
677 return true;
678 };
679 modified
680 .and_then(|timestamp| SystemTime::now().duration_since(timestamp).ok())
681 .is_some_and(|age| age >= minimum_age)
682}
683
684fn system_time_to_unix(value: SystemTime) -> Option<u64> {
685 value
686 .duration_since(UNIX_EPOCH)
687 .ok()
688 .map(|age| age.as_secs())
689}
690
691#[must_use]
693pub fn totals_by_category(report: &ScanReport) -> HashMap<Category, u64> {
694 let mut totals = HashMap::new();
695 for candidate in &report.candidates {
696 *totals.entry(candidate.category).or_default() += candidate.bytes;
697 }
698 totals
699}
700
701#[cfg(test)]
702mod tests {
703 use std::fs;
704
705 use proptest::prelude::*;
706 use tempfile::tempdir;
707
708 use super::*;
709
710 fn options(root: &Path, categories: HashSet<Category>) -> ScanOptions {
711 ScanOptions {
712 roots: vec![root.to_path_buf()],
713 categories,
714 include_global_caches: false,
715 include_expensive_caches: false,
716 max_depth: 16,
717 excludes: Vec::new(),
718 older_than: None,
719 min_size: 0,
720 protect_git_tracked: false,
721 learning_mode: LearningMode::Disabled,
722 }
723 }
724
725 #[test]
726 fn classify_should_accept_rust_target_with_marker() -> Result<()> {
727 let temporary = tempdir()?;
728 let target = temporary.path().join("target");
729 fs::create_dir_all(target.join("debug"))?;
730
731 assert!(matches!(classify(&target), Some((Category::RustTarget, _))));
732 Ok(())
733 }
734
735 #[test]
736 fn classify_should_reject_unmarked_target_directory() -> Result<()> {
737 let temporary = tempdir()?;
738 let target = temporary.path().join("target");
739 fs::create_dir_all(&target)?;
740
741 assert!(classify(&target).is_none());
742 Ok(())
743 }
744
745 #[test]
746 fn classify_should_protect_backup_names_case_insensitively() -> Result<()> {
747 let temporary = tempdir()?;
748 let modules = temporary.path().join("Backups/project/node_modules");
749 fs::create_dir_all(&modules)?;
750
751 assert!(classify(&modules).is_none());
752 Ok(())
753 }
754
755 #[test]
756 fn scan_should_not_descend_into_node_modules() -> Result<()> {
757 let temporary = tempdir()?;
758 let modules = temporary.path().join("node_modules");
759 fs::create_dir_all(modules.join("nested/node_modules"))?;
760 fs::write(modules.join("package.js"), "content")?;
761
762 let report = scan(&options(
763 temporary.path(),
764 HashSet::from([Category::NodeModules]),
765 ))?;
766
767 assert_eq!(report.candidates.len(), 1);
768 Ok(())
769 }
770
771 #[test]
772 fn scan_should_apply_exclude_glob() -> Result<()> {
773 let temporary = tempdir()?;
774 fs::create_dir_all(temporary.path().join("vendor/node_modules"))?;
775 let mut scan_options = options(temporary.path(), HashSet::from([Category::NodeModules]));
776 scan_options.excludes.push("vendor/**".to_owned());
777
778 let report = scan(&scan_options)?;
779
780 assert!(report.candidates.is_empty());
781 Ok(())
782 }
783
784 #[cfg(unix)]
785 #[test]
786 fn scan_should_not_follow_symlink_to_node_modules() -> Result<()> {
787 use std::os::unix::fs::symlink;
788
789 let temporary = tempdir()?;
790 let outside = tempdir()?;
791 let modules = outside.path().join("node_modules");
792 fs::create_dir_all(&modules)?;
793 symlink(&modules, temporary.path().join("node_modules"))?;
794
795 let report = scan(&options(
796 temporary.path(),
797 HashSet::from([Category::NodeModules]),
798 ))?;
799
800 assert!(report.candidates.is_empty());
801 Ok(())
802 }
803
804 #[test]
805 fn learning_mode_should_observe_unknown_project_cache_as_review_only() -> Result<()> {
806 let temporary = tempdir()?;
807 fs::write(temporary.path().join("package.json"), "{}")?;
808 let cache = temporary.path().join("dist");
809 fs::create_dir_all(&cache)?;
810 fs::write(cache.join("bundle.js"), "generated")?;
811 let mut options = options(temporary.path(), HashSet::from(Category::all()));
812 options.learning_mode = LearningMode::Enabled;
813
814 let report = scan(&options)?;
815
816 assert!(report.candidates.is_empty());
817 assert_eq!(report.review_candidates.len(), 1);
818 Ok(())
819 }
820
821 #[test]
822 fn learning_mode_should_measure_active_artifact_without_making_it_cleanable() -> Result<()> {
823 let temporary = tempdir()?;
824 let target = temporary.path().join("target/debug");
825 fs::create_dir_all(&target)?;
826 fs::write(target.join("artifact"), "fresh")?;
827 let mut options = options(temporary.path(), HashSet::from([Category::RustTarget]));
828 options.learning_mode = LearningMode::Enabled;
829 options.older_than = Some(Duration::from_secs(86_400));
830
831 let report = scan(&options)?;
832
833 assert!(report.candidates.is_empty());
834 assert_eq!(report.learning_observations.len(), 1);
835 Ok(())
836 }
837
838 proptest! {
839 #[test]
840 fn protected_names_are_case_insensitive(uppercase in any::<bool>()) {
841 let name = if uppercase { "POSTGRES" } else { "postgres" };
842 let path = PathBuf::from("root").join(name).join("node_modules");
843 prop_assert!(is_protected(&path));
844 }
845 }
846}