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}
65
66impl std::fmt::Display for PruneStatus {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 match self {
69 PruneStatus::Pruned => write!(f, "Pruned"),
70 PruneStatus::SkippedActive => write!(f, "Skipped (active)"),
71 PruneStatus::SkippedDryRun => write!(f, "Skipped (dry run)"),
72 PruneStatus::LockfileError(e) => write!(f, "Lockfile error: {e}"),
73 PruneStatus::ActivityCheckError(e) => write!(f, "Activity check failed: {e}"),
74 PruneStatus::PathMissing => {
75 write!(
76 f,
77 "Path no longer exists (`devp unlink --missing` clears it)"
78 )
79 }
80 PruneStatus::NoBloat => write!(f, "No bloat found"),
81 PruneStatus::Disabled => write!(f, "Disabled"),
82 PruneStatus::SkippedIgnored => write!(
83 f,
84 "Ignored (ignore.devprune.json or ignore config in .devprune.json)"
85 ),
86 PruneStatus::DeleteError(e) => write!(f, "Delete error: {e}"),
87 PruneStatus::SkippedSymlink(e) => write!(f, "Skipped (symlink): {e}"),
88 PruneStatus::ConfigError(e) => write!(f, "Unreadable .devprune.json: {e}"),
89 }
90 }
91}
92
93pub const BYTES_PER_MIB: u64 = 1024 * 1024;
96
97#[derive(Debug, Clone, Default, PartialEq)]
104pub struct AdapterFilter {
105 only: Option<Vec<String>>,
106 skip: Vec<String>,
107}
108
109impl AdapterFilter {
110 pub fn new(only: Option<&str>, skip: Option<&str>) -> Result<Self> {
115 let known: Vec<&'static str> = crate::adapters::get_all_adapters()
116 .iter()
117 .map(|a| a.name())
118 .collect();
119
120 let parse = |raw: &str, flag: &str| -> Result<Vec<String>> {
121 let mut out = Vec::new();
122 for token in raw.split(',') {
123 let name = token.trim().to_lowercase();
124 if name.is_empty() {
125 continue;
126 }
127 if !known.contains(&name.as_str()) {
128 anyhow::bail!(
129 "`--{flag} {name}` names no known package manager. Available: {}.",
130 known.join(", ")
131 );
132 }
133 if !out.contains(&name) {
134 out.push(name);
135 }
136 }
137 if out.is_empty() {
138 anyhow::bail!("`--{flag}` was given no adapter names.");
139 }
140 Ok(out)
141 };
142
143 let only = only.map(|raw| parse(raw, "only")).transpose()?;
144 let skip = skip
145 .map(|raw| parse(raw, "skip"))
146 .transpose()?
147 .unwrap_or_default();
148
149 if let Some(only) = &only
150 && let Some(clash) = only.iter().find(|n| skip.contains(n))
151 {
152 anyhow::bail!("`{clash}` is in both --only and --skip; pick one.");
153 }
154
155 Ok(Self { only, skip })
156 }
157
158 pub fn allows(&self, name: &str) -> bool {
160 if self.skip.iter().any(|s| s == name) {
161 return false;
162 }
163 match &self.only {
164 Some(only) => only.iter().any(|o| o == name),
165 None => true,
166 }
167 }
168
169 pub fn is_unrestricted(&self) -> bool {
171 self.only.is_none() && self.skip.is_empty()
172 }
173
174 pub fn describe(&self) -> Option<String> {
176 if self.is_unrestricted() {
177 return None;
178 }
179 let mut parts = Vec::new();
180 if let Some(only) = &self.only {
181 parts.push(format!("only {}", only.join(", ")));
182 }
183 if !self.skip.is_empty() {
184 parts.push(format!("skipping {}", self.skip.join(", ")));
185 }
186 Some(parts.join("; "))
187 }
188}
189
190#[derive(Debug, Clone)]
196pub struct PruneOptions {
197 pub idle_days: u64,
199 pub dry_run: bool,
201 pub force: bool,
203 pub only_dirs: Option<Vec<String>>,
209 pub adapters: AdapterFilter,
211 pub min_size_bytes: u64,
213 pub scan_depth: usize,
218 pub allow_manifest_rewrite: bool,
220 pub command_timeout_secs: u64,
225 pub build_idle_days: u64,
231 pub adapter_idle_days: BTreeMap<String, u64>,
236}
237
238impl Default for PruneOptions {
239 fn default() -> Self {
240 Self {
241 idle_days: 0,
242 dry_run: false,
243 force: false,
244 only_dirs: None,
245 adapters: AdapterFilter::default(),
246 min_size_bytes: 0,
247 scan_depth: crate::constants::DEFAULT_SCAN_DEPTH,
248 allow_manifest_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
249 command_timeout_secs: crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS,
250 build_idle_days: crate::constants::DEFAULT_BUILD_IDLE_DAYS,
251 adapter_idle_days: BTreeMap::new(),
252 }
253 }
254}
255
256impl PruneOptions {
257 fn idle_threshold_for(&self, name: &str, opt_in: bool, base: u64) -> u64 {
271 let mut days = base;
272 if opt_in {
273 days = days.max(self.build_idle_days);
274 }
275 if let Some(&explicit) = self.adapter_idle_days.get(name) {
276 days = days.max(explicit);
277 }
278 days
279 }
280
281 pub fn new(idle_days: u64, dry_run: bool, force: bool) -> Self {
283 Self {
284 idle_days,
285 dry_run,
286 force,
287 ..Self::default()
288 }
289 }
290}
291
292#[derive(Debug, Clone)]
294pub struct PruneResult {
295 pub repo_path: PathBuf,
297 pub adapter_name: String,
299 pub bloat_dir: String,
301 pub size_freed: u64,
303 pub shared_bytes: u64,
307 pub runtime: Option<String>,
310 pub status: PruneStatus,
312}
313
314impl PruneResult {
315 pub fn project_dir(&self) -> PathBuf {
323 self.repo_path
324 .join(&self.bloat_dir)
325 .parent()
326 .map(Path::to_path_buf)
327 .unwrap_or_else(|| self.repo_path.clone())
328 }
329}
330
331pub fn prune_repo(
338 repo_path: &Path,
339 idle_days: u64,
340 dry_run: bool,
341 force: bool,
342) -> Vec<PruneResult> {
343 prune_repo_with(repo_path, &PruneOptions::new(idle_days, dry_run, force))
344}
345
346pub fn prune_repo_selected(
355 repo_path: &Path,
356 idle_days: u64,
357 dry_run: bool,
358 force: bool,
359 only: Option<&[String]>,
360) -> Vec<PruneResult> {
361 prune_repo_with(
362 repo_path,
363 &PruneOptions {
364 only_dirs: only.map(<[String]>::to_vec),
365 ..PruneOptions::new(idle_days, dry_run, force)
366 },
367 )
368}
369
370pub fn prune_repo_with(repo_path: &Path, opts: &PruneOptions) -> Vec<PruneResult> {
375 let idle_days = opts.idle_days;
376 let dry_run = opts.dry_run;
377 let force = opts.force;
378 let only = opts.only_dirs.as_deref();
379 let mut results = Vec::new();
380
381 if !repo_path.exists() {
385 results.push(PruneResult {
386 repo_path: repo_path.to_path_buf(),
387 adapter_name: "-".to_string(),
388 bloat_dir: "-".to_string(),
389 size_freed: 0,
390 shared_bytes: 0,
391 runtime: None,
392 status: PruneStatus::PathMissing,
393 });
394 return results;
395 }
396
397 if !scanner::is_git_repo(repo_path) {
401 results.push(PruneResult {
402 repo_path: repo_path.to_path_buf(),
403 adapter_name: "-".to_string(),
404 bloat_dir: "-".to_string(),
405 size_freed: 0,
406 shared_bytes: 0,
407 runtime: None,
408 status: PruneStatus::ActivityCheckError(format!(
409 "`{}` is no longer a git repository — nothing was touched. \
410 `devp unlink` removes it from the registry.",
411 repo_path.display()
412 )),
413 });
414 return results;
415 }
416
417 if repo_path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
419 results.push(PruneResult {
420 repo_path: repo_path.to_path_buf(),
421 adapter_name: "-".to_string(),
422 bloat_dir: "-".to_string(),
423 size_freed: 0,
424 shared_bytes: 0,
425 runtime: None,
426 status: PruneStatus::SkippedIgnored,
427 });
428 return results;
429 }
430
431 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(repo_path) {
435 Ok(cfg) => cfg,
436 Err(e) => {
437 results.push(PruneResult {
438 repo_path: repo_path.to_path_buf(),
439 adapter_name: "-".to_string(),
440 bloat_dir: "-".to_string(),
441 size_freed: 0,
442 shared_bytes: 0,
443 runtime: None,
444 status: PruneStatus::ConfigError(e),
445 });
446 return results;
447 }
448 };
449 if per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false) {
450 results.push(PruneResult {
451 repo_path: repo_path.to_path_buf(),
452 adapter_name: "-".to_string(),
453 bloat_dir: "-".to_string(),
454 size_freed: 0,
455 shared_bytes: 0,
456 runtime: None,
457 status: PruneStatus::SkippedIgnored,
458 });
459 return results;
460 }
461
462 let effective_idle_days = per_repo_config
464 .as_ref()
465 .and_then(|c| c.override_idle_days)
466 .unwrap_or(idle_days);
467
468 let min_size_bytes = if only.is_some() {
471 0
472 } else {
473 per_repo_config
474 .as_ref()
475 .and_then(|c| c.min_size_mb)
476 .map(|mb| mb.saturating_mul(BYTES_PER_MIB))
477 .unwrap_or(opts.min_size_bytes)
478 };
479
480 if !force {
482 match git::is_repo_idle(repo_path, effective_idle_days) {
483 Ok(false) => {
484 results.push(PruneResult {
485 repo_path: repo_path.to_path_buf(),
486 adapter_name: "-".to_string(),
487 bloat_dir: "-".to_string(),
488 size_freed: 0,
489 shared_bytes: 0,
490 runtime: None,
491 status: PruneStatus::SkippedActive,
492 });
493 return results;
494 }
495 Ok(true) => {} Err(e) => {
497 results.push(PruneResult {
498 repo_path: repo_path.to_path_buf(),
499 adapter_name: "-".to_string(),
500 bloat_dir: "-".to_string(),
501 size_freed: 0,
502 shared_bytes: 0,
503 runtime: None,
504 status: PruneStatus::ActivityCheckError(e.to_string()),
505 });
506 return results;
507 }
508 }
509 }
510
511 let projects = workspace::discover_to_depth(
515 repo_path,
516 workspace::resolve_depth(repo_path, opts.scan_depth),
517 );
518
519 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
523
524 let mut idle_at: BTreeMap<u64, bool> = BTreeMap::new();
529
530 for project in &projects {
531 for adapter in &project.adapters {
532 if !opts.adapters.allows(adapter.name()) {
533 continue;
534 }
535
536 let threshold =
540 opts.idle_threshold_for(adapter.name(), adapter.opt_in(), effective_idle_days);
541 if threshold > effective_idle_days && !force {
542 let idle_enough = *idle_at
543 .entry(threshold)
544 .or_insert_with(|| git::is_repo_idle(repo_path, threshold).unwrap_or(false));
545 if !idle_enough {
546 continue;
547 }
548 }
549
550 let bloat_dirs: Vec<(String, BloatDir)> = adapter
557 .bloat_dirs(&project.path)
558 .into_iter()
559 .map(|bd| (workspace::relative_label(repo_path, &bd.path), bd))
560 .filter(|(label, _)| only.is_none_or(|names| names.contains(label)))
561 .filter(|(_, bd)| bd.size_bytes >= min_size_bytes)
562 .filter(|(_, bd)| claimed.insert(bd.path.clone()))
563 .collect();
564
565 if bloat_dirs.is_empty() {
566 continue;
567 }
568
569 let mut deletable: Vec<(String, BloatDir)> = Vec::new();
578 for (label, bd) in bloat_dirs {
579 if fs::symlink_metadata(&bd.path)
584 .map(|m| m.file_type().is_symlink())
585 .unwrap_or(false)
586 {
587 results.push(PruneResult {
588 repo_path: repo_path.to_path_buf(),
589 adapter_name: adapter.name().to_string(),
590 bloat_dir: label,
591 size_freed: 0,
592 shared_bytes: 0,
593 runtime: None,
594 status: PruneStatus::SkippedSymlink(format!(
595 "`{}` is a symlink to storage dev-prune does not own — \
596 left alone. Remove the link yourself if you really want \
597 it gone.",
598 bd.path.display()
599 )),
600 });
601 continue;
602 }
603
604 if is_mount_point(&bd.path) {
609 results.push(PruneResult {
610 repo_path: repo_path.to_path_buf(),
611 adapter_name: adapter.name().to_string(),
612 bloat_dir: label,
613 size_freed: 0,
614 shared_bytes: 0,
615 runtime: None,
616 status: PruneStatus::SkippedSymlink(format!(
617 "`{}` is a mount point — it is on a different filesystem \
618 than the repository around it, so its contents are shared \
619 with whatever mounted it. Left alone.",
620 bd.path.display()
621 )),
622 });
623 continue;
624 }
625
626 if let Some(nested) = find_nested_git(&bd.path) {
631 results.push(PruneResult {
632 repo_path: repo_path.to_path_buf(),
633 adapter_name: adapter.name().to_string(),
634 bloat_dir: label,
635 size_freed: 0,
636 shared_bytes: 0,
637 runtime: None,
638 status: PruneStatus::DeleteError(format!(
639 "`{}` contains a git repository at `{}` — refusing to \
640 delete it. Move or remove that checkout yourself if it \
641 holds nothing you need.",
642 bd.path.display(),
643 nested.display()
644 )),
645 });
646 continue;
647 }
648
649 deletable.push((label, bd));
650 }
651
652 if deletable.is_empty() {
653 continue;
654 }
655
656 if !dry_run {
658 let policy = crate::adapters::EnforcePolicy {
659 allow_rewrite: opts.allow_manifest_rewrite,
660 timeout: std::time::Duration::from_secs(opts.command_timeout_secs),
661 };
662 if let Err(e) = adapter.enforce_lockfile(&project.path, policy) {
663 for (label, _) in &deletable {
664 results.push(PruneResult {
665 repo_path: repo_path.to_path_buf(),
666 adapter_name: adapter.name().to_string(),
667 bloat_dir: label.clone(),
668 size_freed: 0,
669 shared_bytes: 0,
670 runtime: None,
671 status: PruneStatus::LockfileError(e.to_string()),
672 });
673 }
674 continue;
675 }
676 }
677
678 for (label, bd) in deletable {
679 if dry_run {
680 results.push(PruneResult {
681 repo_path: repo_path.to_path_buf(),
682 adapter_name: adapter.name().to_string(),
683 bloat_dir: label,
684 size_freed: bd.size_bytes,
685 shared_bytes: bd.shared_bytes,
686 runtime: None,
687 status: PruneStatus::SkippedDryRun,
688 });
689 continue;
690 }
691
692 let size = bd.size_bytes;
693 let runtime = adapter.runtime_tag(&project.path, &bd.name);
697 let delete = fs::remove_dir_all(&bd.path).or_else(|_| {
704 std::thread::sleep(std::time::Duration::from_millis(250));
705 fs::remove_dir_all(&bd.path)
706 });
707 match delete {
708 Ok(()) => {
711 results.push(PruneResult {
712 repo_path: repo_path.to_path_buf(),
713 adapter_name: adapter.name().to_string(),
714 bloat_dir: label,
715 size_freed: size,
716 shared_bytes: bd.shared_bytes,
717 runtime: runtime.clone(),
718 status: PruneStatus::Pruned,
719 });
720 }
721 Err(_) if !bd.path.exists() => {
722 results.push(PruneResult {
723 repo_path: repo_path.to_path_buf(),
724 adapter_name: adapter.name().to_string(),
725 bloat_dir: label,
726 size_freed: size,
727 shared_bytes: bd.shared_bytes,
728 runtime: runtime.clone(),
729 status: PruneStatus::Pruned,
730 });
731 }
732 Err(e) => {
733 let remaining = crate::adapters::dir_size(&bd.path);
739 let freed = size.saturating_sub(remaining);
740 let message = if freed > 0 {
741 format!(
742 "{e} — `{}` was partially deleted ({} of {} remains) \
743 and is no longer usable. Close whatever holds it open, \
744 then run `devp restore` to rebuild it.",
745 bd.path.display(),
746 crate::output::format_bytes(remaining),
747 crate::output::format_bytes(size)
748 )
749 } else {
750 e.to_string()
751 };
752 results.push(PruneResult {
753 repo_path: repo_path.to_path_buf(),
754 adapter_name: adapter.name().to_string(),
755 bloat_dir: label,
756 size_freed: freed,
757 shared_bytes: 0,
758 runtime,
759 status: PruneStatus::DeleteError(message),
760 });
761 }
762 }
763 }
764 }
765 }
766
767 if results.is_empty() {
769 results.push(PruneResult {
770 repo_path: repo_path.to_path_buf(),
771 adapter_name: "-".to_string(),
772 bloat_dir: "-".to_string(),
773 size_freed: 0,
774 shared_bytes: 0,
775 runtime: None,
776 status: PruneStatus::NoBloat,
777 });
778 }
779
780 results
781}
782
783#[cfg(unix)]
795fn is_mount_point(path: &Path) -> bool {
796 use std::os::unix::fs::MetadataExt;
797 let Some(parent) = path.parent() else {
798 return false;
799 };
800 match (fs::symlink_metadata(path), fs::symlink_metadata(parent)) {
801 (Ok(here), Ok(above)) => here.dev() != above.dev(),
802 _ => false,
804 }
805}
806
807#[cfg(not(unix))]
808fn is_mount_point(_path: &Path) -> bool {
809 false
810}
811
812fn find_nested_git(dir: &Path) -> Option<PathBuf> {
818 walkdir::WalkDir::new(dir)
819 .follow_links(false)
820 .into_iter()
821 .flatten()
822 .find(|e| e.file_name() == ".git")
823 .map(|e| e.into_path())
824}
825
826fn collect_bloat(
833 repo_path: &Path,
834 min_size_bytes: u64,
835 depth: usize,
836) -> (Vec<String>, Vec<BloatDir>) {
837 let mut adapter_names: Vec<String> = Vec::new();
838 let mut bloat: Vec<BloatDir> = Vec::new();
839 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
840
841 for project in workspace::discover_to_depth(repo_path, depth) {
842 for adapter in &project.adapters {
843 let name = adapter.name();
844 if !adapter_names.iter().any(|existing| existing == name) {
845 adapter_names.push(name.to_string());
846 }
847 for bd in adapter.bloat_dirs(&project.path) {
848 if bd.size_bytes < min_size_bytes {
849 continue;
850 }
851 if fs::symlink_metadata(&bd.path)
857 .map(|m| m.file_type().is_symlink())
858 .unwrap_or(false)
859 || is_mount_point(&bd.path)
860 || find_nested_git(&bd.path).is_some()
861 {
862 continue;
863 }
864 if claimed.insert(bd.path.clone()) {
865 bloat.push(BloatDir {
866 name: workspace::relative_label(repo_path, &bd.path),
867 ..bd
868 });
869 }
870 }
871 }
872 }
873
874 (adapter_names, bloat)
875}
876
877pub fn prune_all_with(registry: &mut Registry, opts: &PruneOptions) -> Vec<PruneResult> {
882 let mut all_results = Vec::new();
883
884 let mut repos: Vec<(PathBuf, u64, bool)> = registry
890 .repositories
891 .iter()
892 .map(|(path, entry)| {
893 let idle_days = entry
894 .override_idle_days
895 .unwrap_or(registry.settings.idle_days);
896 (path.clone(), idle_days, entry.enabled)
897 })
898 .collect();
899 repos.sort_by(|a, b| a.0.cmp(&b.0));
900
901 for (path, idle_days, enabled) in repos {
902 if !enabled {
903 all_results.push(PruneResult {
904 repo_path: path.clone(),
905 adapter_name: "-".to_string(),
906 bloat_dir: "-".to_string(),
907 size_freed: 0,
908 shared_bytes: 0,
909 runtime: None,
910 status: PruneStatus::Disabled,
911 });
912 continue;
913 }
914
915 let results = prune_repo_with(
916 &path,
917 &PruneOptions {
918 idle_days,
919 ..opts.clone()
920 },
921 );
922
923 let path_freed: u64 = results
924 .iter()
925 .filter(|r| matches!(r.status, PruneStatus::Pruned))
926 .map(|r| r.size_freed)
927 .sum();
928
929 if path_freed > 0 {
930 registry.mark_pruned(&path, path_freed);
931 }
932
933 all_results.extend(results);
934 }
935
936 all_results
937}
938
939pub fn prune_all(registry: &mut Registry, dry_run: bool, force: bool) -> Vec<PruneResult> {
941 prune_all_with(registry, &PruneOptions::new(0, dry_run, force))
942}
943
944pub fn restore_project_to_depth(
956 project_path: &Path,
957 global_depth: usize,
958 timeout: std::time::Duration,
959) -> Result<Vec<(String, Result<()>)>> {
960 let depth = workspace::resolve_depth(project_path, global_depth);
961 let projects = workspace::discover_to_depth(project_path, depth);
962
963 if projects.is_empty() {
964 anyhow::bail!(
965 "No recognized package manager found in {}",
966 project_path.display()
967 );
968 }
969
970 let mut results = Vec::new();
971 for project in &projects {
972 for adapter in &project.adapters {
973 let label = if project.relative == "." {
974 adapter.name().to_string()
975 } else {
976 format!("{} ({})", adapter.name(), project.relative)
977 };
978 results.push((label, adapter.restore(&project.path, timeout)));
979 }
980 }
981
982 Ok(results)
983}
984
985fn owning_project(bloat_label: &str) -> &str {
993 match bloat_label.rsplit_once('/') {
994 Some((parent, _)) => parent,
995 None => ".",
996 }
997}
998
999pub fn restore_deleted(
1011 repo_path: &Path,
1012 deleted: &[crate::config::PrunedDir],
1013 global_depth: usize,
1014 timeout: std::time::Duration,
1015) -> Vec<(String, Result<()>)> {
1016 let depth = workspace::resolve_depth(repo_path, global_depth);
1017 let projects = workspace::discover_to_depth(repo_path, depth);
1018
1019 let mut results = Vec::new();
1020 for dir in deleted {
1021 let (bloat_label, adapter_name) = (&dir.bloat_dir, &dir.adapter);
1022 let runtime = dir.runtime.as_deref();
1023 let wanted = owning_project(bloat_label);
1024 let label = format!("{adapter_name} ({bloat_label})");
1025 let dir_name = bloat_label
1028 .rsplit_once('/')
1029 .map_or(bloat_label.as_str(), |(_, name)| name);
1030
1031 let found = projects
1032 .iter()
1033 .filter(|p| p.relative == wanted)
1034 .flat_map(|p| p.adapters.iter().map(move |a| (p, a)))
1035 .find(|(_, a)| a.name() == adapter_name);
1036
1037 if let Some((project, adapter)) = found {
1038 results.push((
1039 label,
1040 adapter.restore_named(&project.path, dir_name, runtime, timeout),
1041 ));
1042 continue;
1043 }
1044
1045 let project_dir = if wanted == "." {
1051 repo_path.to_path_buf()
1052 } else {
1053 repo_path.join(wanted)
1054 };
1055 let recorded = crate::adapters::get_all_adapters()
1056 .into_iter()
1057 .find(|a| a.name() == adapter_name);
1058 match recorded {
1059 Some(adapter) if project_dir.is_dir() => {
1060 results.push((
1061 label,
1062 adapter.restore_named(&project_dir, dir_name, runtime, timeout),
1063 ));
1064 }
1065 _ => results.push((
1066 label,
1067 Err(anyhow::anyhow!(
1068 "`{wanted}` in {} is no longer a {adapter_name} project — it may have been \
1069 moved or removed since the prune. Restore it by hand if it still exists.",
1070 repo_path.display()
1071 )),
1072 )),
1073 }
1074 }
1075
1076 results
1077}
1078
1079#[derive(Debug, Clone, PartialEq)]
1081pub enum SkipReason {
1082 Candidate,
1084 Active,
1086 Ignored,
1089 NoBloat,
1091 PathMissing,
1093 ConfigError(String),
1095}
1096
1097impl std::fmt::Display for SkipReason {
1098 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1099 match self {
1100 SkipReason::Candidate => write!(f, "Candidate"),
1101 SkipReason::Active => write!(f, "Active (not idle)"),
1102 SkipReason::Ignored => write!(f, "Ignored"),
1103 SkipReason::NoBloat => write!(f, "No bloat found"),
1104 SkipReason::PathMissing => write!(f, "Path missing"),
1105 SkipReason::ConfigError(_) => write!(f, "Unreadable .devprune.json"),
1106 }
1107 }
1108}
1109
1110#[derive(Debug, Clone)]
1112pub struct RepoStatusEntry {
1113 pub path: PathBuf,
1115 pub entry: RepoEntry,
1117 pub reason: SkipReason,
1119 pub adapters: Vec<String>,
1121 pub bloat_dirs: Vec<BloatDir>,
1123 pub reclaimable_bytes: u64,
1125 pub last_activity: Option<DateTime<Utc>>,
1127 pub idle_days: u64,
1129}
1130
1131fn status_for_repo(registry: &Registry, path: &Path, reg_entry: &RepoEntry) -> RepoStatusEntry {
1140 let registry_idle_days = reg_entry
1141 .override_idle_days
1142 .unwrap_or(registry.settings.idle_days);
1143
1144 if !path.exists() {
1147 return RepoStatusEntry {
1148 path: path.to_path_buf(),
1149 entry: reg_entry.clone(),
1150 reason: SkipReason::PathMissing,
1151 adapters: Vec::new(),
1152 bloat_dirs: Vec::new(),
1153 reclaimable_bytes: 0,
1154 last_activity: None,
1155 idle_days: registry_idle_days,
1156 };
1157 }
1158
1159 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(path) {
1164 Ok(cfg) => cfg,
1165 Err(e) => {
1166 return RepoStatusEntry {
1167 path: path.to_path_buf(),
1168 entry: reg_entry.clone(),
1169 reason: SkipReason::ConfigError(e),
1170 adapters: Vec::new(),
1171 bloat_dirs: Vec::new(),
1172 reclaimable_bytes: 0,
1173 last_activity: last_activity_time(path),
1174 idle_days: registry_idle_days,
1175 };
1176 }
1177 };
1178 let idle_days = per_repo_config
1179 .as_ref()
1180 .and_then(|c| c.override_idle_days)
1181 .unwrap_or(registry_idle_days);
1182
1183 let is_ignored = !reg_entry.enabled
1185 || path.join(constants::DEVPRUNE_IGNORE_FILE).exists()
1186 || per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false);
1187 if is_ignored {
1188 return RepoStatusEntry {
1189 path: path.to_path_buf(),
1190 entry: reg_entry.clone(),
1191 reason: SkipReason::Ignored,
1192 adapters: Vec::new(),
1193 bloat_dirs: Vec::new(),
1194 reclaimable_bytes: 0,
1195 last_activity: last_activity_time(path),
1196 idle_days,
1197 };
1198 }
1199
1200 let activity = git::get_last_activity(path).ok().flatten();
1205 let activity_time = to_utc(activity);
1206 let is_idle = git::is_idle_at(activity, idle_days);
1207
1208 let min_size_bytes = per_repo_config
1210 .as_ref()
1211 .and_then(|c| c.min_size_mb)
1212 .unwrap_or(registry.settings.min_size_mb)
1213 .saturating_mul(BYTES_PER_MIB);
1214 let depth = workspace::clamp_depth(
1218 per_repo_config
1219 .as_ref()
1220 .and_then(|c| c.scan_depth)
1221 .unwrap_or(registry.settings.scan_depth),
1222 );
1223 let (adapter_names, all_bloat) = collect_bloat(path, min_size_bytes, depth);
1224 let reclaimable: u64 = all_bloat.iter().map(|b| b.size_bytes).sum();
1225
1226 let reason = if !is_idle {
1227 SkipReason::Active
1228 } else if all_bloat.is_empty() {
1229 SkipReason::NoBloat
1230 } else {
1231 SkipReason::Candidate
1232 };
1233
1234 RepoStatusEntry {
1235 path: path.to_path_buf(),
1236 entry: reg_entry.clone(),
1237 reason,
1238 adapters: adapter_names,
1239 bloat_dirs: all_bloat,
1240 reclaimable_bytes: reclaimable,
1241 last_activity: activity_time,
1242 idle_days,
1243 }
1244}
1245
1246fn scan_thread_count(total: usize) -> usize {
1263 let requested = std::env::var(constants::STATUS_SCAN_THREADS_ENV)
1264 .ok()
1265 .and_then(|v| v.trim().parse::<usize>().ok())
1266 .filter(|n| *n > 0)
1267 .unwrap_or_else(|| {
1268 std::thread::available_parallelism()
1269 .map(std::num::NonZeroUsize::get)
1270 .unwrap_or(4)
1271 .saturating_mul(constants::STATUS_SCAN_THREADS_PER_CORE)
1272 });
1273 clamp_scan_threads(requested, total)
1274}
1275
1276fn clamp_scan_threads(requested: usize, total: usize) -> usize {
1279 requested
1280 .clamp(1, constants::STATUS_SCAN_MAX_THREADS)
1281 .min(total.max(1))
1282}
1283
1284pub fn get_full_status(registry: &Registry) -> Vec<RepoStatusEntry> {
1285 get_full_status_reporting(registry, &|_done, _total| {})
1286}
1287
1288pub fn get_full_status_reporting(
1299 registry: &Registry,
1300 progress: &(dyn Fn(usize, usize) + Sync),
1301) -> Vec<RepoStatusEntry> {
1302 use std::sync::atomic::{AtomicUsize, Ordering};
1303
1304 let repos: Vec<(&PathBuf, &RepoEntry)> = registry.repositories.iter().collect();
1305 let total = repos.len();
1306 let workers = scan_thread_count(total);
1307
1308 let next = AtomicUsize::new(0);
1313 let done = AtomicUsize::new(0);
1314
1315 let take_work = || {
1316 let mut mine = Vec::new();
1317 loop {
1318 let i = next.fetch_add(1, Ordering::Relaxed);
1319 if i >= total {
1320 break;
1321 }
1322 let (path, reg_entry) = repos[i];
1323 mine.push(status_for_repo(registry, path, reg_entry));
1324 progress(done.fetch_add(1, Ordering::Relaxed) + 1, total);
1325 }
1326 mine
1327 };
1328
1329 let chunks: Vec<Vec<RepoStatusEntry>> = std::thread::scope(|scope| {
1330 let mut handles = Vec::with_capacity(workers.saturating_sub(1));
1338 for n in 1..workers {
1339 match std::thread::Builder::new()
1340 .name(format!("devp-scan-{n}"))
1341 .spawn_scoped(scope, take_work)
1342 {
1343 Ok(handle) => handles.push(handle),
1344 Err(_) => break,
1345 }
1346 }
1347
1348 let mut chunks = vec![take_work()];
1351 chunks.extend(
1352 handles
1353 .into_iter()
1354 .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e))),
1357 );
1358 chunks
1359 });
1360
1361 let mut entries: Vec<RepoStatusEntry> = chunks.into_iter().flatten().collect();
1362
1363 fn rank(reason: &SkipReason) -> u8 {
1368 match reason {
1369 SkipReason::Candidate => 0,
1370 SkipReason::PathMissing => 2,
1371 _ => 1,
1372 }
1373 }
1374 entries.sort_by(|a, b| {
1375 rank(&a.reason)
1376 .cmp(&rank(&b.reason))
1377 .then_with(|| a.path.cmp(&b.path))
1378 });
1379
1380 entries
1381}
1382
1383pub fn take_top(repos: &[RepoStatusEntry], top: Option<usize>) -> Vec<RepoStatusEntry> {
1392 let Some(n) = top else {
1393 return repos.to_vec();
1394 };
1395
1396 let mut ranked: Vec<usize> = (0..repos.len()).collect();
1397 ranked.sort_by_key(|&i| std::cmp::Reverse(repos[i].reclaimable_bytes));
1398 ranked.truncate(n);
1399 ranked.sort_unstable();
1400 ranked.into_iter().map(|i| repos[i].clone()).collect()
1401}
1402
1403pub fn compute_display_name(repo_path: &Path, all_paths: &[PathBuf]) -> String {
1408 if let Some(cfg) = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
1411 .ok()
1412 .flatten()
1413 && let Some(custom) = cfg.project_name
1414 && !custom.trim().is_empty()
1415 {
1416 return custom;
1417 }
1418
1419 let folder_name = repo_path
1420 .file_name()
1421 .map(|n| n.to_string_lossy().to_string())
1422 .unwrap_or_else(|| crate::output::clean_path(repo_path));
1423
1424 let duplicate_count = all_paths
1426 .iter()
1427 .filter(|p| {
1428 p.file_name()
1429 .map(|n| n.to_string_lossy().to_string())
1430 .as_deref()
1431 == Some(&folder_name)
1432 })
1433 .count();
1434
1435 if duplicate_count > 1
1436 && let Some(parent) = repo_path.parent()
1437 && let Some(parent_name) = parent.file_name()
1438 {
1439 return format!("{}/{}", parent_name.to_string_lossy(), folder_name);
1440 }
1441
1442 folder_name
1443}
1444
1445fn last_activity_time(path: &Path) -> Option<DateTime<Utc>> {
1448 to_utc(git::get_last_activity(path).ok().flatten())
1449}
1450
1451fn to_utc(system_time: Option<SystemTime>) -> Option<DateTime<Utc>> {
1453 system_time.map(|st| {
1454 let duration = st
1455 .duration_since(SystemTime::UNIX_EPOCH)
1456 .unwrap_or_default();
1457 DateTime::from_timestamp(duration.as_secs() as i64, 0).unwrap_or_default()
1458 })
1459}
1460
1461#[cfg(test)]
1462mod tests {
1463 use super::*;
1464 use std::fs;
1465 use std::process::Command;
1466 use tempfile::TempDir;
1467
1468 const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1471
1472 #[test]
1473 fn an_ordinary_directory_is_not_a_mount_point() {
1474 let tmp = TempDir::new().unwrap();
1477 let dir = tmp.path().join("node_modules");
1478 fs::create_dir_all(&dir).unwrap();
1479 assert!(!is_mount_point(&dir));
1480 }
1481
1482 #[test]
1483 fn a_filesystem_root_is_not_reported_as_a_mount_point() {
1484 let root = Path::new(std::path::MAIN_SEPARATOR_STR);
1487 assert!(!is_mount_point(root));
1488 }
1489
1490 fn create_git_repo_with_commit(path: &Path) {
1491 fs::create_dir_all(path).unwrap();
1492 Command::new("git")
1493 .args(["init"])
1494 .current_dir(path)
1495 .output()
1496 .unwrap();
1497 fs::write(path.join("README.md"), "# Test").unwrap();
1498 Command::new("git")
1499 .args(["add", "."])
1500 .current_dir(path)
1501 .output()
1502 .unwrap();
1503 Command::new("git")
1504 .args([
1505 "-c",
1506 "user.name=Test",
1507 "-c",
1508 "user.email=test@test.com",
1509 "commit",
1510 "-m",
1511 "initial",
1512 ])
1513 .current_dir(path)
1514 .output()
1515 .unwrap();
1516 }
1517
1518 #[test]
1519 fn a_bloat_label_names_the_project_that_owns_it() {
1520 assert_eq!(owning_project("node_modules"), ".");
1521 assert_eq!(owning_project("frontend/node_modules"), "frontend");
1522 assert_eq!(
1523 owning_project("packages/@scope/app/.venv"),
1524 "packages/@scope/app"
1525 );
1526 }
1527
1528 #[test]
1529 fn restore_deleted_touches_only_the_projects_that_were_pruned() {
1530 let tmp = TempDir::new().unwrap();
1533 let root = tmp.path();
1534 for name in ["frontend", "docs"] {
1535 let dir = root.join(name);
1536 fs::create_dir_all(&dir).unwrap();
1537 fs::write(dir.join("package.json"), "{}").unwrap();
1538 fs::write(dir.join("package-lock.json"), "{}").unwrap();
1539 }
1540
1541 let deleted = vec![crate::config::PrunedDir {
1542 repo_path: root.to_path_buf(),
1543 bloat_dir: "frontend/node_modules".to_string(),
1544 adapter: "npm".to_string(),
1545 size_freed: 0,
1546 runtime: None,
1547 }];
1548 let results = restore_deleted(root, &deleted, 4, TEST_TIMEOUT);
1549
1550 assert_eq!(results.len(), 1, "one recorded directory, one attempt");
1551 assert_eq!(results[0].0, "npm (frontend/node_modules)");
1552 }
1553
1554 #[test]
1555 fn restore_deleted_reports_a_project_that_is_no_longer_there() {
1556 let tmp = TempDir::new().unwrap();
1559 let deleted = vec![crate::config::PrunedDir {
1560 repo_path: tmp.path().to_path_buf(),
1561 bloat_dir: "services/api/.venv".to_string(),
1562 adapter: "uv".to_string(),
1563 size_freed: 0,
1564 runtime: None,
1565 }];
1566 let results = restore_deleted(tmp.path(), &deleted, 4, TEST_TIMEOUT);
1567
1568 assert_eq!(results.len(), 1);
1569 assert_eq!(results[0].0, "uv (services/api/.venv)");
1570 let err = results[0].1.as_ref().unwrap_err().to_string();
1571 assert!(err.contains("services/api"), "names the missing project");
1572 assert!(err.contains("uv"), "names the adapter that owned it");
1573 }
1574
1575 #[test]
1576 fn test_prune_status_display() {
1577 assert_eq!(PruneStatus::Pruned.to_string(), "Pruned");
1578 assert_eq!(PruneStatus::SkippedActive.to_string(), "Skipped (active)");
1579 assert_eq!(PruneStatus::SkippedDryRun.to_string(), "Skipped (dry run)");
1580 }
1581
1582 #[test]
1583 fn test_prune_repo_non_git() {
1584 let tmp = TempDir::new().unwrap();
1587 let results = prune_repo(tmp.path(), 15, false, false);
1588 assert_eq!(results.len(), 1);
1589 assert!(matches!(
1590 results[0].status,
1591 PruneStatus::ActivityCheckError(_)
1592 ));
1593 }
1594
1595 #[test]
1596 fn test_prune_repo_active_skipped() {
1597 let tmp = TempDir::new().unwrap();
1598 let repo = tmp.path().join("repo");
1599 create_git_repo_with_commit(&repo);
1600 let results = prune_repo(&repo, 15, false, false);
1602 assert_eq!(results.len(), 1);
1603 assert!(matches!(results[0].status, PruneStatus::SkippedActive));
1604 }
1605
1606 #[test]
1609 fn test_unparseable_per_repo_config_skips_the_repo() {
1610 let tmp = TempDir::new().unwrap();
1611 let repo = tmp.path().join("repo");
1612 create_git_repo_with_commit(&repo);
1613 fs::create_dir(repo.join("target")).unwrap();
1614 fs::write(repo.join("target").join("dummy"), "data").unwrap();
1615 fs::write(
1616 repo.join("Cargo.toml"),
1617 "[package]\nname = \"t\"\nversion = \"0.1.0\"",
1618 )
1619 .unwrap();
1620 fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1621 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1623
1624 let results = prune_repo(&repo, 15, false, true);
1626
1627 assert_eq!(results.len(), 1);
1628 assert!(
1629 matches!(results[0].status, PruneStatus::ConfigError(_)),
1630 "expected ConfigError, got {:?}",
1631 results[0].status
1632 );
1633 assert!(repo.join("target").exists(), "target must survive");
1634 }
1635
1636 #[test]
1640 fn a_broken_config_is_reported_by_status_and_not_as_a_candidate() {
1641 let tmp = TempDir::new().unwrap();
1642 let repo = tmp.path().join("repo");
1643 create_git_repo_with_commit(&repo);
1644 create_python_project(&repo);
1645 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1646
1647 let mut registry = Registry::default();
1648 registry.add_repo(repo.clone());
1649
1650 let entries = get_full_status(®istry);
1651 assert_eq!(entries.len(), 1);
1652 assert!(
1653 matches!(entries[0].reason, SkipReason::ConfigError(_)),
1654 "expected ConfigError, got {:?}",
1655 entries[0].reason
1656 );
1657 assert_eq!(entries[0].reclaimable_bytes, 0);
1658 }
1659
1660 #[test]
1661 fn test_prune_repo_dry_run() {
1662 let tmp = TempDir::new().unwrap();
1663 let repo = tmp.path().join("repo");
1664 create_git_repo_with_commit(&repo);
1665 create_go_project(&repo);
1668 let results = prune_repo(&repo, 15, true, true);
1670 let dry_run_results: Vec<_> = results
1671 .iter()
1672 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
1673 .collect();
1674 assert!(!dry_run_results.is_empty());
1675 assert!(repo.join("vendor").exists());
1677 }
1678
1679 fn create_python_project(dir: &Path) {
1684 fs::create_dir_all(dir).unwrap();
1685 fs::write(dir.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1686 let venv = dir.join(".venv");
1687 fs::create_dir_all(&venv).unwrap();
1688 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1689 fs::write(venv.join("payload.bin"), vec![0u8; 4096]).unwrap();
1690 }
1691
1692 fn create_go_project(dir: &Path) {
1697 fs::create_dir_all(dir).unwrap();
1698 fs::write(dir.join("go.mod"), "module example.com/x\n\ngo 1.22\n").unwrap();
1699 fs::write(dir.join("go.sum"), "").unwrap();
1700 let vendor = dir.join("vendor");
1701 fs::create_dir_all(&vendor).unwrap();
1702 fs::write(vendor.join("modules.txt"), "# example.com/dep v1.0.0\n").unwrap();
1703 fs::write(vendor.join("payload.bin"), vec![0u8; 4096]).unwrap();
1704 }
1705
1706 fn labels(results: &[PruneResult]) -> Vec<String> {
1708 let mut out: Vec<String> = results.iter().map(|r| r.bloat_dir.clone()).collect();
1709 out.sort();
1710 out
1711 }
1712
1713 #[test]
1714 fn test_prune_finds_several_ecosystems_at_the_repo_root() {
1715 let tmp = TempDir::new().unwrap();
1716 let repo = tmp.path().join("repo");
1717 create_git_repo_with_commit(&repo);
1718
1719 create_go_project(&repo);
1720 fs::write(repo.join("package.json"), "{}").unwrap();
1721 fs::write(repo.join("package-lock.json"), "{}").unwrap();
1722 fs::create_dir(repo.join("node_modules")).unwrap();
1723 create_python_project(&repo);
1724
1725 let results = prune_repo(&repo, 15, true, true);
1726 assert_eq!(labels(&results), vec![".venv", "node_modules", "vendor"]);
1727 }
1728
1729 #[test]
1730 fn test_prune_finds_ecosystems_at_different_depths() {
1731 let tmp = TempDir::new().unwrap();
1732 let repo = tmp.path().join("repo");
1733 create_git_repo_with_commit(&repo);
1734
1735 fs::create_dir_all(repo.join("frontend")).unwrap();
1736 fs::write(repo.join("frontend/package.json"), "{}").unwrap();
1737 fs::write(repo.join("frontend/pnpm-lock.yaml"), "").unwrap();
1738 fs::create_dir(repo.join("frontend/node_modules")).unwrap();
1739
1740 create_go_project(&repo.join("tools/cli"));
1741
1742 create_python_project(&repo.join("services/api"));
1743
1744 let results = prune_repo(&repo, 15, true, true);
1745 assert_eq!(
1746 labels(&results),
1747 vec![
1748 "frontend/node_modules",
1749 "services/api/.venv",
1750 "tools/cli/vendor",
1751 ]
1752 );
1753 }
1754
1755 #[test]
1756 fn test_prune_deletes_only_the_selected_nested_directory() {
1757 let tmp = TempDir::new().unwrap();
1758 let repo = tmp.path().join("repo");
1759 create_git_repo_with_commit(&repo);
1760 create_python_project(&repo.join("a"));
1761 create_python_project(&repo.join("b"));
1762
1763 let results = prune_repo_selected(&repo, 0, false, true, Some(&["a/.venv".to_string()]));
1764
1765 assert_eq!(labels(&results), vec!["a/.venv"]);
1766 assert!(matches!(results[0].status, PruneStatus::Pruned));
1767 assert!(!repo.join("a/.venv").exists());
1768 assert!(repo.join("b/.venv").exists());
1769 }
1770
1771 #[test]
1772 fn test_prune_ignores_bloat_inside_a_nested_repository() {
1773 let tmp = TempDir::new().unwrap();
1774 let repo = tmp.path().join("repo");
1775 create_git_repo_with_commit(&repo);
1776 create_python_project(&repo.join("outer"));
1777
1778 let nested = repo.join("nested");
1781 create_git_repo_with_commit(&nested);
1782 create_python_project(&nested);
1783
1784 let results = prune_repo(&repo, 15, true, true);
1785 assert_eq!(labels(&results), vec!["outer/.venv"]);
1786 }
1787
1788 #[test]
1789 fn test_prune_repo_no_adapters() {
1790 let tmp = TempDir::new().unwrap();
1791 let repo = tmp.path().join("repo");
1792 create_git_repo_with_commit(&repo);
1793 let results = prune_repo(&repo, 15, false, true);
1795 assert!(
1796 results
1797 .iter()
1798 .any(|r| matches!(r.status, PruneStatus::NoBloat))
1799 );
1800 }
1801
1802 #[test]
1803 fn test_prune_all_disabled() {
1804 let tmp = TempDir::new().unwrap();
1805 let _registry_path = tmp.path().join("registry.json");
1806
1807 let mut registry = Registry::default();
1808 let repo_path = PathBuf::from("/nonexistent/repo");
1809 registry.add_repo(repo_path.clone());
1810 registry.repositories.get_mut(&repo_path).unwrap().enabled = false;
1811
1812 let results = prune_all(&mut registry, false, false);
1813 assert!(
1814 results
1815 .iter()
1816 .any(|r| matches!(r.status, PruneStatus::Disabled))
1817 );
1818 }
1819
1820 #[test]
1821 fn test_restore_project_no_adapters() {
1822 let tmp = TempDir::new().unwrap();
1823 let result = restore_project_to_depth(
1824 tmp.path(),
1825 crate::constants::DEFAULT_SCAN_DEPTH,
1826 TEST_TIMEOUT,
1827 );
1828 assert!(result.is_err());
1829 }
1830
1831 #[test]
1832 fn restore_deleted_trusts_the_record_when_the_prune_erased_detection() {
1833 let tmp = TempDir::new().unwrap();
1837 let api = tmp.path().join("api");
1838 fs::create_dir_all(&api).unwrap();
1839 fs::write(api.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1840 let deleted = vec![crate::config::PrunedDir {
1843 repo_path: tmp.path().to_path_buf(),
1844 bloat_dir: "api/.venv".to_string(),
1845 adapter: "venv".to_string(),
1846 size_freed: 0,
1847 runtime: None,
1848 }];
1849 let results = restore_deleted(tmp.path(), &deleted, 4, std::time::Duration::ZERO);
1852
1853 assert_eq!(results.len(), 1);
1854 assert_eq!(results[0].0, "venv (api/.venv)");
1855 if let Err(e) = &results[0].1 {
1856 assert!(
1857 !e.to_string().contains("no longer a"),
1858 "the recorded adapter must be attempted, got: {e}"
1859 );
1860 }
1861 }
1862
1863 #[test]
1864 fn a_git_repository_inside_a_bloat_directory_refuses_the_delete() {
1865 let tmp = TempDir::new().unwrap();
1868 let repo = tmp.path().join("repo");
1869 create_git_repo_with_commit(&repo);
1870 create_python_project(&repo);
1871 fs::create_dir_all(repo.join(".venv/src/vendored/.git")).unwrap();
1872
1873 let results = prune_repo_selected(&repo, 0, false, true, Some(&[".venv".to_string()]));
1874
1875 assert_eq!(results.len(), 1);
1876 let PruneStatus::DeleteError(msg) = &results[0].status else {
1877 panic!("expected a refusal, got {:?}", results[0].status);
1878 };
1879 assert!(msg.contains("git repository"), "says why: {msg}");
1880 assert!(repo.join(".venv").exists(), "nothing may be deleted");
1881 assert_eq!(results[0].size_freed, 0);
1882 }
1883
1884 fn status_entry(name: &str, reclaimable: u64) -> RepoStatusEntry {
1885 RepoStatusEntry {
1886 path: PathBuf::from(name),
1887 entry: RepoEntry::new(),
1888 reason: SkipReason::Candidate,
1889 adapters: Vec::new(),
1890 bloat_dirs: Vec::new(),
1891 reclaimable_bytes: reclaimable,
1892 last_activity: None,
1893 idle_days: 15,
1894 }
1895 }
1896
1897 #[test]
1898 fn take_top_selects_by_size_but_keeps_the_dashboard_order() {
1899 let repos = [
1900 status_entry("small", 10),
1901 status_entry("big", 300),
1902 status_entry("mid", 200),
1903 ];
1904 let names: Vec<String> = take_top(&repos, Some(2))
1905 .iter()
1906 .map(|e| e.path.display().to_string())
1907 .collect();
1908 assert_eq!(names, vec!["big", "mid"]);
1912 }
1913
1914 #[test]
1915 fn take_top_without_a_limit_or_with_an_oversized_one_returns_everything() {
1916 let repos = [status_entry("a", 1), status_entry("b", 2)];
1917 assert_eq!(take_top(&repos, None).len(), 2);
1918 assert_eq!(take_top(&repos, Some(10)).len(), 2);
1919 assert_eq!(take_top(&repos, Some(0)).len(), 0);
1920 }
1921
1922 #[test]
1923 fn test_restore_project_with_npm() {
1924 let tmp = TempDir::new().unwrap();
1925 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1926 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1927 let results = restore_project_to_depth(
1928 tmp.path(),
1929 crate::constants::DEFAULT_SCAN_DEPTH,
1930 TEST_TIMEOUT,
1931 );
1932 assert!(results.is_ok());
1934 let results = results.unwrap();
1935 assert!(!results.is_empty());
1936 assert_eq!(results[0].0, "npm");
1937 }
1938
1939 #[test]
1940 fn the_scan_never_starts_more_threads_than_there_is_work() {
1941 assert_eq!(clamp_scan_threads(32, 3), 3);
1943 assert_eq!(clamp_scan_threads(0, 0), 1);
1946 assert_eq!(clamp_scan_threads(0, 50), 1);
1947 }
1948
1949 #[test]
1950 fn an_absurd_thread_request_is_clamped_rather_than_honoured() {
1951 assert_eq!(
1953 clamp_scan_threads(9_999, 500),
1954 constants::STATUS_SCAN_MAX_THREADS
1955 );
1956 }
1957
1958 #[test]
1959 fn a_registry_of_one_repository_is_scanned_on_the_calling_thread_alone() {
1960 assert_eq!(clamp_scan_threads(16, 1), 1);
1961 }
1962}