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#[derive(Debug, Clone)]
17pub struct ScanOptions {
18 pub roots: Vec<PathBuf>,
20 pub categories: HashSet<Category>,
22 pub include_global_caches: bool,
24 pub include_expensive_caches: bool,
26 pub max_depth: usize,
28 pub excludes: Vec<String>,
30 pub older_than: Option<Duration>,
32 pub min_size: u64,
34 pub protect_git_tracked: bool,
36}
37
38#[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
58pub 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#[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#[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#[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#[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}