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#[derive(Debug, Clone)]
25pub struct ScanOptions {
26 pub roots: Vec<PathBuf>,
28 pub categories: HashSet<Category>,
30 pub include_global_caches: bool,
32 pub include_expensive_caches: bool,
34 pub max_depth: usize,
36 pub excludes: Vec<String>,
38 pub older_than: Option<Duration>,
40 pub min_size: u64,
42 pub protect_git_tracked: bool,
44 pub learning_mode: LearningMode,
46 pub approved_review_paths: HashSet<PathBuf>,
48 pub custom_rules: Vec<CustomRule>,
50}
51
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
54pub enum LearningMode {
55 #[default]
57 Disabled,
58 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#[derive(Debug)]
93struct PendingArtifact {
94 path: PathBuf,
95 kind: PendingKind,
96}
97
98#[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
118pub 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#[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#[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#[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
736fn 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#[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}