Skip to main content

devclean/
scanner.rs

1use std::collections::{HashMap, HashSet};
2use std::env;
3use std::ffi::OsStr;
4use std::fs;
5use std::path::{Component, Path, PathBuf};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use anyhow::{Context, Result};
9use directories::BaseDirs;
10use walkdir::WalkDir;
11
12use crate::model::{
13    Candidate, Category, Confidence, LearningObservation, ReviewCandidate, ScanReport,
14};
15use crate::policy::{ExcludePolicy, contains_git_tracked_files};
16
17/// Scanner configuration.
18#[derive(Debug, Clone)]
19pub struct ScanOptions {
20    /// Filesystem roots to inspect.
21    pub roots: Vec<PathBuf>,
22    /// Artifact categories to include.
23    pub categories: HashSet<Category>,
24    /// Include known global package and tool caches.
25    pub include_global_caches: bool,
26    /// Include large runtime and model caches.
27    pub include_expensive_caches: bool,
28    /// Maximum traversal depth below each root.
29    pub max_depth: usize,
30    /// User-provided path globs to skip.
31    pub excludes: Vec<String>,
32    /// Only include artifacts whose latest modification is at least this old.
33    pub older_than: Option<Duration>,
34    /// Only include artifacts at least this large.
35    pub min_size: u64,
36    /// Refuse candidates containing Git-tracked files.
37    pub protect_git_tracked: bool,
38    /// Observe large cache-like directories without making them cleanable.
39    pub learning_mode: LearningMode,
40}
41
42/// Controls whether the scanner emits local growth observations.
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44pub enum LearningMode {
45    /// Return cleanup candidates only.
46    #[default]
47    Disabled,
48    /// Measure active known artifacts and review-only cache-like directories.
49    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/// Returns useful development roots without scanning the entire home directory.
67#[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
86/// Scans configured roots without modifying the filesystem.
87///
88/// # Errors
89///
90/// Returns an error when no valid root exists, a root cannot be normalized, or an exclude glob
91/// is invalid.
92pub 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/// Classifies a directory using conservative, filesystem-verifiable markers.
373#[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/// Returns the exact allowlist for package and tool caches that are cheap to restore.
574#[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/// Returns the exact allowlist for caches that can be expensive to download again.
622#[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/// Groups candidate bytes by category.
692#[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}