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