1use std::collections::BTreeMap;
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::time::SystemTime;
19
20use anyhow::Result;
21use chrono::{DateTime, Utc};
22
23use crate::adapters::BloatDir;
24use crate::config::{Registry, RepoEntry};
25use crate::constants;
26use crate::scanner;
27use crate::scanner::git;
28use crate::workspace;
29
30#[derive(Debug, Clone)]
32pub enum PruneStatus {
33 Pruned,
35 SkippedActive,
37 SkippedDryRun,
39 LockfileError(String),
41 ActivityCheckError(String),
46 PathMissing,
48 NoBloat,
50 Disabled,
52 SkippedIgnored,
54 DeleteError(String),
56 SkippedSymlink(String),
62 ConfigError(String),
64 SkippedDeclaration(String),
70}
71
72impl std::fmt::Display for PruneStatus {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 PruneStatus::Pruned => write!(f, "Pruned"),
76 PruneStatus::SkippedActive => write!(f, "Skipped (active)"),
77 PruneStatus::SkippedDryRun => write!(f, "Skipped (dry run)"),
78 PruneStatus::LockfileError(e) => write!(f, "Lockfile error: {e}"),
79 PruneStatus::ActivityCheckError(e) => write!(f, "Activity check failed: {e}"),
80 PruneStatus::PathMissing => {
81 write!(
82 f,
83 "Path no longer exists (`devp unlink --missing` clears it)"
84 )
85 }
86 PruneStatus::NoBloat => write!(f, "No bloat found"),
87 PruneStatus::Disabled => write!(f, "Disabled"),
88 PruneStatus::SkippedIgnored => write!(
89 f,
90 "Ignored (ignore.devprune.json or ignore config in .devprune.json)"
91 ),
92 PruneStatus::DeleteError(e) => write!(f, "Delete error: {e}"),
93 PruneStatus::SkippedSymlink(e) => write!(f, "Skipped (symlink): {e}"),
94 PruneStatus::ConfigError(e) => write!(f, "Unreadable .devprune.json: {e}"),
95 PruneStatus::SkippedDeclaration(e) => write!(f, "Skipped (declaration): {e}"),
96 }
97 }
98}
99
100pub const BYTES_PER_MIB: u64 = 1024 * 1024;
103
104#[derive(Debug, Clone, Default, PartialEq)]
111pub struct AdapterFilter {
112 only: Option<Vec<String>>,
113 skip: Vec<String>,
114}
115
116impl AdapterFilter {
117 pub fn new(only: Option<&str>, skip: Option<&str>) -> Result<Self> {
122 let known: Vec<&'static str> = crate::adapters::get_all_adapters()
126 .iter()
127 .map(|a| a.name())
128 .chain(std::iter::once(constants::DECLARED_ADAPTER_NAME))
129 .collect();
130
131 let parse = |raw: &str, flag: &str| -> Result<Vec<String>> {
132 let mut out = Vec::new();
133 for token in raw.split(',') {
134 let name = token.trim().to_lowercase();
135 if name.is_empty() {
136 continue;
137 }
138 if !known.contains(&name.as_str()) {
139 anyhow::bail!(
140 "`--{flag} {name}` names no known package manager. Available: {}.",
141 known.join(", ")
142 );
143 }
144 if !out.contains(&name) {
145 out.push(name);
146 }
147 }
148 if out.is_empty() {
149 anyhow::bail!("`--{flag}` was given no adapter names.");
150 }
151 Ok(out)
152 };
153
154 let only = only.map(|raw| parse(raw, "only")).transpose()?;
155 let skip = skip
156 .map(|raw| parse(raw, "skip"))
157 .transpose()?
158 .unwrap_or_default();
159
160 if let Some(only) = &only
161 && let Some(clash) = only.iter().find(|n| skip.contains(n))
162 {
163 anyhow::bail!("`{clash}` is in both --only and --skip; pick one.");
164 }
165
166 Ok(Self { only, skip })
167 }
168
169 pub fn allows(&self, name: &str) -> bool {
171 if self.skip.iter().any(|s| s == name) {
172 return false;
173 }
174 match &self.only {
175 Some(only) => only.iter().any(|o| o == name),
176 None => true,
177 }
178 }
179
180 pub fn is_unrestricted(&self) -> bool {
182 self.only.is_none() && self.skip.is_empty()
183 }
184
185 pub fn describe(&self) -> Option<String> {
187 if self.is_unrestricted() {
188 return None;
189 }
190 let mut parts = Vec::new();
191 if let Some(only) = &self.only {
192 parts.push(format!("only {}", only.join(", ")));
193 }
194 if !self.skip.is_empty() {
195 parts.push(format!("skipping {}", self.skip.join(", ")));
196 }
197 Some(parts.join("; "))
198 }
199}
200
201#[derive(Debug, Clone)]
207pub struct PruneOptions {
208 pub idle_days: u64,
210 pub dry_run: bool,
212 pub force: bool,
214 pub only_dirs: Option<Vec<String>>,
220 pub adapters: AdapterFilter,
222 pub min_size_bytes: u64,
224 pub scan_depth: usize,
229 pub allow_manifest_rewrite: bool,
231 pub command_timeout_secs: u64,
236 pub build_idle_days: u64,
242 pub adapter_idle_days: BTreeMap<String, u64>,
247}
248
249impl Default for PruneOptions {
250 fn default() -> Self {
251 Self {
252 idle_days: 0,
253 dry_run: false,
254 force: false,
255 only_dirs: None,
256 adapters: AdapterFilter::default(),
257 min_size_bytes: 0,
258 scan_depth: crate::constants::DEFAULT_SCAN_DEPTH,
259 allow_manifest_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
260 command_timeout_secs: crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS,
261 build_idle_days: crate::constants::DEFAULT_BUILD_IDLE_DAYS,
262 adapter_idle_days: BTreeMap::new(),
263 }
264 }
265}
266
267impl PruneOptions {
268 fn idle_threshold_for(&self, name: &str, opt_in: bool, base: u64) -> u64 {
282 let mut days = base;
283 if opt_in {
284 days = days.max(self.build_idle_days);
285 }
286 if let Some(&explicit) = self.adapter_idle_days.get(name) {
287 days = days.max(explicit);
288 }
289 days
290 }
291
292 pub fn new(idle_days: u64, dry_run: bool, force: bool) -> Self {
294 Self {
295 idle_days,
296 dry_run,
297 force,
298 ..Self::default()
299 }
300 }
301}
302
303#[derive(Debug, Clone)]
305pub struct PruneResult {
306 pub repo_path: PathBuf,
308 pub adapter_name: String,
310 pub bloat_dir: String,
312 pub size_freed: u64,
314 pub shared_bytes: u64,
318 pub runtime: Option<String>,
321 pub status: PruneStatus,
323}
324
325impl PruneResult {
326 pub fn project_dir(&self) -> PathBuf {
334 self.repo_path
335 .join(&self.bloat_dir)
336 .parent()
337 .map(Path::to_path_buf)
338 .unwrap_or_else(|| self.repo_path.clone())
339 }
340}
341
342pub fn prune_repo(
349 repo_path: &Path,
350 idle_days: u64,
351 dry_run: bool,
352 force: bool,
353) -> Vec<PruneResult> {
354 prune_repo_with(repo_path, &PruneOptions::new(idle_days, dry_run, force))
355}
356
357pub fn prune_repo_selected(
366 repo_path: &Path,
367 idle_days: u64,
368 dry_run: bool,
369 force: bool,
370 only: Option<&[String]>,
371) -> Vec<PruneResult> {
372 prune_repo_with(
373 repo_path,
374 &PruneOptions {
375 only_dirs: only.map(<[String]>::to_vec),
376 ..PruneOptions::new(idle_days, dry_run, force)
377 },
378 )
379}
380
381pub fn prune_repo_with(repo_path: &Path, opts: &PruneOptions) -> Vec<PruneResult> {
386 let idle_days = opts.idle_days;
387 let dry_run = opts.dry_run;
388 let force = opts.force;
389 let only = opts.only_dirs.as_deref();
390 let mut results = Vec::new();
391
392 if !repo_path.exists() {
396 results.push(PruneResult {
397 repo_path: repo_path.to_path_buf(),
398 adapter_name: "-".to_string(),
399 bloat_dir: "-".to_string(),
400 size_freed: 0,
401 shared_bytes: 0,
402 runtime: None,
403 status: PruneStatus::PathMissing,
404 });
405 return results;
406 }
407
408 if !scanner::is_git_repo(repo_path) {
412 results.push(PruneResult {
413 repo_path: repo_path.to_path_buf(),
414 adapter_name: "-".to_string(),
415 bloat_dir: "-".to_string(),
416 size_freed: 0,
417 shared_bytes: 0,
418 runtime: None,
419 status: PruneStatus::ActivityCheckError(format!(
420 "`{}` is no longer a git repository — nothing was touched. \
421 `devp unlink` removes it from the registry.",
422 repo_path.display()
423 )),
424 });
425 return results;
426 }
427
428 if repo_path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
430 results.push(PruneResult {
431 repo_path: repo_path.to_path_buf(),
432 adapter_name: "-".to_string(),
433 bloat_dir: "-".to_string(),
434 size_freed: 0,
435 shared_bytes: 0,
436 runtime: None,
437 status: PruneStatus::SkippedIgnored,
438 });
439 return results;
440 }
441
442 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(repo_path) {
446 Ok(cfg) => cfg,
447 Err(e) => {
448 results.push(PruneResult {
449 repo_path: repo_path.to_path_buf(),
450 adapter_name: "-".to_string(),
451 bloat_dir: "-".to_string(),
452 size_freed: 0,
453 shared_bytes: 0,
454 runtime: None,
455 status: PruneStatus::ConfigError(e),
456 });
457 return results;
458 }
459 };
460 if per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false) {
461 results.push(PruneResult {
462 repo_path: repo_path.to_path_buf(),
463 adapter_name: "-".to_string(),
464 bloat_dir: "-".to_string(),
465 size_freed: 0,
466 shared_bytes: 0,
467 runtime: None,
468 status: PruneStatus::SkippedIgnored,
469 });
470 return results;
471 }
472
473 let effective_idle_days = per_repo_config
475 .as_ref()
476 .and_then(|c| c.override_idle_days)
477 .unwrap_or(idle_days);
478
479 let min_size_bytes = if only.is_some() {
482 0
483 } else {
484 per_repo_config
485 .as_ref()
486 .and_then(|c| c.min_size_mb)
487 .map(|mb| mb.saturating_mul(BYTES_PER_MIB))
488 .unwrap_or(opts.min_size_bytes)
489 };
490
491 if !force {
493 match git::is_repo_idle(repo_path, effective_idle_days) {
494 Ok(false) => {
495 results.push(PruneResult {
496 repo_path: repo_path.to_path_buf(),
497 adapter_name: "-".to_string(),
498 bloat_dir: "-".to_string(),
499 size_freed: 0,
500 shared_bytes: 0,
501 runtime: None,
502 status: PruneStatus::SkippedActive,
503 });
504 return results;
505 }
506 Ok(true) => {} Err(e) => {
508 results.push(PruneResult {
509 repo_path: repo_path.to_path_buf(),
510 adapter_name: "-".to_string(),
511 bloat_dir: "-".to_string(),
512 size_freed: 0,
513 shared_bytes: 0,
514 runtime: None,
515 status: PruneStatus::ActivityCheckError(e.to_string()),
516 });
517 return results;
518 }
519 }
520 }
521
522 let projects = workspace::discover_to_depth(
526 repo_path,
527 workspace::resolve_depth(repo_path, opts.scan_depth),
528 );
529
530 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
534
535 let mut idle_at: BTreeMap<u64, bool> = BTreeMap::new();
540
541 for project in &projects {
542 for adapter in &project.adapters {
543 if !opts.adapters.allows(adapter.name()) {
544 continue;
545 }
546
547 let threshold =
551 opts.idle_threshold_for(adapter.name(), adapter.opt_in(), effective_idle_days);
552 if threshold > effective_idle_days && !force {
553 let idle_enough = *idle_at
554 .entry(threshold)
555 .or_insert_with(|| git::is_repo_idle(repo_path, threshold).unwrap_or(false));
556 if !idle_enough {
557 continue;
558 }
559 }
560
561 let bloat_dirs: Vec<(String, BloatDir)> = adapter
568 .bloat_dirs(&project.path)
569 .into_iter()
570 .map(|bd| (workspace::relative_label(repo_path, &bd.path), bd))
571 .filter(|(label, _)| only.is_none_or(|names| names.contains(label)))
572 .filter(|(_, bd)| bd.size_bytes >= min_size_bytes)
573 .filter(|(_, bd)| claimed.insert(bd.path.clone()))
574 .collect();
575
576 if bloat_dirs.is_empty() {
577 continue;
578 }
579
580 let mut deletable: Vec<(String, BloatDir)> = Vec::new();
589 for (label, bd) in bloat_dirs {
590 if let Some(status) = shared_storage_refusal(&bd.path) {
591 results.push(PruneResult {
592 repo_path: repo_path.to_path_buf(),
593 adapter_name: adapter.name().to_string(),
594 bloat_dir: label,
595 size_freed: 0,
596 shared_bytes: 0,
597 runtime: None,
598 status,
599 });
600 continue;
601 }
602
603 deletable.push((label, bd));
604 }
605
606 if deletable.is_empty() {
607 continue;
608 }
609
610 if !dry_run {
612 let policy = crate::adapters::EnforcePolicy {
613 allow_rewrite: opts.allow_manifest_rewrite,
614 timeout: std::time::Duration::from_secs(opts.command_timeout_secs),
615 };
616 if let Err(e) = adapter.enforce_lockfile(&project.path, policy) {
617 for (label, _) in &deletable {
618 results.push(PruneResult {
619 repo_path: repo_path.to_path_buf(),
620 adapter_name: adapter.name().to_string(),
621 bloat_dir: label.clone(),
622 size_freed: 0,
623 shared_bytes: 0,
624 runtime: None,
625 status: PruneStatus::LockfileError(e.to_string()),
626 });
627 }
628 continue;
629 }
630 }
631
632 for (label, bd) in deletable {
633 if dry_run {
634 results.push(PruneResult {
635 repo_path: repo_path.to_path_buf(),
636 adapter_name: adapter.name().to_string(),
637 bloat_dir: label,
638 size_freed: bd.size_bytes,
639 shared_bytes: bd.shared_bytes,
640 runtime: None,
641 status: PruneStatus::SkippedDryRun,
642 });
643 continue;
644 }
645
646 let runtime = adapter.runtime_tag(&project.path, &bd.name);
650 results.push(delete_bloat(repo_path, adapter.name(), label, &bd, runtime));
651 }
652 }
653 }
654
655 results.extend(prune_declarations(
656 repo_path,
657 per_repo_config.as_ref(),
658 opts,
659 min_size_bytes,
660 only,
661 &mut claimed,
662 ));
663
664 if results.is_empty() {
666 results.push(PruneResult {
667 repo_path: repo_path.to_path_buf(),
668 adapter_name: "-".to_string(),
669 bloat_dir: "-".to_string(),
670 size_freed: 0,
671 shared_bytes: 0,
672 runtime: None,
673 status: PruneStatus::NoBloat,
674 });
675 }
676
677 results
678}
679
680fn shared_storage_refusal(path: &Path) -> Option<PruneStatus> {
689 if fs::symlink_metadata(path)
693 .map(|m| m.file_type().is_symlink())
694 .unwrap_or(false)
695 {
696 return Some(PruneStatus::SkippedSymlink(format!(
697 "`{}` is a symlink to storage dev-prune does not own — left alone. Remove \
698 the link yourself if you really want it gone.",
699 path.display()
700 )));
701 }
702
703 if is_mount_point(path) {
708 return Some(PruneStatus::SkippedSymlink(format!(
709 "`{}` is a mount point — it is on a different filesystem than the repository \
710 around it, so its contents are shared with whatever mounted it. Left alone.",
711 path.display()
712 )));
713 }
714
715 if let Some(nested) = find_nested_git(path) {
720 return Some(PruneStatus::DeleteError(format!(
721 "`{}` contains a git repository at `{}` — refusing to delete it. Move or \
722 remove that checkout yourself if it holds nothing you need.",
723 path.display(),
724 nested.display()
725 )));
726 }
727
728 None
729}
730
731fn delete_bloat(
733 repo_path: &Path,
734 adapter_name: &str,
735 label: String,
736 bd: &BloatDir,
737 runtime: Option<String>,
738) -> PruneResult {
739 let size = bd.size_bytes;
740 let delete = fs::remove_dir_all(&bd.path).or_else(|_| {
746 std::thread::sleep(std::time::Duration::from_millis(250));
747 fs::remove_dir_all(&bd.path)
748 });
749 let (size_freed, shared_bytes, status) = match delete {
752 Ok(()) => (size, bd.shared_bytes, PruneStatus::Pruned),
753 Err(_) if !bd.path.exists() => (size, bd.shared_bytes, PruneStatus::Pruned),
754 Err(e) => {
755 let remaining = crate::adapters::dir_size(&bd.path);
760 let freed = size.saturating_sub(remaining);
761 let message = if freed > 0 {
762 format!(
763 "{e} — `{}` was partially deleted ({} of {} remains) and is no \
764 longer usable. Close whatever holds it open, then run `devp \
765 restore` to rebuild it.",
766 bd.path.display(),
767 crate::output::format_bytes(remaining),
768 crate::output::format_bytes(size)
769 )
770 } else {
771 e.to_string()
772 };
773 (freed, 0, PruneStatus::DeleteError(message))
774 }
775 };
776 PruneResult {
777 repo_path: repo_path.to_path_buf(),
778 adapter_name: adapter_name.to_string(),
779 bloat_dir: label,
780 size_freed,
781 shared_bytes,
782 runtime,
783 status,
784 }
785}
786
787fn prune_declarations(
793 repo_path: &Path,
794 config: Option<&crate::config::PerRepoConfig>,
795 opts: &PruneOptions,
796 min_size_bytes: u64,
797 only: Option<&[String]>,
798 claimed: &mut std::collections::HashSet<PathBuf>,
799) -> Vec<PruneResult> {
800 let name = constants::DECLARED_ADAPTER_NAME;
801 if !opts.adapters.allows(name) {
802 return Vec::new();
803 }
804 let Some(declared) = config.and_then(|c| c.prunable.as_ref()) else {
805 return Vec::new();
806 };
807
808 let mut results = Vec::new();
809 for outcome in crate::declared::resolve(repo_path, &declared.directories) {
810 let target = match outcome {
811 crate::declared::Declaration::Prunable(target) => target,
812 crate::declared::Declaration::Refused { label, reason } => {
816 results.push(PruneResult {
817 repo_path: repo_path.to_path_buf(),
818 adapter_name: name.to_string(),
819 bloat_dir: label,
820 size_freed: 0,
821 shared_bytes: 0,
822 runtime: None,
823 status: PruneStatus::SkippedDeclaration(reason),
824 });
825 continue;
826 }
827 };
828
829 if only.is_some_and(|names| !names.contains(&target.label)) {
830 continue;
831 }
832 if target.size_bytes < min_size_bytes {
833 continue;
834 }
835 if !claimed.insert(target.path.clone()) {
836 continue;
837 }
838
839 let bd = BloatDir {
840 name: target.label.clone(),
841 path: target.path.clone(),
842 size_bytes: target.size_bytes,
843 shared_bytes: 0,
844 };
845 if let Some(status) = shared_storage_refusal(&bd.path) {
846 results.push(PruneResult {
847 repo_path: repo_path.to_path_buf(),
848 adapter_name: name.to_string(),
849 bloat_dir: target.label,
850 size_freed: 0,
851 shared_bytes: 0,
852 runtime: None,
853 status,
854 });
855 continue;
856 }
857 if opts.dry_run {
858 results.push(PruneResult {
859 repo_path: repo_path.to_path_buf(),
860 adapter_name: name.to_string(),
861 bloat_dir: target.label,
862 size_freed: target.size_bytes,
863 shared_bytes: 0,
864 runtime: None,
865 status: PruneStatus::SkippedDryRun,
866 });
867 continue;
868 }
869
870 results.push(delete_bloat(
875 repo_path,
876 name,
877 target.label.clone(),
878 &bd,
879 Some(target.rebuild.clone()),
880 ));
881 }
882 results
883}
884
885#[cfg(unix)]
897fn is_mount_point(path: &Path) -> bool {
898 use std::os::unix::fs::MetadataExt;
899 let Some(parent) = path.parent() else {
900 return false;
901 };
902 match (fs::symlink_metadata(path), fs::symlink_metadata(parent)) {
903 (Ok(here), Ok(above)) => here.dev() != above.dev(),
904 _ => false,
906 }
907}
908
909#[cfg(not(unix))]
910fn is_mount_point(_path: &Path) -> bool {
911 false
912}
913
914fn find_nested_git(dir: &Path) -> Option<PathBuf> {
920 walkdir::WalkDir::new(dir)
921 .follow_links(false)
922 .into_iter()
923 .flatten()
924 .find(|e| e.file_name() == ".git")
925 .map(|e| e.into_path())
926}
927
928fn collect_bloat(
939 repo_path: &Path,
940 min_size_bytes: u64,
941 depth: usize,
942) -> (Vec<String>, Vec<BloatDir>, Vec<(String, u64)>) {
943 let mut adapter_names: Vec<String> = Vec::new();
944 let mut bloat: Vec<BloatDir> = Vec::new();
945 let mut by_adapter: BTreeMap<String, u64> = BTreeMap::new();
946 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
947
948 for project in workspace::discover_to_depth(repo_path, depth) {
949 for adapter in &project.adapters {
950 let name = adapter.name();
951 if !adapter_names.iter().any(|existing| existing == name) {
952 adapter_names.push(name.to_string());
953 }
954 for bd in adapter.bloat_dirs(&project.path) {
955 if bd.size_bytes < min_size_bytes {
956 continue;
957 }
958 if shared_storage_refusal(&bd.path).is_some() {
964 continue;
965 }
966 if claimed.insert(bd.path.clone()) {
967 *by_adapter.entry(name.to_string()).or_default() += bd.size_bytes;
968 bloat.push(BloatDir {
969 name: workspace::relative_label(repo_path, &bd.path),
970 ..bd
971 });
972 }
973 }
974 }
975 }
976
977 let declared = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
981 .ok()
982 .flatten()
983 .and_then(|c| c.prunable)
984 .map(|p| p.directories)
985 .unwrap_or_default();
986 for outcome in crate::declared::resolve(repo_path, &declared) {
987 let crate::declared::Declaration::Prunable(target) = outcome else {
988 continue;
989 };
990 if target.size_bytes < min_size_bytes
991 || shared_storage_refusal(&target.path).is_some()
992 || !claimed.insert(target.path.clone())
993 {
994 continue;
995 }
996 let name = constants::DECLARED_ADAPTER_NAME;
997 if !adapter_names.iter().any(|existing| existing == name) {
998 adapter_names.push(name.to_string());
999 }
1000 *by_adapter.entry(name.to_string()).or_default() += target.size_bytes;
1001 bloat.push(BloatDir {
1002 name: target.label,
1003 path: target.path,
1004 size_bytes: target.size_bytes,
1005 shared_bytes: 0,
1006 });
1007 }
1008
1009 (adapter_names, bloat, by_adapter.into_iter().collect())
1010}
1011
1012pub fn prune_all_with(registry: &mut Registry, opts: &PruneOptions) -> Vec<PruneResult> {
1017 let mut all_results = Vec::new();
1018
1019 let mut repos: Vec<(PathBuf, u64, bool)> = registry
1025 .repositories
1026 .iter()
1027 .map(|(path, entry)| {
1028 let idle_days = entry
1029 .override_idle_days
1030 .unwrap_or(registry.settings.idle_days);
1031 (path.clone(), idle_days, entry.enabled)
1032 })
1033 .collect();
1034 repos.sort_by(|a, b| a.0.cmp(&b.0));
1035
1036 for (path, idle_days, enabled) in repos {
1037 if !enabled {
1038 all_results.push(PruneResult {
1039 repo_path: path.clone(),
1040 adapter_name: "-".to_string(),
1041 bloat_dir: "-".to_string(),
1042 size_freed: 0,
1043 shared_bytes: 0,
1044 runtime: None,
1045 status: PruneStatus::Disabled,
1046 });
1047 continue;
1048 }
1049
1050 let results = prune_repo_with(
1051 &path,
1052 &PruneOptions {
1053 idle_days,
1054 ..opts.clone()
1055 },
1056 );
1057
1058 let path_freed: u64 = results
1059 .iter()
1060 .filter(|r| matches!(r.status, PruneStatus::Pruned))
1061 .map(|r| r.size_freed)
1062 .sum();
1063
1064 if path_freed > 0 {
1065 registry.mark_pruned(&path, path_freed);
1066 }
1067
1068 all_results.extend(results);
1069 }
1070
1071 all_results
1072}
1073
1074pub fn prune_all(registry: &mut Registry, dry_run: bool, force: bool) -> Vec<PruneResult> {
1076 prune_all_with(registry, &PruneOptions::new(0, dry_run, force))
1077}
1078
1079pub fn restore_project_to_depth(
1091 project_path: &Path,
1092 global_depth: usize,
1093 timeout: std::time::Duration,
1094) -> Result<Vec<(String, Result<()>)>> {
1095 let depth = workspace::resolve_depth(project_path, global_depth);
1096 let projects = workspace::discover_to_depth(project_path, depth);
1097
1098 if projects.is_empty() {
1099 anyhow::bail!(
1100 "No recognized package manager found in {}",
1101 project_path.display()
1102 );
1103 }
1104
1105 let mut results = Vec::new();
1106 for project in &projects {
1107 for adapter in &project.adapters {
1108 let label = if project.relative == "." {
1109 adapter.name().to_string()
1110 } else {
1111 format!("{} ({})", adapter.name(), project.relative)
1112 };
1113 results.push((label, adapter.restore(&project.path, timeout)));
1114 }
1115 }
1116
1117 Ok(results)
1118}
1119
1120fn owning_project(bloat_label: &str) -> &str {
1128 match bloat_label.rsplit_once('/') {
1129 Some((parent, _)) => parent,
1130 None => ".",
1131 }
1132}
1133
1134pub struct RestoreOutcome {
1153 pub label: String,
1155 pub adapter: String,
1157 pub bytes: u64,
1159 pub elapsed: std::time::Duration,
1161 pub result: Result<()>,
1163}
1164
1165pub fn restore_deleted(
1166 repo_path: &Path,
1167 deleted: &[crate::config::PrunedDir],
1168 global_depth: usize,
1169 timeout: std::time::Duration,
1170) -> Vec<RestoreOutcome> {
1171 let depth = workspace::resolve_depth(repo_path, global_depth);
1172 let projects = workspace::discover_to_depth(repo_path, depth);
1173
1174 let mut results = Vec::new();
1175 for dir in deleted {
1176 let (bloat_label, adapter_name) = (&dir.bloat_dir, &dir.adapter);
1177 let timed = |result: Result<()>, started: std::time::Instant| RestoreOutcome {
1180 label: format!("{adapter_name} ({bloat_label})"),
1181 adapter: adapter_name.clone(),
1182 bytes: dir.size_freed,
1183 elapsed: started.elapsed(),
1184 result,
1185 };
1186 let runtime = dir.runtime.as_deref();
1187 let wanted = owning_project(bloat_label);
1188 let dir_name = bloat_label
1191 .rsplit_once('/')
1192 .map_or(bloat_label.as_str(), |(_, name)| name);
1193
1194 let found = projects
1195 .iter()
1196 .filter(|p| p.relative == wanted)
1197 .flat_map(|p| p.adapters.iter().map(move |a| (p, a)))
1198 .find(|(_, a)| a.name() == adapter_name);
1199
1200 if let Some((project, adapter)) = found {
1201 let started = std::time::Instant::now();
1202 let result = adapter.restore_named(&project.path, dir_name, runtime, timeout);
1203 results.push(timed(result, started));
1204 continue;
1205 }
1206
1207 let project_dir = if wanted == "." {
1213 repo_path.to_path_buf()
1214 } else {
1215 repo_path.join(wanted)
1216 };
1217 let recorded = crate::adapters::get_all_adapters()
1218 .into_iter()
1219 .find(|a| a.name() == adapter_name);
1220 match recorded {
1221 Some(adapter) if project_dir.is_dir() => {
1222 let started = std::time::Instant::now();
1223 let result = adapter.restore_named(&project_dir, dir_name, runtime, timeout);
1224 results.push(timed(result, started));
1225 }
1226 _ => results.push(timed(
1227 Err(anyhow::anyhow!(
1228 "`{wanted}` in {} is no longer a {adapter_name} project — it may have been \
1229 moved or removed since the prune. Restore it by hand if it still exists.",
1230 repo_path.display()
1231 )),
1232 std::time::Instant::now(),
1233 )),
1234 }
1235 }
1236
1237 results
1238}
1239
1240#[derive(Debug, Clone, PartialEq)]
1242pub enum SkipReason {
1243 Candidate,
1245 Active,
1247 Ignored,
1250 NoBloat,
1252 PathMissing,
1254 ConfigError(String),
1256}
1257
1258impl std::fmt::Display for SkipReason {
1259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1260 match self {
1261 SkipReason::Candidate => write!(f, "Candidate"),
1262 SkipReason::Active => write!(f, "Active (not idle)"),
1263 SkipReason::Ignored => write!(f, "Ignored"),
1264 SkipReason::NoBloat => write!(f, "No bloat found"),
1265 SkipReason::PathMissing => write!(f, "Path missing"),
1266 SkipReason::ConfigError(_) => write!(f, "Unreadable .devprune.json"),
1267 }
1268 }
1269}
1270
1271#[derive(Debug, Clone)]
1273pub struct RepoStatusEntry {
1274 pub path: PathBuf,
1276 pub entry: RepoEntry,
1278 pub reason: SkipReason,
1280 pub adapters: Vec<String>,
1282 pub bloat_dirs: Vec<BloatDir>,
1284 pub reclaimable_bytes: u64,
1286 pub reclaimable_by_adapter: Vec<(String, u64)>,
1289 pub last_activity: Option<DateTime<Utc>>,
1291 pub idle_days: u64,
1293}
1294
1295fn status_for_repo(registry: &Registry, path: &Path, reg_entry: &RepoEntry) -> RepoStatusEntry {
1304 let registry_idle_days = reg_entry
1305 .override_idle_days
1306 .unwrap_or(registry.settings.idle_days);
1307
1308 if !path.exists() {
1311 return RepoStatusEntry {
1312 path: path.to_path_buf(),
1313 entry: reg_entry.clone(),
1314 reason: SkipReason::PathMissing,
1315 adapters: Vec::new(),
1316 bloat_dirs: Vec::new(),
1317 reclaimable_by_adapter: Vec::new(),
1318 reclaimable_bytes: 0,
1319 last_activity: None,
1320 idle_days: registry_idle_days,
1321 };
1322 }
1323
1324 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(path) {
1329 Ok(cfg) => cfg,
1330 Err(e) => {
1331 return RepoStatusEntry {
1332 path: path.to_path_buf(),
1333 entry: reg_entry.clone(),
1334 reason: SkipReason::ConfigError(e),
1335 adapters: Vec::new(),
1336 bloat_dirs: Vec::new(),
1337 reclaimable_by_adapter: Vec::new(),
1338 reclaimable_bytes: 0,
1339 last_activity: last_activity_time(path),
1340 idle_days: registry_idle_days,
1341 };
1342 }
1343 };
1344 let idle_days = per_repo_config
1345 .as_ref()
1346 .and_then(|c| c.override_idle_days)
1347 .unwrap_or(registry_idle_days);
1348
1349 let is_ignored = !reg_entry.enabled
1351 || path.join(constants::DEVPRUNE_IGNORE_FILE).exists()
1352 || per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false);
1353 if is_ignored {
1354 return RepoStatusEntry {
1355 path: path.to_path_buf(),
1356 entry: reg_entry.clone(),
1357 reason: SkipReason::Ignored,
1358 adapters: Vec::new(),
1359 bloat_dirs: Vec::new(),
1360 reclaimable_by_adapter: Vec::new(),
1361 reclaimable_bytes: 0,
1362 last_activity: last_activity_time(path),
1363 idle_days,
1364 };
1365 }
1366
1367 let activity = git::get_last_activity(path).ok().flatten();
1372 let activity_time = to_utc(activity);
1373 let is_idle = git::is_idle_at(activity, idle_days);
1374
1375 let min_size_bytes = per_repo_config
1377 .as_ref()
1378 .and_then(|c| c.min_size_mb)
1379 .unwrap_or(registry.settings.min_size_mb)
1380 .saturating_mul(BYTES_PER_MIB);
1381 let depth = workspace::clamp_depth(
1385 per_repo_config
1386 .as_ref()
1387 .and_then(|c| c.scan_depth)
1388 .unwrap_or(registry.settings.scan_depth),
1389 );
1390 let (adapter_names, all_bloat, by_adapter) = collect_bloat(path, min_size_bytes, depth);
1391 let reclaimable: u64 = all_bloat.iter().map(|b| b.size_bytes).sum();
1392
1393 let reason = if !is_idle {
1394 SkipReason::Active
1395 } else if all_bloat.is_empty() {
1396 SkipReason::NoBloat
1397 } else {
1398 SkipReason::Candidate
1399 };
1400
1401 RepoStatusEntry {
1402 path: path.to_path_buf(),
1403 entry: reg_entry.clone(),
1404 reason,
1405 adapters: adapter_names,
1406 bloat_dirs: all_bloat,
1407 reclaimable_bytes: reclaimable,
1408 reclaimable_by_adapter: by_adapter,
1409 last_activity: activity_time,
1410 idle_days,
1411 }
1412}
1413
1414fn scan_thread_count(total: usize) -> usize {
1431 let requested = std::env::var(constants::STATUS_SCAN_THREADS_ENV)
1432 .ok()
1433 .and_then(|v| v.trim().parse::<usize>().ok())
1434 .filter(|n| *n > 0)
1435 .unwrap_or_else(|| {
1436 std::thread::available_parallelism()
1437 .map(std::num::NonZeroUsize::get)
1438 .unwrap_or(4)
1439 .saturating_mul(constants::STATUS_SCAN_THREADS_PER_CORE)
1440 });
1441 clamp_scan_threads(requested, total)
1442}
1443
1444fn clamp_scan_threads(requested: usize, total: usize) -> usize {
1447 requested
1448 .clamp(1, constants::STATUS_SCAN_MAX_THREADS)
1449 .min(total.max(1))
1450}
1451
1452pub fn get_full_status(registry: &Registry) -> Vec<RepoStatusEntry> {
1453 get_full_status_reporting(registry, &|_done, _total| {})
1454}
1455
1456pub fn get_full_status_reporting(
1467 registry: &Registry,
1468 progress: &(dyn Fn(usize, usize) + Sync),
1469) -> Vec<RepoStatusEntry> {
1470 use std::sync::atomic::{AtomicUsize, Ordering};
1471
1472 let repos: Vec<(&PathBuf, &RepoEntry)> = registry.repositories.iter().collect();
1473 let total = repos.len();
1474 let workers = scan_thread_count(total);
1475
1476 let next = AtomicUsize::new(0);
1481 let done = AtomicUsize::new(0);
1482
1483 let take_work = || {
1484 let mut mine = Vec::new();
1485 loop {
1486 let i = next.fetch_add(1, Ordering::Relaxed);
1487 if i >= total {
1488 break;
1489 }
1490 let (path, reg_entry) = repos[i];
1491 mine.push(status_for_repo(registry, path, reg_entry));
1492 progress(done.fetch_add(1, Ordering::Relaxed) + 1, total);
1493 }
1494 mine
1495 };
1496
1497 let chunks: Vec<Vec<RepoStatusEntry>> = std::thread::scope(|scope| {
1498 let mut handles = Vec::with_capacity(workers.saturating_sub(1));
1506 for n in 1..workers {
1507 match std::thread::Builder::new()
1508 .name(format!("devp-scan-{n}"))
1509 .spawn_scoped(scope, take_work)
1510 {
1511 Ok(handle) => handles.push(handle),
1512 Err(_) => break,
1513 }
1514 }
1515
1516 let mut chunks = vec![take_work()];
1519 chunks.extend(
1520 handles
1521 .into_iter()
1522 .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e))),
1525 );
1526 chunks
1527 });
1528
1529 let mut entries: Vec<RepoStatusEntry> = chunks.into_iter().flatten().collect();
1530
1531 fn rank(reason: &SkipReason) -> u8 {
1536 match reason {
1537 SkipReason::Candidate => 0,
1538 SkipReason::PathMissing => 2,
1539 _ => 1,
1540 }
1541 }
1542 entries.sort_by(|a, b| {
1543 rank(&a.reason)
1544 .cmp(&rank(&b.reason))
1545 .then_with(|| a.path.cmp(&b.path))
1546 });
1547
1548 entries
1549}
1550
1551pub fn take_top(repos: &[RepoStatusEntry], top: Option<usize>) -> Vec<RepoStatusEntry> {
1560 let Some(n) = top else {
1561 return repos.to_vec();
1562 };
1563
1564 let mut ranked: Vec<usize> = (0..repos.len()).collect();
1565 ranked.sort_by_key(|&i| std::cmp::Reverse(repos[i].reclaimable_bytes));
1566 ranked.truncate(n);
1567 ranked.sort_unstable();
1568 ranked.into_iter().map(|i| repos[i].clone()).collect()
1569}
1570
1571pub fn compute_display_name(repo_path: &Path, all_paths: &[PathBuf]) -> String {
1576 if let Some(cfg) = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
1579 .ok()
1580 .flatten()
1581 && let Some(custom) = cfg.project_name
1582 && !custom.trim().is_empty()
1583 {
1584 return custom;
1585 }
1586
1587 let folder_name = repo_path
1588 .file_name()
1589 .map(|n| n.to_string_lossy().to_string())
1590 .unwrap_or_else(|| crate::output::clean_path(repo_path));
1591
1592 let duplicate_count = all_paths
1594 .iter()
1595 .filter(|p| {
1596 p.file_name()
1597 .map(|n| n.to_string_lossy().to_string())
1598 .as_deref()
1599 == Some(&folder_name)
1600 })
1601 .count();
1602
1603 if duplicate_count > 1
1604 && let Some(parent) = repo_path.parent()
1605 && let Some(parent_name) = parent.file_name()
1606 {
1607 return format!("{}/{}", parent_name.to_string_lossy(), folder_name);
1608 }
1609
1610 folder_name
1611}
1612
1613fn last_activity_time(path: &Path) -> Option<DateTime<Utc>> {
1616 to_utc(git::get_last_activity(path).ok().flatten())
1617}
1618
1619fn to_utc(system_time: Option<SystemTime>) -> Option<DateTime<Utc>> {
1621 system_time.map(|st| {
1622 let duration = st
1623 .duration_since(SystemTime::UNIX_EPOCH)
1624 .unwrap_or_default();
1625 DateTime::from_timestamp(duration.as_secs() as i64, 0).unwrap_or_default()
1626 })
1627}
1628
1629#[cfg(test)]
1630mod tests {
1631 use super::*;
1632 use std::fs;
1633 use std::process::Command;
1634 use tempfile::TempDir;
1635
1636 const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1639
1640 #[test]
1641 fn an_ordinary_directory_is_not_a_mount_point() {
1642 let tmp = TempDir::new().unwrap();
1645 let dir = tmp.path().join("node_modules");
1646 fs::create_dir_all(&dir).unwrap();
1647 assert!(!is_mount_point(&dir));
1648 }
1649
1650 #[test]
1651 fn a_filesystem_root_is_not_reported_as_a_mount_point() {
1652 let root = Path::new(std::path::MAIN_SEPARATOR_STR);
1655 assert!(!is_mount_point(root));
1656 }
1657
1658 fn create_git_repo_with_commit(path: &Path) {
1659 fs::create_dir_all(path).unwrap();
1660 Command::new("git")
1661 .args(["init"])
1662 .current_dir(path)
1663 .output()
1664 .unwrap();
1665 fs::write(path.join("README.md"), "# Test").unwrap();
1666 Command::new("git")
1667 .args(["add", "."])
1668 .current_dir(path)
1669 .output()
1670 .unwrap();
1671 Command::new("git")
1672 .args([
1673 "-c",
1674 "user.name=Test",
1675 "-c",
1676 "user.email=test@test.com",
1677 "commit",
1678 "-m",
1679 "initial",
1680 ])
1681 .current_dir(path)
1682 .output()
1683 .unwrap();
1684 }
1685
1686 #[test]
1687 fn a_bloat_label_names_the_project_that_owns_it() {
1688 assert_eq!(owning_project("node_modules"), ".");
1689 assert_eq!(owning_project("frontend/node_modules"), "frontend");
1690 assert_eq!(
1691 owning_project("packages/@scope/app/.venv"),
1692 "packages/@scope/app"
1693 );
1694 }
1695
1696 #[test]
1697 fn restore_deleted_touches_only_the_projects_that_were_pruned() {
1698 let tmp = TempDir::new().unwrap();
1701 let root = tmp.path();
1702 for name in ["frontend", "docs"] {
1703 let dir = root.join(name);
1704 fs::create_dir_all(&dir).unwrap();
1705 fs::write(dir.join("package.json"), "{}").unwrap();
1706 fs::write(dir.join("package-lock.json"), "{}").unwrap();
1707 }
1708
1709 let deleted = vec![crate::config::PrunedDir {
1710 repo_path: root.to_path_buf(),
1711 bloat_dir: "frontend/node_modules".to_string(),
1712 adapter: "npm".to_string(),
1713 size_freed: 0,
1714 runtime: None,
1715 }];
1716 let results = restore_deleted(root, &deleted, 4, TEST_TIMEOUT);
1717
1718 assert_eq!(results.len(), 1, "one recorded directory, one attempt");
1719 assert_eq!(results[0].label, "npm (frontend/node_modules)");
1720 }
1721
1722 #[test]
1723 fn restore_deleted_reports_a_project_that_is_no_longer_there() {
1724 let tmp = TempDir::new().unwrap();
1727 let deleted = vec![crate::config::PrunedDir {
1728 repo_path: tmp.path().to_path_buf(),
1729 bloat_dir: "services/api/.venv".to_string(),
1730 adapter: "uv".to_string(),
1731 size_freed: 0,
1732 runtime: None,
1733 }];
1734 let results = restore_deleted(tmp.path(), &deleted, 4, TEST_TIMEOUT);
1735
1736 assert_eq!(results.len(), 1);
1737 assert_eq!(results[0].label, "uv (services/api/.venv)");
1738 let err = results[0].result.as_ref().unwrap_err().to_string();
1739 assert!(err.contains("services/api"), "names the missing project");
1740 assert!(err.contains("uv"), "names the adapter that owned it");
1741 }
1742
1743 #[test]
1744 fn test_prune_status_display() {
1745 assert_eq!(PruneStatus::Pruned.to_string(), "Pruned");
1746 assert_eq!(PruneStatus::SkippedActive.to_string(), "Skipped (active)");
1747 assert_eq!(PruneStatus::SkippedDryRun.to_string(), "Skipped (dry run)");
1748 }
1749
1750 #[test]
1751 fn test_prune_repo_non_git() {
1752 let tmp = TempDir::new().unwrap();
1755 let results = prune_repo(tmp.path(), 15, false, false);
1756 assert_eq!(results.len(), 1);
1757 assert!(matches!(
1758 results[0].status,
1759 PruneStatus::ActivityCheckError(_)
1760 ));
1761 }
1762
1763 #[test]
1764 fn test_prune_repo_active_skipped() {
1765 let tmp = TempDir::new().unwrap();
1766 let repo = tmp.path().join("repo");
1767 create_git_repo_with_commit(&repo);
1768 let results = prune_repo(&repo, 15, false, false);
1770 assert_eq!(results.len(), 1);
1771 assert!(matches!(results[0].status, PruneStatus::SkippedActive));
1772 }
1773
1774 #[test]
1777 fn test_unparseable_per_repo_config_skips_the_repo() {
1778 let tmp = TempDir::new().unwrap();
1779 let repo = tmp.path().join("repo");
1780 create_git_repo_with_commit(&repo);
1781 fs::create_dir(repo.join("target")).unwrap();
1782 fs::write(repo.join("target").join("dummy"), "data").unwrap();
1783 fs::write(
1784 repo.join("Cargo.toml"),
1785 "[package]\nname = \"t\"\nversion = \"0.1.0\"",
1786 )
1787 .unwrap();
1788 fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1789 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1791
1792 let results = prune_repo(&repo, 15, false, true);
1794
1795 assert_eq!(results.len(), 1);
1796 assert!(
1797 matches!(results[0].status, PruneStatus::ConfigError(_)),
1798 "expected ConfigError, got {:?}",
1799 results[0].status
1800 );
1801 assert!(repo.join("target").exists(), "target must survive");
1802 }
1803
1804 #[test]
1808 fn a_broken_config_is_reported_by_status_and_not_as_a_candidate() {
1809 let tmp = TempDir::new().unwrap();
1810 let repo = tmp.path().join("repo");
1811 create_git_repo_with_commit(&repo);
1812 create_python_project(&repo);
1813 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1814
1815 let mut registry = Registry::default();
1816 registry.add_repo(repo.clone());
1817
1818 let entries = get_full_status(®istry);
1819 assert_eq!(entries.len(), 1);
1820 assert!(
1821 matches!(entries[0].reason, SkipReason::ConfigError(_)),
1822 "expected ConfigError, got {:?}",
1823 entries[0].reason
1824 );
1825 assert_eq!(entries[0].reclaimable_bytes, 0);
1826 }
1827
1828 #[test]
1829 fn test_prune_repo_dry_run() {
1830 let tmp = TempDir::new().unwrap();
1831 let repo = tmp.path().join("repo");
1832 create_git_repo_with_commit(&repo);
1833 create_go_project(&repo);
1836 let results = prune_repo(&repo, 15, true, true);
1838 let dry_run_results: Vec<_> = results
1839 .iter()
1840 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
1841 .collect();
1842 assert!(!dry_run_results.is_empty());
1843 assert!(repo.join("vendor").exists());
1845 }
1846
1847 fn create_python_project(dir: &Path) {
1852 fs::create_dir_all(dir).unwrap();
1853 fs::write(dir.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1854 let venv = dir.join(".venv");
1855 fs::create_dir_all(&venv).unwrap();
1856 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1857 fs::write(venv.join("payload.bin"), vec![0u8; 4096]).unwrap();
1858 }
1859
1860 fn create_go_project(dir: &Path) {
1865 fs::create_dir_all(dir).unwrap();
1866 fs::write(dir.join("go.mod"), "module example.com/x\n\ngo 1.22\n").unwrap();
1867 fs::write(dir.join("go.sum"), "").unwrap();
1868 let vendor = dir.join("vendor");
1869 fs::create_dir_all(&vendor).unwrap();
1870 fs::write(vendor.join("modules.txt"), "# example.com/dep v1.0.0\n").unwrap();
1871 fs::write(vendor.join("payload.bin"), vec![0u8; 4096]).unwrap();
1872 }
1873
1874 fn labels(results: &[PruneResult]) -> Vec<String> {
1876 let mut out: Vec<String> = results.iter().map(|r| r.bloat_dir.clone()).collect();
1877 out.sort();
1878 out
1879 }
1880
1881 #[test]
1882 fn test_prune_finds_several_ecosystems_at_the_repo_root() {
1883 let tmp = TempDir::new().unwrap();
1884 let repo = tmp.path().join("repo");
1885 create_git_repo_with_commit(&repo);
1886
1887 create_go_project(&repo);
1888 fs::write(repo.join("package.json"), "{}").unwrap();
1889 fs::write(repo.join("package-lock.json"), "{}").unwrap();
1890 fs::create_dir(repo.join("node_modules")).unwrap();
1891 create_python_project(&repo);
1892
1893 let results = prune_repo(&repo, 15, true, true);
1894 assert_eq!(labels(&results), vec![".venv", "node_modules", "vendor"]);
1895 }
1896
1897 #[test]
1898 fn test_prune_finds_ecosystems_at_different_depths() {
1899 let tmp = TempDir::new().unwrap();
1900 let repo = tmp.path().join("repo");
1901 create_git_repo_with_commit(&repo);
1902
1903 fs::create_dir_all(repo.join("frontend")).unwrap();
1904 fs::write(repo.join("frontend/package.json"), "{}").unwrap();
1905 fs::write(repo.join("frontend/pnpm-lock.yaml"), "").unwrap();
1906 fs::create_dir(repo.join("frontend/node_modules")).unwrap();
1907
1908 create_go_project(&repo.join("tools/cli"));
1909
1910 create_python_project(&repo.join("services/api"));
1911
1912 let results = prune_repo(&repo, 15, true, true);
1913 assert_eq!(
1914 labels(&results),
1915 vec![
1916 "frontend/node_modules",
1917 "services/api/.venv",
1918 "tools/cli/vendor",
1919 ]
1920 );
1921 }
1922
1923 #[test]
1924 fn test_prune_deletes_only_the_selected_nested_directory() {
1925 let tmp = TempDir::new().unwrap();
1926 let repo = tmp.path().join("repo");
1927 create_git_repo_with_commit(&repo);
1928 create_python_project(&repo.join("a"));
1929 create_python_project(&repo.join("b"));
1930
1931 let results = prune_repo_selected(&repo, 0, false, true, Some(&["a/.venv".to_string()]));
1932
1933 assert_eq!(labels(&results), vec!["a/.venv"]);
1934 assert!(matches!(results[0].status, PruneStatus::Pruned));
1935 assert!(!repo.join("a/.venv").exists());
1936 assert!(repo.join("b/.venv").exists());
1937 }
1938
1939 #[test]
1940 fn test_prune_ignores_bloat_inside_a_nested_repository() {
1941 let tmp = TempDir::new().unwrap();
1942 let repo = tmp.path().join("repo");
1943 create_git_repo_with_commit(&repo);
1944 create_python_project(&repo.join("outer"));
1945
1946 let nested = repo.join("nested");
1949 create_git_repo_with_commit(&nested);
1950 create_python_project(&nested);
1951
1952 let results = prune_repo(&repo, 15, true, true);
1953 assert_eq!(labels(&results), vec!["outer/.venv"]);
1954 }
1955
1956 #[test]
1957 fn test_prune_repo_no_adapters() {
1958 let tmp = TempDir::new().unwrap();
1959 let repo = tmp.path().join("repo");
1960 create_git_repo_with_commit(&repo);
1961 let results = prune_repo(&repo, 15, false, true);
1963 assert!(
1964 results
1965 .iter()
1966 .any(|r| matches!(r.status, PruneStatus::NoBloat))
1967 );
1968 }
1969
1970 #[test]
1971 fn test_prune_all_disabled() {
1972 let tmp = TempDir::new().unwrap();
1973 let _registry_path = tmp.path().join("registry.json");
1974
1975 let mut registry = Registry::default();
1976 let repo_path = PathBuf::from("/nonexistent/repo");
1977 registry.add_repo(repo_path.clone());
1978 registry.repositories.get_mut(&repo_path).unwrap().enabled = false;
1979
1980 let results = prune_all(&mut registry, false, false);
1981 assert!(
1982 results
1983 .iter()
1984 .any(|r| matches!(r.status, PruneStatus::Disabled))
1985 );
1986 }
1987
1988 #[test]
1989 fn test_restore_project_no_adapters() {
1990 let tmp = TempDir::new().unwrap();
1991 let result = restore_project_to_depth(
1992 tmp.path(),
1993 crate::constants::DEFAULT_SCAN_DEPTH,
1994 TEST_TIMEOUT,
1995 );
1996 assert!(result.is_err());
1997 }
1998
1999 #[test]
2000 fn restore_deleted_trusts_the_record_when_the_prune_erased_detection() {
2001 let tmp = TempDir::new().unwrap();
2005 let api = tmp.path().join("api");
2006 fs::create_dir_all(&api).unwrap();
2007 fs::write(api.join("requirements.txt"), "requests==2.32.3\n").unwrap();
2008 let deleted = vec![crate::config::PrunedDir {
2011 repo_path: tmp.path().to_path_buf(),
2012 bloat_dir: "api/.venv".to_string(),
2013 adapter: "venv".to_string(),
2014 size_freed: 0,
2015 runtime: None,
2016 }];
2017 let results = restore_deleted(tmp.path(), &deleted, 4, std::time::Duration::ZERO);
2020
2021 assert_eq!(results.len(), 1);
2022 assert_eq!(results[0].label, "venv (api/.venv)");
2023 if let Err(e) = &results[0].result {
2024 assert!(
2025 !e.to_string().contains("no longer a"),
2026 "the recorded adapter must be attempted, got: {e}"
2027 );
2028 }
2029 }
2030
2031 #[test]
2032 fn a_git_repository_inside_a_bloat_directory_refuses_the_delete() {
2033 let tmp = TempDir::new().unwrap();
2036 let repo = tmp.path().join("repo");
2037 create_git_repo_with_commit(&repo);
2038 create_python_project(&repo);
2039 fs::create_dir_all(repo.join(".venv/src/vendored/.git")).unwrap();
2040
2041 let results = prune_repo_selected(&repo, 0, false, true, Some(&[".venv".to_string()]));
2042
2043 assert_eq!(results.len(), 1);
2044 let PruneStatus::DeleteError(msg) = &results[0].status else {
2045 panic!("expected a refusal, got {:?}", results[0].status);
2046 };
2047 assert!(msg.contains("git repository"), "says why: {msg}");
2048 assert!(repo.join(".venv").exists(), "nothing may be deleted");
2049 assert_eq!(results[0].size_freed, 0);
2050 }
2051
2052 fn status_entry(name: &str, reclaimable: u64) -> RepoStatusEntry {
2053 RepoStatusEntry {
2054 path: PathBuf::from(name),
2055 entry: RepoEntry::new(),
2056 reason: SkipReason::Candidate,
2057 adapters: Vec::new(),
2058 bloat_dirs: Vec::new(),
2059 reclaimable_by_adapter: Vec::new(),
2060 reclaimable_bytes: reclaimable,
2061 last_activity: None,
2062 idle_days: 15,
2063 }
2064 }
2065
2066 #[test]
2067 fn take_top_selects_by_size_but_keeps_the_dashboard_order() {
2068 let repos = [
2069 status_entry("small", 10),
2070 status_entry("big", 300),
2071 status_entry("mid", 200),
2072 ];
2073 let names: Vec<String> = take_top(&repos, Some(2))
2074 .iter()
2075 .map(|e| e.path.display().to_string())
2076 .collect();
2077 assert_eq!(names, vec!["big", "mid"]);
2081 }
2082
2083 #[test]
2084 fn take_top_without_a_limit_or_with_an_oversized_one_returns_everything() {
2085 let repos = [status_entry("a", 1), status_entry("b", 2)];
2086 assert_eq!(take_top(&repos, None).len(), 2);
2087 assert_eq!(take_top(&repos, Some(10)).len(), 2);
2088 assert_eq!(take_top(&repos, Some(0)).len(), 0);
2089 }
2090
2091 #[test]
2092 fn test_restore_project_with_npm() {
2093 let tmp = TempDir::new().unwrap();
2094 fs::write(tmp.path().join("package.json"), "{}").unwrap();
2095 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
2096 let results = restore_project_to_depth(
2097 tmp.path(),
2098 crate::constants::DEFAULT_SCAN_DEPTH,
2099 TEST_TIMEOUT,
2100 );
2101 assert!(results.is_ok());
2103 let results = results.unwrap();
2104 assert!(!results.is_empty());
2105 assert_eq!(results[0].0, "npm");
2106 }
2107
2108 #[test]
2109 fn the_scan_never_starts_more_threads_than_there_is_work() {
2110 assert_eq!(clamp_scan_threads(32, 3), 3);
2112 assert_eq!(clamp_scan_threads(0, 0), 1);
2115 assert_eq!(clamp_scan_threads(0, 50), 1);
2116 }
2117
2118 #[test]
2119 fn an_absurd_thread_request_is_clamped_rather_than_honoured() {
2120 assert_eq!(
2122 clamp_scan_threads(9_999, 500),
2123 constants::STATUS_SCAN_MAX_THREADS
2124 );
2125 }
2126
2127 #[test]
2128 fn a_registry_of_one_repository_is_scanned_on_the_calling_thread_alone() {
2129 assert_eq!(clamp_scan_threads(16, 1), 1);
2130 }
2131}