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(
837 repo_path: &Path,
838 min_size_bytes: u64,
839 depth: usize,
840) -> (Vec<String>, Vec<BloatDir>, Vec<(String, u64)>) {
841 let mut adapter_names: Vec<String> = Vec::new();
842 let mut bloat: Vec<BloatDir> = Vec::new();
843 let mut by_adapter: BTreeMap<String, u64> = BTreeMap::new();
844 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
845
846 for project in workspace::discover_to_depth(repo_path, depth) {
847 for adapter in &project.adapters {
848 let name = adapter.name();
849 if !adapter_names.iter().any(|existing| existing == name) {
850 adapter_names.push(name.to_string());
851 }
852 for bd in adapter.bloat_dirs(&project.path) {
853 if bd.size_bytes < min_size_bytes {
854 continue;
855 }
856 if fs::symlink_metadata(&bd.path)
862 .map(|m| m.file_type().is_symlink())
863 .unwrap_or(false)
864 || is_mount_point(&bd.path)
865 || find_nested_git(&bd.path).is_some()
866 {
867 continue;
868 }
869 if claimed.insert(bd.path.clone()) {
870 *by_adapter.entry(name.to_string()).or_default() += bd.size_bytes;
871 bloat.push(BloatDir {
872 name: workspace::relative_label(repo_path, &bd.path),
873 ..bd
874 });
875 }
876 }
877 }
878 }
879
880 (adapter_names, bloat, by_adapter.into_iter().collect())
881}
882
883pub fn prune_all_with(registry: &mut Registry, opts: &PruneOptions) -> Vec<PruneResult> {
888 let mut all_results = Vec::new();
889
890 let mut repos: Vec<(PathBuf, u64, bool)> = registry
896 .repositories
897 .iter()
898 .map(|(path, entry)| {
899 let idle_days = entry
900 .override_idle_days
901 .unwrap_or(registry.settings.idle_days);
902 (path.clone(), idle_days, entry.enabled)
903 })
904 .collect();
905 repos.sort_by(|a, b| a.0.cmp(&b.0));
906
907 for (path, idle_days, enabled) in repos {
908 if !enabled {
909 all_results.push(PruneResult {
910 repo_path: path.clone(),
911 adapter_name: "-".to_string(),
912 bloat_dir: "-".to_string(),
913 size_freed: 0,
914 shared_bytes: 0,
915 runtime: None,
916 status: PruneStatus::Disabled,
917 });
918 continue;
919 }
920
921 let results = prune_repo_with(
922 &path,
923 &PruneOptions {
924 idle_days,
925 ..opts.clone()
926 },
927 );
928
929 let path_freed: u64 = results
930 .iter()
931 .filter(|r| matches!(r.status, PruneStatus::Pruned))
932 .map(|r| r.size_freed)
933 .sum();
934
935 if path_freed > 0 {
936 registry.mark_pruned(&path, path_freed);
937 }
938
939 all_results.extend(results);
940 }
941
942 all_results
943}
944
945pub fn prune_all(registry: &mut Registry, dry_run: bool, force: bool) -> Vec<PruneResult> {
947 prune_all_with(registry, &PruneOptions::new(0, dry_run, force))
948}
949
950pub fn restore_project_to_depth(
962 project_path: &Path,
963 global_depth: usize,
964 timeout: std::time::Duration,
965) -> Result<Vec<(String, Result<()>)>> {
966 let depth = workspace::resolve_depth(project_path, global_depth);
967 let projects = workspace::discover_to_depth(project_path, depth);
968
969 if projects.is_empty() {
970 anyhow::bail!(
971 "No recognized package manager found in {}",
972 project_path.display()
973 );
974 }
975
976 let mut results = Vec::new();
977 for project in &projects {
978 for adapter in &project.adapters {
979 let label = if project.relative == "." {
980 adapter.name().to_string()
981 } else {
982 format!("{} ({})", adapter.name(), project.relative)
983 };
984 results.push((label, adapter.restore(&project.path, timeout)));
985 }
986 }
987
988 Ok(results)
989}
990
991fn owning_project(bloat_label: &str) -> &str {
999 match bloat_label.rsplit_once('/') {
1000 Some((parent, _)) => parent,
1001 None => ".",
1002 }
1003}
1004
1005pub struct RestoreOutcome {
1024 pub label: String,
1026 pub adapter: String,
1028 pub bytes: u64,
1030 pub elapsed: std::time::Duration,
1032 pub result: Result<()>,
1034}
1035
1036pub fn restore_deleted(
1037 repo_path: &Path,
1038 deleted: &[crate::config::PrunedDir],
1039 global_depth: usize,
1040 timeout: std::time::Duration,
1041) -> Vec<RestoreOutcome> {
1042 let depth = workspace::resolve_depth(repo_path, global_depth);
1043 let projects = workspace::discover_to_depth(repo_path, depth);
1044
1045 let mut results = Vec::new();
1046 for dir in deleted {
1047 let (bloat_label, adapter_name) = (&dir.bloat_dir, &dir.adapter);
1048 let timed = |result: Result<()>, started: std::time::Instant| RestoreOutcome {
1051 label: format!("{adapter_name} ({bloat_label})"),
1052 adapter: adapter_name.clone(),
1053 bytes: dir.size_freed,
1054 elapsed: started.elapsed(),
1055 result,
1056 };
1057 let runtime = dir.runtime.as_deref();
1058 let wanted = owning_project(bloat_label);
1059 let dir_name = bloat_label
1062 .rsplit_once('/')
1063 .map_or(bloat_label.as_str(), |(_, name)| name);
1064
1065 let found = projects
1066 .iter()
1067 .filter(|p| p.relative == wanted)
1068 .flat_map(|p| p.adapters.iter().map(move |a| (p, a)))
1069 .find(|(_, a)| a.name() == adapter_name);
1070
1071 if let Some((project, adapter)) = found {
1072 let started = std::time::Instant::now();
1073 let result = adapter.restore_named(&project.path, dir_name, runtime, timeout);
1074 results.push(timed(result, started));
1075 continue;
1076 }
1077
1078 let project_dir = if wanted == "." {
1084 repo_path.to_path_buf()
1085 } else {
1086 repo_path.join(wanted)
1087 };
1088 let recorded = crate::adapters::get_all_adapters()
1089 .into_iter()
1090 .find(|a| a.name() == adapter_name);
1091 match recorded {
1092 Some(adapter) if project_dir.is_dir() => {
1093 let started = std::time::Instant::now();
1094 let result = adapter.restore_named(&project_dir, dir_name, runtime, timeout);
1095 results.push(timed(result, started));
1096 }
1097 _ => results.push(timed(
1098 Err(anyhow::anyhow!(
1099 "`{wanted}` in {} is no longer a {adapter_name} project — it may have been \
1100 moved or removed since the prune. Restore it by hand if it still exists.",
1101 repo_path.display()
1102 )),
1103 std::time::Instant::now(),
1104 )),
1105 }
1106 }
1107
1108 results
1109}
1110
1111#[derive(Debug, Clone, PartialEq)]
1113pub enum SkipReason {
1114 Candidate,
1116 Active,
1118 Ignored,
1121 NoBloat,
1123 PathMissing,
1125 ConfigError(String),
1127}
1128
1129impl std::fmt::Display for SkipReason {
1130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1131 match self {
1132 SkipReason::Candidate => write!(f, "Candidate"),
1133 SkipReason::Active => write!(f, "Active (not idle)"),
1134 SkipReason::Ignored => write!(f, "Ignored"),
1135 SkipReason::NoBloat => write!(f, "No bloat found"),
1136 SkipReason::PathMissing => write!(f, "Path missing"),
1137 SkipReason::ConfigError(_) => write!(f, "Unreadable .devprune.json"),
1138 }
1139 }
1140}
1141
1142#[derive(Debug, Clone)]
1144pub struct RepoStatusEntry {
1145 pub path: PathBuf,
1147 pub entry: RepoEntry,
1149 pub reason: SkipReason,
1151 pub adapters: Vec<String>,
1153 pub bloat_dirs: Vec<BloatDir>,
1155 pub reclaimable_bytes: u64,
1157 pub reclaimable_by_adapter: Vec<(String, u64)>,
1160 pub last_activity: Option<DateTime<Utc>>,
1162 pub idle_days: u64,
1164}
1165
1166fn status_for_repo(registry: &Registry, path: &Path, reg_entry: &RepoEntry) -> RepoStatusEntry {
1175 let registry_idle_days = reg_entry
1176 .override_idle_days
1177 .unwrap_or(registry.settings.idle_days);
1178
1179 if !path.exists() {
1182 return RepoStatusEntry {
1183 path: path.to_path_buf(),
1184 entry: reg_entry.clone(),
1185 reason: SkipReason::PathMissing,
1186 adapters: Vec::new(),
1187 bloat_dirs: Vec::new(),
1188 reclaimable_by_adapter: Vec::new(),
1189 reclaimable_bytes: 0,
1190 last_activity: None,
1191 idle_days: registry_idle_days,
1192 };
1193 }
1194
1195 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(path) {
1200 Ok(cfg) => cfg,
1201 Err(e) => {
1202 return RepoStatusEntry {
1203 path: path.to_path_buf(),
1204 entry: reg_entry.clone(),
1205 reason: SkipReason::ConfigError(e),
1206 adapters: Vec::new(),
1207 bloat_dirs: Vec::new(),
1208 reclaimable_by_adapter: Vec::new(),
1209 reclaimable_bytes: 0,
1210 last_activity: last_activity_time(path),
1211 idle_days: registry_idle_days,
1212 };
1213 }
1214 };
1215 let idle_days = per_repo_config
1216 .as_ref()
1217 .and_then(|c| c.override_idle_days)
1218 .unwrap_or(registry_idle_days);
1219
1220 let is_ignored = !reg_entry.enabled
1222 || path.join(constants::DEVPRUNE_IGNORE_FILE).exists()
1223 || per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false);
1224 if is_ignored {
1225 return RepoStatusEntry {
1226 path: path.to_path_buf(),
1227 entry: reg_entry.clone(),
1228 reason: SkipReason::Ignored,
1229 adapters: Vec::new(),
1230 bloat_dirs: Vec::new(),
1231 reclaimable_by_adapter: Vec::new(),
1232 reclaimable_bytes: 0,
1233 last_activity: last_activity_time(path),
1234 idle_days,
1235 };
1236 }
1237
1238 let activity = git::get_last_activity(path).ok().flatten();
1243 let activity_time = to_utc(activity);
1244 let is_idle = git::is_idle_at(activity, idle_days);
1245
1246 let min_size_bytes = per_repo_config
1248 .as_ref()
1249 .and_then(|c| c.min_size_mb)
1250 .unwrap_or(registry.settings.min_size_mb)
1251 .saturating_mul(BYTES_PER_MIB);
1252 let depth = workspace::clamp_depth(
1256 per_repo_config
1257 .as_ref()
1258 .and_then(|c| c.scan_depth)
1259 .unwrap_or(registry.settings.scan_depth),
1260 );
1261 let (adapter_names, all_bloat, by_adapter) = collect_bloat(path, min_size_bytes, depth);
1262 let reclaimable: u64 = all_bloat.iter().map(|b| b.size_bytes).sum();
1263
1264 let reason = if !is_idle {
1265 SkipReason::Active
1266 } else if all_bloat.is_empty() {
1267 SkipReason::NoBloat
1268 } else {
1269 SkipReason::Candidate
1270 };
1271
1272 RepoStatusEntry {
1273 path: path.to_path_buf(),
1274 entry: reg_entry.clone(),
1275 reason,
1276 adapters: adapter_names,
1277 bloat_dirs: all_bloat,
1278 reclaimable_bytes: reclaimable,
1279 reclaimable_by_adapter: by_adapter,
1280 last_activity: activity_time,
1281 idle_days,
1282 }
1283}
1284
1285fn scan_thread_count(total: usize) -> usize {
1302 let requested = std::env::var(constants::STATUS_SCAN_THREADS_ENV)
1303 .ok()
1304 .and_then(|v| v.trim().parse::<usize>().ok())
1305 .filter(|n| *n > 0)
1306 .unwrap_or_else(|| {
1307 std::thread::available_parallelism()
1308 .map(std::num::NonZeroUsize::get)
1309 .unwrap_or(4)
1310 .saturating_mul(constants::STATUS_SCAN_THREADS_PER_CORE)
1311 });
1312 clamp_scan_threads(requested, total)
1313}
1314
1315fn clamp_scan_threads(requested: usize, total: usize) -> usize {
1318 requested
1319 .clamp(1, constants::STATUS_SCAN_MAX_THREADS)
1320 .min(total.max(1))
1321}
1322
1323pub fn get_full_status(registry: &Registry) -> Vec<RepoStatusEntry> {
1324 get_full_status_reporting(registry, &|_done, _total| {})
1325}
1326
1327pub fn get_full_status_reporting(
1338 registry: &Registry,
1339 progress: &(dyn Fn(usize, usize) + Sync),
1340) -> Vec<RepoStatusEntry> {
1341 use std::sync::atomic::{AtomicUsize, Ordering};
1342
1343 let repos: Vec<(&PathBuf, &RepoEntry)> = registry.repositories.iter().collect();
1344 let total = repos.len();
1345 let workers = scan_thread_count(total);
1346
1347 let next = AtomicUsize::new(0);
1352 let done = AtomicUsize::new(0);
1353
1354 let take_work = || {
1355 let mut mine = Vec::new();
1356 loop {
1357 let i = next.fetch_add(1, Ordering::Relaxed);
1358 if i >= total {
1359 break;
1360 }
1361 let (path, reg_entry) = repos[i];
1362 mine.push(status_for_repo(registry, path, reg_entry));
1363 progress(done.fetch_add(1, Ordering::Relaxed) + 1, total);
1364 }
1365 mine
1366 };
1367
1368 let chunks: Vec<Vec<RepoStatusEntry>> = std::thread::scope(|scope| {
1369 let mut handles = Vec::with_capacity(workers.saturating_sub(1));
1377 for n in 1..workers {
1378 match std::thread::Builder::new()
1379 .name(format!("devp-scan-{n}"))
1380 .spawn_scoped(scope, take_work)
1381 {
1382 Ok(handle) => handles.push(handle),
1383 Err(_) => break,
1384 }
1385 }
1386
1387 let mut chunks = vec![take_work()];
1390 chunks.extend(
1391 handles
1392 .into_iter()
1393 .map(|h| h.join().unwrap_or_else(|e| std::panic::resume_unwind(e))),
1396 );
1397 chunks
1398 });
1399
1400 let mut entries: Vec<RepoStatusEntry> = chunks.into_iter().flatten().collect();
1401
1402 fn rank(reason: &SkipReason) -> u8 {
1407 match reason {
1408 SkipReason::Candidate => 0,
1409 SkipReason::PathMissing => 2,
1410 _ => 1,
1411 }
1412 }
1413 entries.sort_by(|a, b| {
1414 rank(&a.reason)
1415 .cmp(&rank(&b.reason))
1416 .then_with(|| a.path.cmp(&b.path))
1417 });
1418
1419 entries
1420}
1421
1422pub fn take_top(repos: &[RepoStatusEntry], top: Option<usize>) -> Vec<RepoStatusEntry> {
1431 let Some(n) = top else {
1432 return repos.to_vec();
1433 };
1434
1435 let mut ranked: Vec<usize> = (0..repos.len()).collect();
1436 ranked.sort_by_key(|&i| std::cmp::Reverse(repos[i].reclaimable_bytes));
1437 ranked.truncate(n);
1438 ranked.sort_unstable();
1439 ranked.into_iter().map(|i| repos[i].clone()).collect()
1440}
1441
1442pub fn compute_display_name(repo_path: &Path, all_paths: &[PathBuf]) -> String {
1447 if let Some(cfg) = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
1450 .ok()
1451 .flatten()
1452 && let Some(custom) = cfg.project_name
1453 && !custom.trim().is_empty()
1454 {
1455 return custom;
1456 }
1457
1458 let folder_name = repo_path
1459 .file_name()
1460 .map(|n| n.to_string_lossy().to_string())
1461 .unwrap_or_else(|| crate::output::clean_path(repo_path));
1462
1463 let duplicate_count = all_paths
1465 .iter()
1466 .filter(|p| {
1467 p.file_name()
1468 .map(|n| n.to_string_lossy().to_string())
1469 .as_deref()
1470 == Some(&folder_name)
1471 })
1472 .count();
1473
1474 if duplicate_count > 1
1475 && let Some(parent) = repo_path.parent()
1476 && let Some(parent_name) = parent.file_name()
1477 {
1478 return format!("{}/{}", parent_name.to_string_lossy(), folder_name);
1479 }
1480
1481 folder_name
1482}
1483
1484fn last_activity_time(path: &Path) -> Option<DateTime<Utc>> {
1487 to_utc(git::get_last_activity(path).ok().flatten())
1488}
1489
1490fn to_utc(system_time: Option<SystemTime>) -> Option<DateTime<Utc>> {
1492 system_time.map(|st| {
1493 let duration = st
1494 .duration_since(SystemTime::UNIX_EPOCH)
1495 .unwrap_or_default();
1496 DateTime::from_timestamp(duration.as_secs() as i64, 0).unwrap_or_default()
1497 })
1498}
1499
1500#[cfg(test)]
1501mod tests {
1502 use super::*;
1503 use std::fs;
1504 use std::process::Command;
1505 use tempfile::TempDir;
1506
1507 const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1510
1511 #[test]
1512 fn an_ordinary_directory_is_not_a_mount_point() {
1513 let tmp = TempDir::new().unwrap();
1516 let dir = tmp.path().join("node_modules");
1517 fs::create_dir_all(&dir).unwrap();
1518 assert!(!is_mount_point(&dir));
1519 }
1520
1521 #[test]
1522 fn a_filesystem_root_is_not_reported_as_a_mount_point() {
1523 let root = Path::new(std::path::MAIN_SEPARATOR_STR);
1526 assert!(!is_mount_point(root));
1527 }
1528
1529 fn create_git_repo_with_commit(path: &Path) {
1530 fs::create_dir_all(path).unwrap();
1531 Command::new("git")
1532 .args(["init"])
1533 .current_dir(path)
1534 .output()
1535 .unwrap();
1536 fs::write(path.join("README.md"), "# Test").unwrap();
1537 Command::new("git")
1538 .args(["add", "."])
1539 .current_dir(path)
1540 .output()
1541 .unwrap();
1542 Command::new("git")
1543 .args([
1544 "-c",
1545 "user.name=Test",
1546 "-c",
1547 "user.email=test@test.com",
1548 "commit",
1549 "-m",
1550 "initial",
1551 ])
1552 .current_dir(path)
1553 .output()
1554 .unwrap();
1555 }
1556
1557 #[test]
1558 fn a_bloat_label_names_the_project_that_owns_it() {
1559 assert_eq!(owning_project("node_modules"), ".");
1560 assert_eq!(owning_project("frontend/node_modules"), "frontend");
1561 assert_eq!(
1562 owning_project("packages/@scope/app/.venv"),
1563 "packages/@scope/app"
1564 );
1565 }
1566
1567 #[test]
1568 fn restore_deleted_touches_only_the_projects_that_were_pruned() {
1569 let tmp = TempDir::new().unwrap();
1572 let root = tmp.path();
1573 for name in ["frontend", "docs"] {
1574 let dir = root.join(name);
1575 fs::create_dir_all(&dir).unwrap();
1576 fs::write(dir.join("package.json"), "{}").unwrap();
1577 fs::write(dir.join("package-lock.json"), "{}").unwrap();
1578 }
1579
1580 let deleted = vec![crate::config::PrunedDir {
1581 repo_path: root.to_path_buf(),
1582 bloat_dir: "frontend/node_modules".to_string(),
1583 adapter: "npm".to_string(),
1584 size_freed: 0,
1585 runtime: None,
1586 }];
1587 let results = restore_deleted(root, &deleted, 4, TEST_TIMEOUT);
1588
1589 assert_eq!(results.len(), 1, "one recorded directory, one attempt");
1590 assert_eq!(results[0].label, "npm (frontend/node_modules)");
1591 }
1592
1593 #[test]
1594 fn restore_deleted_reports_a_project_that_is_no_longer_there() {
1595 let tmp = TempDir::new().unwrap();
1598 let deleted = vec![crate::config::PrunedDir {
1599 repo_path: tmp.path().to_path_buf(),
1600 bloat_dir: "services/api/.venv".to_string(),
1601 adapter: "uv".to_string(),
1602 size_freed: 0,
1603 runtime: None,
1604 }];
1605 let results = restore_deleted(tmp.path(), &deleted, 4, TEST_TIMEOUT);
1606
1607 assert_eq!(results.len(), 1);
1608 assert_eq!(results[0].label, "uv (services/api/.venv)");
1609 let err = results[0].result.as_ref().unwrap_err().to_string();
1610 assert!(err.contains("services/api"), "names the missing project");
1611 assert!(err.contains("uv"), "names the adapter that owned it");
1612 }
1613
1614 #[test]
1615 fn test_prune_status_display() {
1616 assert_eq!(PruneStatus::Pruned.to_string(), "Pruned");
1617 assert_eq!(PruneStatus::SkippedActive.to_string(), "Skipped (active)");
1618 assert_eq!(PruneStatus::SkippedDryRun.to_string(), "Skipped (dry run)");
1619 }
1620
1621 #[test]
1622 fn test_prune_repo_non_git() {
1623 let tmp = TempDir::new().unwrap();
1626 let results = prune_repo(tmp.path(), 15, false, false);
1627 assert_eq!(results.len(), 1);
1628 assert!(matches!(
1629 results[0].status,
1630 PruneStatus::ActivityCheckError(_)
1631 ));
1632 }
1633
1634 #[test]
1635 fn test_prune_repo_active_skipped() {
1636 let tmp = TempDir::new().unwrap();
1637 let repo = tmp.path().join("repo");
1638 create_git_repo_with_commit(&repo);
1639 let results = prune_repo(&repo, 15, false, false);
1641 assert_eq!(results.len(), 1);
1642 assert!(matches!(results[0].status, PruneStatus::SkippedActive));
1643 }
1644
1645 #[test]
1648 fn test_unparseable_per_repo_config_skips_the_repo() {
1649 let tmp = TempDir::new().unwrap();
1650 let repo = tmp.path().join("repo");
1651 create_git_repo_with_commit(&repo);
1652 fs::create_dir(repo.join("target")).unwrap();
1653 fs::write(repo.join("target").join("dummy"), "data").unwrap();
1654 fs::write(
1655 repo.join("Cargo.toml"),
1656 "[package]\nname = \"t\"\nversion = \"0.1.0\"",
1657 )
1658 .unwrap();
1659 fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1660 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1662
1663 let results = prune_repo(&repo, 15, false, true);
1665
1666 assert_eq!(results.len(), 1);
1667 assert!(
1668 matches!(results[0].status, PruneStatus::ConfigError(_)),
1669 "expected ConfigError, got {:?}",
1670 results[0].status
1671 );
1672 assert!(repo.join("target").exists(), "target must survive");
1673 }
1674
1675 #[test]
1679 fn a_broken_config_is_reported_by_status_and_not_as_a_candidate() {
1680 let tmp = TempDir::new().unwrap();
1681 let repo = tmp.path().join("repo");
1682 create_git_repo_with_commit(&repo);
1683 create_python_project(&repo);
1684 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1685
1686 let mut registry = Registry::default();
1687 registry.add_repo(repo.clone());
1688
1689 let entries = get_full_status(®istry);
1690 assert_eq!(entries.len(), 1);
1691 assert!(
1692 matches!(entries[0].reason, SkipReason::ConfigError(_)),
1693 "expected ConfigError, got {:?}",
1694 entries[0].reason
1695 );
1696 assert_eq!(entries[0].reclaimable_bytes, 0);
1697 }
1698
1699 #[test]
1700 fn test_prune_repo_dry_run() {
1701 let tmp = TempDir::new().unwrap();
1702 let repo = tmp.path().join("repo");
1703 create_git_repo_with_commit(&repo);
1704 create_go_project(&repo);
1707 let results = prune_repo(&repo, 15, true, true);
1709 let dry_run_results: Vec<_> = results
1710 .iter()
1711 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
1712 .collect();
1713 assert!(!dry_run_results.is_empty());
1714 assert!(repo.join("vendor").exists());
1716 }
1717
1718 fn create_python_project(dir: &Path) {
1723 fs::create_dir_all(dir).unwrap();
1724 fs::write(dir.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1725 let venv = dir.join(".venv");
1726 fs::create_dir_all(&venv).unwrap();
1727 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1728 fs::write(venv.join("payload.bin"), vec![0u8; 4096]).unwrap();
1729 }
1730
1731 fn create_go_project(dir: &Path) {
1736 fs::create_dir_all(dir).unwrap();
1737 fs::write(dir.join("go.mod"), "module example.com/x\n\ngo 1.22\n").unwrap();
1738 fs::write(dir.join("go.sum"), "").unwrap();
1739 let vendor = dir.join("vendor");
1740 fs::create_dir_all(&vendor).unwrap();
1741 fs::write(vendor.join("modules.txt"), "# example.com/dep v1.0.0\n").unwrap();
1742 fs::write(vendor.join("payload.bin"), vec![0u8; 4096]).unwrap();
1743 }
1744
1745 fn labels(results: &[PruneResult]) -> Vec<String> {
1747 let mut out: Vec<String> = results.iter().map(|r| r.bloat_dir.clone()).collect();
1748 out.sort();
1749 out
1750 }
1751
1752 #[test]
1753 fn test_prune_finds_several_ecosystems_at_the_repo_root() {
1754 let tmp = TempDir::new().unwrap();
1755 let repo = tmp.path().join("repo");
1756 create_git_repo_with_commit(&repo);
1757
1758 create_go_project(&repo);
1759 fs::write(repo.join("package.json"), "{}").unwrap();
1760 fs::write(repo.join("package-lock.json"), "{}").unwrap();
1761 fs::create_dir(repo.join("node_modules")).unwrap();
1762 create_python_project(&repo);
1763
1764 let results = prune_repo(&repo, 15, true, true);
1765 assert_eq!(labels(&results), vec![".venv", "node_modules", "vendor"]);
1766 }
1767
1768 #[test]
1769 fn test_prune_finds_ecosystems_at_different_depths() {
1770 let tmp = TempDir::new().unwrap();
1771 let repo = tmp.path().join("repo");
1772 create_git_repo_with_commit(&repo);
1773
1774 fs::create_dir_all(repo.join("frontend")).unwrap();
1775 fs::write(repo.join("frontend/package.json"), "{}").unwrap();
1776 fs::write(repo.join("frontend/pnpm-lock.yaml"), "").unwrap();
1777 fs::create_dir(repo.join("frontend/node_modules")).unwrap();
1778
1779 create_go_project(&repo.join("tools/cli"));
1780
1781 create_python_project(&repo.join("services/api"));
1782
1783 let results = prune_repo(&repo, 15, true, true);
1784 assert_eq!(
1785 labels(&results),
1786 vec![
1787 "frontend/node_modules",
1788 "services/api/.venv",
1789 "tools/cli/vendor",
1790 ]
1791 );
1792 }
1793
1794 #[test]
1795 fn test_prune_deletes_only_the_selected_nested_directory() {
1796 let tmp = TempDir::new().unwrap();
1797 let repo = tmp.path().join("repo");
1798 create_git_repo_with_commit(&repo);
1799 create_python_project(&repo.join("a"));
1800 create_python_project(&repo.join("b"));
1801
1802 let results = prune_repo_selected(&repo, 0, false, true, Some(&["a/.venv".to_string()]));
1803
1804 assert_eq!(labels(&results), vec!["a/.venv"]);
1805 assert!(matches!(results[0].status, PruneStatus::Pruned));
1806 assert!(!repo.join("a/.venv").exists());
1807 assert!(repo.join("b/.venv").exists());
1808 }
1809
1810 #[test]
1811 fn test_prune_ignores_bloat_inside_a_nested_repository() {
1812 let tmp = TempDir::new().unwrap();
1813 let repo = tmp.path().join("repo");
1814 create_git_repo_with_commit(&repo);
1815 create_python_project(&repo.join("outer"));
1816
1817 let nested = repo.join("nested");
1820 create_git_repo_with_commit(&nested);
1821 create_python_project(&nested);
1822
1823 let results = prune_repo(&repo, 15, true, true);
1824 assert_eq!(labels(&results), vec!["outer/.venv"]);
1825 }
1826
1827 #[test]
1828 fn test_prune_repo_no_adapters() {
1829 let tmp = TempDir::new().unwrap();
1830 let repo = tmp.path().join("repo");
1831 create_git_repo_with_commit(&repo);
1832 let results = prune_repo(&repo, 15, false, true);
1834 assert!(
1835 results
1836 .iter()
1837 .any(|r| matches!(r.status, PruneStatus::NoBloat))
1838 );
1839 }
1840
1841 #[test]
1842 fn test_prune_all_disabled() {
1843 let tmp = TempDir::new().unwrap();
1844 let _registry_path = tmp.path().join("registry.json");
1845
1846 let mut registry = Registry::default();
1847 let repo_path = PathBuf::from("/nonexistent/repo");
1848 registry.add_repo(repo_path.clone());
1849 registry.repositories.get_mut(&repo_path).unwrap().enabled = false;
1850
1851 let results = prune_all(&mut registry, false, false);
1852 assert!(
1853 results
1854 .iter()
1855 .any(|r| matches!(r.status, PruneStatus::Disabled))
1856 );
1857 }
1858
1859 #[test]
1860 fn test_restore_project_no_adapters() {
1861 let tmp = TempDir::new().unwrap();
1862 let result = restore_project_to_depth(
1863 tmp.path(),
1864 crate::constants::DEFAULT_SCAN_DEPTH,
1865 TEST_TIMEOUT,
1866 );
1867 assert!(result.is_err());
1868 }
1869
1870 #[test]
1871 fn restore_deleted_trusts_the_record_when_the_prune_erased_detection() {
1872 let tmp = TempDir::new().unwrap();
1876 let api = tmp.path().join("api");
1877 fs::create_dir_all(&api).unwrap();
1878 fs::write(api.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1879 let deleted = vec![crate::config::PrunedDir {
1882 repo_path: tmp.path().to_path_buf(),
1883 bloat_dir: "api/.venv".to_string(),
1884 adapter: "venv".to_string(),
1885 size_freed: 0,
1886 runtime: None,
1887 }];
1888 let results = restore_deleted(tmp.path(), &deleted, 4, std::time::Duration::ZERO);
1891
1892 assert_eq!(results.len(), 1);
1893 assert_eq!(results[0].label, "venv (api/.venv)");
1894 if let Err(e) = &results[0].result {
1895 assert!(
1896 !e.to_string().contains("no longer a"),
1897 "the recorded adapter must be attempted, got: {e}"
1898 );
1899 }
1900 }
1901
1902 #[test]
1903 fn a_git_repository_inside_a_bloat_directory_refuses_the_delete() {
1904 let tmp = TempDir::new().unwrap();
1907 let repo = tmp.path().join("repo");
1908 create_git_repo_with_commit(&repo);
1909 create_python_project(&repo);
1910 fs::create_dir_all(repo.join(".venv/src/vendored/.git")).unwrap();
1911
1912 let results = prune_repo_selected(&repo, 0, false, true, Some(&[".venv".to_string()]));
1913
1914 assert_eq!(results.len(), 1);
1915 let PruneStatus::DeleteError(msg) = &results[0].status else {
1916 panic!("expected a refusal, got {:?}", results[0].status);
1917 };
1918 assert!(msg.contains("git repository"), "says why: {msg}");
1919 assert!(repo.join(".venv").exists(), "nothing may be deleted");
1920 assert_eq!(results[0].size_freed, 0);
1921 }
1922
1923 fn status_entry(name: &str, reclaimable: u64) -> RepoStatusEntry {
1924 RepoStatusEntry {
1925 path: PathBuf::from(name),
1926 entry: RepoEntry::new(),
1927 reason: SkipReason::Candidate,
1928 adapters: Vec::new(),
1929 bloat_dirs: Vec::new(),
1930 reclaimable_by_adapter: Vec::new(),
1931 reclaimable_bytes: reclaimable,
1932 last_activity: None,
1933 idle_days: 15,
1934 }
1935 }
1936
1937 #[test]
1938 fn take_top_selects_by_size_but_keeps_the_dashboard_order() {
1939 let repos = [
1940 status_entry("small", 10),
1941 status_entry("big", 300),
1942 status_entry("mid", 200),
1943 ];
1944 let names: Vec<String> = take_top(&repos, Some(2))
1945 .iter()
1946 .map(|e| e.path.display().to_string())
1947 .collect();
1948 assert_eq!(names, vec!["big", "mid"]);
1952 }
1953
1954 #[test]
1955 fn take_top_without_a_limit_or_with_an_oversized_one_returns_everything() {
1956 let repos = [status_entry("a", 1), status_entry("b", 2)];
1957 assert_eq!(take_top(&repos, None).len(), 2);
1958 assert_eq!(take_top(&repos, Some(10)).len(), 2);
1959 assert_eq!(take_top(&repos, Some(0)).len(), 0);
1960 }
1961
1962 #[test]
1963 fn test_restore_project_with_npm() {
1964 let tmp = TempDir::new().unwrap();
1965 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1966 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1967 let results = restore_project_to_depth(
1968 tmp.path(),
1969 crate::constants::DEFAULT_SCAN_DEPTH,
1970 TEST_TIMEOUT,
1971 );
1972 assert!(results.is_ok());
1974 let results = results.unwrap();
1975 assert!(!results.is_empty());
1976 assert_eq!(results[0].0, "npm");
1977 }
1978
1979 #[test]
1980 fn the_scan_never_starts_more_threads_than_there_is_work() {
1981 assert_eq!(clamp_scan_threads(32, 3), 3);
1983 assert_eq!(clamp_scan_threads(0, 0), 1);
1986 assert_eq!(clamp_scan_threads(0, 50), 1);
1987 }
1988
1989 #[test]
1990 fn an_absurd_thread_request_is_clamped_rather_than_honoured() {
1991 assert_eq!(
1993 clamp_scan_threads(9_999, 500),
1994 constants::STATUS_SCAN_MAX_THREADS
1995 );
1996 }
1997
1998 #[test]
1999 fn a_registry_of_one_repository_is_scanned_on_the_calling_thread_alone() {
2000 assert_eq!(clamp_scan_threads(16, 1), 1);
2001 }
2002}