1use std::fs;
16use std::path::{Path, PathBuf};
17use std::time::SystemTime;
18
19use anyhow::Result;
20use chrono::{DateTime, Utc};
21
22use crate::adapters::BloatDir;
23use crate::config::{Registry, RepoEntry};
24use crate::constants;
25use crate::scanner;
26use crate::scanner::git;
27use crate::workspace;
28
29#[derive(Debug, Clone)]
31pub enum PruneStatus {
32 Pruned,
34 SkippedActive,
36 SkippedDryRun,
38 LockfileError(String),
40 NoBloat,
42 Disabled,
44 SkippedIgnored,
46 DeleteError(String),
48 ConfigError(String),
50}
51
52impl std::fmt::Display for PruneStatus {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 match self {
55 PruneStatus::Pruned => write!(f, "Pruned"),
56 PruneStatus::SkippedActive => write!(f, "Skipped (active)"),
57 PruneStatus::SkippedDryRun => write!(f, "Skipped (dry run)"),
58 PruneStatus::LockfileError(e) => write!(f, "Lockfile error: {e}"),
59 PruneStatus::NoBloat => write!(f, "No bloat found"),
60 PruneStatus::Disabled => write!(f, "Disabled"),
61 PruneStatus::SkippedIgnored => write!(
62 f,
63 "Ignored (ignore.devprune.json or ignore config in .devprune.json)"
64 ),
65 PruneStatus::DeleteError(e) => write!(f, "Delete error: {e}"),
66 PruneStatus::ConfigError(e) => write!(f, "Unreadable .devprune.json: {e}"),
67 }
68 }
69}
70
71pub const BYTES_PER_MIB: u64 = 1024 * 1024;
74
75#[derive(Debug, Clone, Default, PartialEq)]
82pub struct AdapterFilter {
83 only: Option<Vec<String>>,
84 skip: Vec<String>,
85}
86
87impl AdapterFilter {
88 pub fn new(only: Option<&str>, skip: Option<&str>) -> Result<Self> {
93 let known: Vec<&'static str> = crate::adapters::get_all_adapters()
94 .iter()
95 .map(|a| a.name())
96 .collect();
97
98 let parse = |raw: &str, flag: &str| -> Result<Vec<String>> {
99 let mut out = Vec::new();
100 for token in raw.split(',') {
101 let name = token.trim().to_lowercase();
102 if name.is_empty() {
103 continue;
104 }
105 if !known.contains(&name.as_str()) {
106 anyhow::bail!(
107 "`--{flag} {name}` names no known package manager. Available: {}.",
108 known.join(", ")
109 );
110 }
111 if !out.contains(&name) {
112 out.push(name);
113 }
114 }
115 if out.is_empty() {
116 anyhow::bail!("`--{flag}` was given no adapter names.");
117 }
118 Ok(out)
119 };
120
121 let only = only.map(|raw| parse(raw, "only")).transpose()?;
122 let skip = skip
123 .map(|raw| parse(raw, "skip"))
124 .transpose()?
125 .unwrap_or_default();
126
127 if let Some(only) = &only {
128 if let Some(clash) = only.iter().find(|n| skip.contains(n)) {
129 anyhow::bail!("`{clash}` is in both --only and --skip; pick one.");
130 }
131 }
132
133 Ok(Self { only, skip })
134 }
135
136 pub fn allows(&self, name: &str) -> bool {
138 if self.skip.iter().any(|s| s == name) {
139 return false;
140 }
141 match &self.only {
142 Some(only) => only.iter().any(|o| o == name),
143 None => true,
144 }
145 }
146
147 pub fn is_unrestricted(&self) -> bool {
149 self.only.is_none() && self.skip.is_empty()
150 }
151
152 pub fn describe(&self) -> Option<String> {
154 if self.is_unrestricted() {
155 return None;
156 }
157 let mut parts = Vec::new();
158 if let Some(only) = &self.only {
159 parts.push(format!("only {}", only.join(", ")));
160 }
161 if !self.skip.is_empty() {
162 parts.push(format!("skipping {}", self.skip.join(", ")));
163 }
164 Some(parts.join("; "))
165 }
166}
167
168#[derive(Debug, Clone)]
174pub struct PruneOptions {
175 pub idle_days: u64,
177 pub dry_run: bool,
179 pub force: bool,
181 pub only_dirs: Option<Vec<String>>,
187 pub adapters: AdapterFilter,
189 pub min_size_bytes: u64,
191 pub scan_depth: usize,
196 pub allow_manifest_rewrite: bool,
198 pub command_timeout_secs: u64,
203}
204
205impl Default for PruneOptions {
206 fn default() -> Self {
207 Self {
208 idle_days: 0,
209 dry_run: false,
210 force: false,
211 only_dirs: None,
212 adapters: AdapterFilter::default(),
213 min_size_bytes: 0,
214 scan_depth: crate::constants::DEFAULT_SCAN_DEPTH,
215 allow_manifest_rewrite: crate::constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
216 command_timeout_secs: crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS,
217 }
218 }
219}
220
221impl PruneOptions {
222 pub fn new(idle_days: u64, dry_run: bool, force: bool) -> Self {
224 Self {
225 idle_days,
226 dry_run,
227 force,
228 ..Self::default()
229 }
230 }
231}
232
233#[derive(Debug, Clone)]
235pub struct PruneResult {
236 pub repo_path: PathBuf,
238 pub adapter_name: String,
240 pub bloat_dir: String,
242 pub size_freed: u64,
244 pub status: PruneStatus,
246}
247
248pub fn prune_repo(
255 repo_path: &Path,
256 idle_days: u64,
257 dry_run: bool,
258 force: bool,
259) -> Vec<PruneResult> {
260 prune_repo_with(repo_path, &PruneOptions::new(idle_days, dry_run, force))
261}
262
263pub fn prune_repo_selected(
272 repo_path: &Path,
273 idle_days: u64,
274 dry_run: bool,
275 force: bool,
276 only: Option<&[String]>,
277) -> Vec<PruneResult> {
278 prune_repo_with(
279 repo_path,
280 &PruneOptions {
281 only_dirs: only.map(<[String]>::to_vec),
282 ..PruneOptions::new(idle_days, dry_run, force)
283 },
284 )
285}
286
287pub fn prune_repo_with(repo_path: &Path, opts: &PruneOptions) -> Vec<PruneResult> {
292 let idle_days = opts.idle_days;
293 let dry_run = opts.dry_run;
294 let force = opts.force;
295 let only = opts.only_dirs.as_deref();
296 let mut results = Vec::new();
297
298 if !scanner::is_git_repo(repo_path) {
300 return results;
301 }
302
303 if repo_path.join(constants::DEVPRUNE_IGNORE_FILE).exists() {
305 results.push(PruneResult {
306 repo_path: repo_path.to_path_buf(),
307 adapter_name: "-".to_string(),
308 bloat_dir: "-".to_string(),
309 size_freed: 0,
310 status: PruneStatus::SkippedIgnored,
311 });
312 return results;
313 }
314
315 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(repo_path) {
319 Ok(cfg) => cfg,
320 Err(e) => {
321 results.push(PruneResult {
322 repo_path: repo_path.to_path_buf(),
323 adapter_name: "-".to_string(),
324 bloat_dir: "-".to_string(),
325 size_freed: 0,
326 status: PruneStatus::ConfigError(e),
327 });
328 return results;
329 }
330 };
331 if per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false) {
332 results.push(PruneResult {
333 repo_path: repo_path.to_path_buf(),
334 adapter_name: "-".to_string(),
335 bloat_dir: "-".to_string(),
336 size_freed: 0,
337 status: PruneStatus::SkippedIgnored,
338 });
339 return results;
340 }
341
342 let effective_idle_days = per_repo_config
344 .as_ref()
345 .and_then(|c| c.override_idle_days)
346 .unwrap_or(idle_days);
347
348 let min_size_bytes = if only.is_some() {
351 0
352 } else {
353 per_repo_config
354 .as_ref()
355 .and_then(|c| c.min_size_mb)
356 .map(|mb| mb.saturating_mul(BYTES_PER_MIB))
357 .unwrap_or(opts.min_size_bytes)
358 };
359
360 if !force {
362 match git::is_repo_idle(repo_path, effective_idle_days) {
363 Ok(false) => {
364 results.push(PruneResult {
365 repo_path: repo_path.to_path_buf(),
366 adapter_name: "-".to_string(),
367 bloat_dir: "-".to_string(),
368 size_freed: 0,
369 status: PruneStatus::SkippedActive,
370 });
371 return results;
372 }
373 Ok(true) => {} Err(e) => {
375 results.push(PruneResult {
376 repo_path: repo_path.to_path_buf(),
377 adapter_name: "-".to_string(),
378 bloat_dir: "-".to_string(),
379 size_freed: 0,
380 status: PruneStatus::LockfileError(format!("Activity check failed: {e}")),
381 });
382 return results;
383 }
384 }
385 }
386
387 let projects = workspace::discover_to_depth(
391 repo_path,
392 workspace::resolve_depth(repo_path, opts.scan_depth),
393 );
394
395 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
399
400 for project in &projects {
401 for adapter in &project.adapters {
402 if !opts.adapters.allows(adapter.name()) {
403 continue;
404 }
405
406 let bloat_dirs: Vec<(String, BloatDir)> = adapter
413 .bloat_dirs(&project.path)
414 .into_iter()
415 .map(|bd| (workspace::relative_label(repo_path, &bd.path), bd))
416 .filter(|(label, _)| only.is_none_or(|names| names.contains(label)))
417 .filter(|(_, bd)| bd.size_bytes >= min_size_bytes)
418 .filter(|(_, bd)| claimed.insert(bd.path.clone()))
419 .collect();
420
421 if bloat_dirs.is_empty() {
422 continue;
423 }
424
425 if !dry_run {
427 let policy = crate::adapters::EnforcePolicy {
428 allow_rewrite: opts.allow_manifest_rewrite,
429 timeout: std::time::Duration::from_secs(opts.command_timeout_secs),
430 };
431 if let Err(e) = adapter.enforce_lockfile(&project.path, policy) {
432 for (label, _) in &bloat_dirs {
433 results.push(PruneResult {
434 repo_path: repo_path.to_path_buf(),
435 adapter_name: adapter.name().to_string(),
436 bloat_dir: label.clone(),
437 size_freed: 0,
438 status: PruneStatus::LockfileError(e.to_string()),
439 });
440 }
441 continue;
442 }
443 }
444
445 for (label, bd) in bloat_dirs {
446 if dry_run {
447 results.push(PruneResult {
448 repo_path: repo_path.to_path_buf(),
449 adapter_name: adapter.name().to_string(),
450 bloat_dir: label,
451 size_freed: bd.size_bytes,
452 status: PruneStatus::SkippedDryRun,
453 });
454 continue;
455 }
456
457 if fs::symlink_metadata(&bd.path)
461 .map(|m| m.file_type().is_symlink())
462 .unwrap_or(false)
463 {
464 results.push(PruneResult {
465 repo_path: repo_path.to_path_buf(),
466 adapter_name: adapter.name().to_string(),
467 bloat_dir: label,
468 size_freed: 0,
469 status: PruneStatus::DeleteError(format!(
470 "`{}` is a symlink — refusing to delete linked storage. \
471 Remove the link yourself if you really want it gone.",
472 bd.path.display()
473 )),
474 });
475 continue;
476 }
477
478 let size = bd.size_bytes;
479 match fs::remove_dir_all(&bd.path) {
480 Ok(()) => {
481 results.push(PruneResult {
482 repo_path: repo_path.to_path_buf(),
483 adapter_name: adapter.name().to_string(),
484 bloat_dir: label,
485 size_freed: size,
486 status: PruneStatus::Pruned,
487 });
488 }
489 Err(e) => {
490 results.push(PruneResult {
491 repo_path: repo_path.to_path_buf(),
492 adapter_name: adapter.name().to_string(),
493 bloat_dir: label,
494 size_freed: 0,
495 status: PruneStatus::DeleteError(e.to_string()),
496 });
497 }
498 }
499 }
500 }
501 }
502
503 if results.is_empty() {
505 results.push(PruneResult {
506 repo_path: repo_path.to_path_buf(),
507 adapter_name: "-".to_string(),
508 bloat_dir: "-".to_string(),
509 size_freed: 0,
510 status: PruneStatus::NoBloat,
511 });
512 }
513
514 results
515}
516
517fn collect_bloat(
524 repo_path: &Path,
525 min_size_bytes: u64,
526 depth: usize,
527) -> (Vec<String>, Vec<BloatDir>) {
528 let mut adapter_names: Vec<String> = Vec::new();
529 let mut bloat: Vec<BloatDir> = Vec::new();
530 let mut claimed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
531
532 for project in workspace::discover_to_depth(repo_path, depth) {
533 for adapter in &project.adapters {
534 let name = adapter.name();
535 if !adapter_names.iter().any(|existing| existing == name) {
536 adapter_names.push(name.to_string());
537 }
538 for bd in adapter.bloat_dirs(&project.path) {
539 if bd.size_bytes < min_size_bytes {
540 continue;
541 }
542 if claimed.insert(bd.path.clone()) {
543 bloat.push(BloatDir {
544 name: workspace::relative_label(repo_path, &bd.path),
545 ..bd
546 });
547 }
548 }
549 }
550 }
551
552 (adapter_names, bloat)
553}
554
555pub fn prune_all_with(registry: &mut Registry, opts: &PruneOptions) -> Vec<PruneResult> {
560 let mut all_results = Vec::new();
561
562 let mut repos: Vec<(PathBuf, u64, bool)> = registry
568 .repositories
569 .iter()
570 .map(|(path, entry)| {
571 let idle_days = entry
572 .override_idle_days
573 .unwrap_or(registry.settings.idle_days);
574 (path.clone(), idle_days, entry.enabled)
575 })
576 .collect();
577 repos.sort_by(|a, b| a.0.cmp(&b.0));
578
579 for (path, idle_days, enabled) in repos {
580 if !enabled {
581 all_results.push(PruneResult {
582 repo_path: path.clone(),
583 adapter_name: "-".to_string(),
584 bloat_dir: "-".to_string(),
585 size_freed: 0,
586 status: PruneStatus::Disabled,
587 });
588 continue;
589 }
590
591 let results = prune_repo_with(
592 &path,
593 &PruneOptions {
594 idle_days,
595 ..opts.clone()
596 },
597 );
598
599 let path_freed: u64 = results
600 .iter()
601 .filter(|r| matches!(r.status, PruneStatus::Pruned))
602 .map(|r| r.size_freed)
603 .sum();
604
605 if path_freed > 0 {
606 registry.mark_pruned(&path, path_freed);
607 }
608
609 all_results.extend(results);
610 }
611
612 all_results
613}
614
615pub fn prune_all(registry: &mut Registry, dry_run: bool, force: bool) -> Vec<PruneResult> {
617 prune_all_with(registry, &PruneOptions::new(0, dry_run, force))
618}
619
620pub fn restore_project(project_path: &Path) -> Result<Vec<(String, Result<()>)>> {
626 restore_project_to_depth(project_path, crate::constants::DEFAULT_SCAN_DEPTH)
627}
628
629pub fn restore_project_to_depth(
635 project_path: &Path,
636 global_depth: usize,
637) -> Result<Vec<(String, Result<()>)>> {
638 let depth = workspace::resolve_depth(project_path, global_depth);
639 let projects = workspace::discover_to_depth(project_path, depth);
640
641 if projects.is_empty() {
642 anyhow::bail!(
643 "No recognized package manager found in {}",
644 project_path.display()
645 );
646 }
647
648 let mut results = Vec::new();
649 for project in &projects {
650 for adapter in &project.adapters {
651 let label = if project.relative == "." {
652 adapter.name().to_string()
653 } else {
654 format!("{} ({})", adapter.name(), project.relative)
655 };
656 results.push((label, adapter.restore(&project.path)));
657 }
658 }
659
660 Ok(results)
661}
662
663fn owning_project(bloat_label: &str) -> &str {
671 match bloat_label.rsplit_once('/') {
672 Some((parent, _)) => parent,
673 None => ".",
674 }
675}
676
677pub fn restore_deleted(
689 repo_path: &Path,
690 deleted: &[(String, String)],
691 global_depth: usize,
692) -> Vec<(String, Result<()>)> {
693 let depth = workspace::resolve_depth(repo_path, global_depth);
694 let projects = workspace::discover_to_depth(repo_path, depth);
695
696 let mut results = Vec::new();
697 for (bloat_label, adapter_name) in deleted {
698 let wanted = owning_project(bloat_label);
699 let label = format!("{adapter_name} ({bloat_label})");
700
701 let found = projects
702 .iter()
703 .filter(|p| p.relative == wanted)
704 .flat_map(|p| p.adapters.iter().map(move |a| (p, a)))
705 .find(|(_, a)| a.name() == adapter_name);
706
707 match found {
708 Some((project, adapter)) => results.push((label, adapter.restore(&project.path))),
709 None => results.push((
710 label,
711 Err(anyhow::anyhow!(
712 "`{wanted}` in {} is no longer a {adapter_name} project — it may have been \
713 moved or removed since the prune. Restore it by hand if it still exists.",
714 repo_path.display()
715 )),
716 )),
717 }
718 }
719
720 results
721}
722
723#[derive(Debug, Clone, PartialEq)]
725pub enum SkipReason {
726 Candidate,
728 Active,
730 Ignored,
733 NoBloat,
735 PathMissing,
737 ConfigError(String),
739}
740
741impl std::fmt::Display for SkipReason {
742 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
743 match self {
744 SkipReason::Candidate => write!(f, "Candidate"),
745 SkipReason::Active => write!(f, "Active (not idle)"),
746 SkipReason::Ignored => write!(f, "Ignored"),
747 SkipReason::NoBloat => write!(f, "No bloat found"),
748 SkipReason::PathMissing => write!(f, "Path missing"),
749 SkipReason::ConfigError(_) => write!(f, "Unreadable .devprune.json"),
750 }
751 }
752}
753
754#[derive(Debug, Clone)]
756pub struct RepoStatusEntry {
757 pub path: PathBuf,
759 pub entry: RepoEntry,
761 pub reason: SkipReason,
763 pub adapters: Vec<String>,
765 pub bloat_dirs: Vec<BloatDir>,
767 pub reclaimable_bytes: u64,
769 pub last_activity: Option<DateTime<Utc>>,
771 pub idle_days: u64,
773}
774
775pub fn get_full_status(registry: &Registry) -> Vec<RepoStatusEntry> {
780 let mut entries: Vec<RepoStatusEntry> = Vec::new();
781
782 for (path, reg_entry) in ®istry.repositories {
783 let registry_idle_days = reg_entry
784 .override_idle_days
785 .unwrap_or(registry.settings.idle_days);
786
787 if !path.exists() {
790 entries.push(RepoStatusEntry {
791 path: path.clone(),
792 entry: reg_entry.clone(),
793 reason: SkipReason::PathMissing,
794 adapters: Vec::new(),
795 bloat_dirs: Vec::new(),
796 reclaimable_bytes: 0,
797 last_activity: None,
798 idle_days: registry_idle_days,
799 });
800 continue;
801 }
802
803 let per_repo_config = match crate::config::PerRepoConfig::load_with_diagnostics(path) {
808 Ok(cfg) => cfg,
809 Err(e) => {
810 entries.push(RepoStatusEntry {
811 path: path.clone(),
812 entry: reg_entry.clone(),
813 reason: SkipReason::ConfigError(e),
814 adapters: Vec::new(),
815 bloat_dirs: Vec::new(),
816 reclaimable_bytes: 0,
817 last_activity: last_activity_time(path),
818 idle_days: registry_idle_days,
819 });
820 continue;
821 }
822 };
823 let idle_days = per_repo_config
824 .as_ref()
825 .and_then(|c| c.override_idle_days)
826 .unwrap_or(registry_idle_days);
827
828 let is_ignored = !reg_entry.enabled
830 || path.join(constants::DEVPRUNE_IGNORE_FILE).exists()
831 || per_repo_config.as_ref().map(|c| c.ignore).unwrap_or(false);
832 if is_ignored {
833 entries.push(RepoStatusEntry {
834 path: path.clone(),
835 entry: reg_entry.clone(),
836 reason: SkipReason::Ignored,
837 adapters: Vec::new(),
838 bloat_dirs: Vec::new(),
839 reclaimable_bytes: 0,
840 last_activity: last_activity_time(path),
841 idle_days,
842 });
843 continue;
844 }
845
846 let activity = git::get_last_activity(path).ok().flatten();
851 let activity_time = to_utc(activity);
852 let is_idle = git::is_idle_at(activity, idle_days);
853
854 let min_size_bytes = per_repo_config
856 .as_ref()
857 .and_then(|c| c.min_size_mb)
858 .unwrap_or(registry.settings.min_size_mb)
859 .saturating_mul(BYTES_PER_MIB);
860 let depth = workspace::clamp_depth(
864 per_repo_config
865 .as_ref()
866 .and_then(|c| c.scan_depth)
867 .unwrap_or(registry.settings.scan_depth),
868 );
869 let (adapter_names, all_bloat) = collect_bloat(path, min_size_bytes, depth);
870 let reclaimable: u64 = all_bloat.iter().map(|b| b.size_bytes).sum();
871
872 let reason = if !is_idle {
873 SkipReason::Active
874 } else if all_bloat.is_empty() {
875 SkipReason::NoBloat
876 } else {
877 SkipReason::Candidate
878 };
879
880 entries.push(RepoStatusEntry {
881 path: path.clone(),
882 entry: reg_entry.clone(),
883 reason,
884 adapters: adapter_names,
885 bloat_dirs: all_bloat,
886 reclaimable_bytes: reclaimable,
887 last_activity: activity_time,
888 idle_days,
889 });
890 }
891
892 entries.sort_by(|a, b| {
894 let a_cand = matches!(a.reason, SkipReason::Candidate);
895 let b_cand = matches!(b.reason, SkipReason::Candidate);
896 b_cand.cmp(&a_cand).then_with(|| a.path.cmp(&b.path))
897 });
898
899 entries
900}
901
902pub fn compute_display_name(repo_path: &Path, all_paths: &[PathBuf]) -> String {
907 if let Some(cfg) = crate::config::PerRepoConfig::load_with_diagnostics(repo_path)
910 .ok()
911 .flatten()
912 {
913 if let Some(custom) = cfg.project_name {
914 if !custom.trim().is_empty() {
915 return custom;
916 }
917 }
918 }
919
920 let folder_name = repo_path
921 .file_name()
922 .map(|n| n.to_string_lossy().to_string())
923 .unwrap_or_else(|| crate::output::clean_path(repo_path));
924
925 let duplicate_count = all_paths
927 .iter()
928 .filter(|p| {
929 p.file_name()
930 .map(|n| n.to_string_lossy().to_string())
931 .as_deref()
932 == Some(&folder_name)
933 })
934 .count();
935
936 if duplicate_count > 1 {
937 if let Some(parent) = repo_path.parent() {
938 if let Some(parent_name) = parent.file_name() {
939 return format!("{}/{}", parent_name.to_string_lossy(), folder_name);
940 }
941 }
942 }
943
944 folder_name
945}
946
947fn last_activity_time(path: &Path) -> Option<DateTime<Utc>> {
950 to_utc(git::get_last_activity(path).ok().flatten())
951}
952
953fn to_utc(system_time: Option<SystemTime>) -> Option<DateTime<Utc>> {
955 system_time.map(|st| {
956 let duration = st
957 .duration_since(SystemTime::UNIX_EPOCH)
958 .unwrap_or_default();
959 DateTime::from_timestamp(duration.as_secs() as i64, 0).unwrap_or_default()
960 })
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966 use std::fs;
967 use std::process::Command;
968 use tempfile::TempDir;
969
970 fn create_git_repo_with_commit(path: &Path) {
971 fs::create_dir_all(path).unwrap();
972 Command::new("git")
973 .args(["init"])
974 .current_dir(path)
975 .output()
976 .unwrap();
977 fs::write(path.join("README.md"), "# Test").unwrap();
978 Command::new("git")
979 .args(["add", "."])
980 .current_dir(path)
981 .output()
982 .unwrap();
983 Command::new("git")
984 .args([
985 "-c",
986 "user.name=Test",
987 "-c",
988 "user.email=test@test.com",
989 "commit",
990 "-m",
991 "initial",
992 ])
993 .current_dir(path)
994 .output()
995 .unwrap();
996 }
997
998 #[test]
999 fn a_bloat_label_names_the_project_that_owns_it() {
1000 assert_eq!(owning_project("node_modules"), ".");
1001 assert_eq!(owning_project("frontend/node_modules"), "frontend");
1002 assert_eq!(
1003 owning_project("packages/@scope/app/.venv"),
1004 "packages/@scope/app"
1005 );
1006 }
1007
1008 #[test]
1009 fn restore_deleted_touches_only_the_projects_that_were_pruned() {
1010 let tmp = TempDir::new().unwrap();
1013 let root = tmp.path();
1014 for name in ["frontend", "docs"] {
1015 let dir = root.join(name);
1016 fs::create_dir_all(&dir).unwrap();
1017 fs::write(dir.join("package.json"), "{}").unwrap();
1018 fs::write(dir.join("package-lock.json"), "{}").unwrap();
1019 }
1020
1021 let deleted = vec![("frontend/node_modules".to_string(), "npm".to_string())];
1022 let results = restore_deleted(root, &deleted, 4);
1023
1024 assert_eq!(results.len(), 1, "one recorded directory, one attempt");
1025 assert_eq!(results[0].0, "npm (frontend/node_modules)");
1026 }
1027
1028 #[test]
1029 fn restore_deleted_reports_a_project_that_is_no_longer_there() {
1030 let tmp = TempDir::new().unwrap();
1033 let deleted = vec![("services/api/.venv".to_string(), "uv".to_string())];
1034 let results = restore_deleted(tmp.path(), &deleted, 4);
1035
1036 assert_eq!(results.len(), 1);
1037 assert_eq!(results[0].0, "uv (services/api/.venv)");
1038 let err = results[0].1.as_ref().unwrap_err().to_string();
1039 assert!(err.contains("services/api"), "names the missing project");
1040 assert!(err.contains("uv"), "names the adapter that owned it");
1041 }
1042
1043 #[test]
1044 fn test_prune_status_display() {
1045 assert_eq!(PruneStatus::Pruned.to_string(), "Pruned");
1046 assert_eq!(PruneStatus::SkippedActive.to_string(), "Skipped (active)");
1047 assert_eq!(PruneStatus::SkippedDryRun.to_string(), "Skipped (dry run)");
1048 }
1049
1050 #[test]
1051 fn test_prune_repo_non_git() {
1052 let tmp = TempDir::new().unwrap();
1053 let results = prune_repo(tmp.path(), 15, false, false);
1054 assert!(results.is_empty());
1055 }
1056
1057 #[test]
1058 fn test_prune_repo_active_skipped() {
1059 let tmp = TempDir::new().unwrap();
1060 let repo = tmp.path().join("repo");
1061 create_git_repo_with_commit(&repo);
1062 let results = prune_repo(&repo, 15, false, false);
1064 assert_eq!(results.len(), 1);
1065 assert!(matches!(results[0].status, PruneStatus::SkippedActive));
1066 }
1067
1068 #[test]
1071 fn test_unparseable_per_repo_config_skips_the_repo() {
1072 let tmp = TempDir::new().unwrap();
1073 let repo = tmp.path().join("repo");
1074 create_git_repo_with_commit(&repo);
1075 fs::create_dir(repo.join("target")).unwrap();
1076 fs::write(repo.join("target").join("dummy"), "data").unwrap();
1077 fs::write(
1078 repo.join("Cargo.toml"),
1079 "[package]\nname = \"t\"\nversion = \"0.1.0\"",
1080 )
1081 .unwrap();
1082 fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1083 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1085
1086 let results = prune_repo(&repo, 15, false, true);
1088
1089 assert_eq!(results.len(), 1);
1090 assert!(
1091 matches!(results[0].status, PruneStatus::ConfigError(_)),
1092 "expected ConfigError, got {:?}",
1093 results[0].status
1094 );
1095 assert!(repo.join("target").exists(), "target must survive");
1096 }
1097
1098 #[test]
1102 fn a_broken_config_is_reported_by_status_and_not_as_a_candidate() {
1103 let tmp = TempDir::new().unwrap();
1104 let repo = tmp.path().join("repo");
1105 create_git_repo_with_commit(&repo);
1106 create_python_project(&repo);
1107 fs::write(repo.join(".devprune.json"), "{ \"ignore\": true, }").unwrap();
1108
1109 let mut registry = Registry::default();
1110 registry.add_repo(repo.clone());
1111
1112 let entries = get_full_status(®istry);
1113 assert_eq!(entries.len(), 1);
1114 assert!(
1115 matches!(entries[0].reason, SkipReason::ConfigError(_)),
1116 "expected ConfigError, got {:?}",
1117 entries[0].reason
1118 );
1119 assert_eq!(entries[0].reclaimable_bytes, 0);
1120 }
1121
1122 #[test]
1123 fn test_prune_repo_dry_run() {
1124 let tmp = TempDir::new().unwrap();
1125 let repo = tmp.path().join("repo");
1126 create_git_repo_with_commit(&repo);
1127 fs::create_dir(repo.join("target")).unwrap();
1129 fs::write(repo.join("target").join("dummy"), "data").unwrap();
1130 fs::write(
1131 repo.join("Cargo.toml"),
1132 "[package]\nname = \"test\"\nversion = \"0.1.0\"\nedition = \"2024\"",
1133 )
1134 .unwrap();
1135 fs::write(repo.join("Cargo.lock"), "# lockfile").unwrap();
1136 let results = prune_repo(&repo, 15, true, true);
1138 let dry_run_results: Vec<_> = results
1139 .iter()
1140 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
1141 .collect();
1142 assert!(!dry_run_results.is_empty());
1143 assert!(repo.join("target").exists());
1145 }
1146
1147 fn create_python_project(dir: &Path) {
1152 fs::create_dir_all(dir).unwrap();
1153 fs::write(dir.join("requirements.txt"), "requests==2.32.3\n").unwrap();
1154 let venv = dir.join(".venv");
1155 fs::create_dir_all(&venv).unwrap();
1156 fs::write(venv.join("pyvenv.cfg"), "home = /usr\n").unwrap();
1157 fs::write(venv.join("payload.bin"), vec![0u8; 4096]).unwrap();
1158 }
1159
1160 fn labels(results: &[PruneResult]) -> Vec<String> {
1162 let mut out: Vec<String> = results.iter().map(|r| r.bloat_dir.clone()).collect();
1163 out.sort();
1164 out
1165 }
1166
1167 #[test]
1168 fn test_prune_finds_several_ecosystems_at_the_repo_root() {
1169 let tmp = TempDir::new().unwrap();
1170 let repo = tmp.path().join("repo");
1171 create_git_repo_with_commit(&repo);
1172
1173 fs::write(repo.join("Cargo.toml"), "[package]\nname = \"x\"").unwrap();
1174 fs::create_dir(repo.join("target")).unwrap();
1175 fs::write(repo.join("package.json"), "{}").unwrap();
1176 fs::write(repo.join("package-lock.json"), "{}").unwrap();
1177 fs::create_dir(repo.join("node_modules")).unwrap();
1178 create_python_project(&repo);
1179
1180 let results = prune_repo(&repo, 15, true, true);
1181 assert_eq!(labels(&results), vec![".venv", "node_modules", "target"]);
1182 }
1183
1184 #[test]
1185 fn test_prune_finds_ecosystems_at_different_depths() {
1186 let tmp = TempDir::new().unwrap();
1187 let repo = tmp.path().join("repo");
1188 create_git_repo_with_commit(&repo);
1189
1190 fs::create_dir_all(repo.join("frontend")).unwrap();
1191 fs::write(repo.join("frontend/package.json"), "{}").unwrap();
1192 fs::write(repo.join("frontend/pnpm-lock.yaml"), "").unwrap();
1193 fs::create_dir(repo.join("frontend/node_modules")).unwrap();
1194
1195 fs::create_dir_all(repo.join("tools/cli")).unwrap();
1196 fs::write(repo.join("tools/cli/Cargo.toml"), "[package]\nname = \"y\"").unwrap();
1197 fs::create_dir(repo.join("tools/cli/target")).unwrap();
1198
1199 create_python_project(&repo.join("services/api"));
1200
1201 let results = prune_repo(&repo, 15, true, true);
1202 assert_eq!(
1203 labels(&results),
1204 vec![
1205 "frontend/node_modules",
1206 "services/api/.venv",
1207 "tools/cli/target",
1208 ]
1209 );
1210 }
1211
1212 #[test]
1213 fn test_prune_deletes_only_the_selected_nested_directory() {
1214 let tmp = TempDir::new().unwrap();
1215 let repo = tmp.path().join("repo");
1216 create_git_repo_with_commit(&repo);
1217 create_python_project(&repo.join("a"));
1218 create_python_project(&repo.join("b"));
1219
1220 let results = prune_repo_selected(&repo, 0, false, true, Some(&["a/.venv".to_string()]));
1221
1222 assert_eq!(labels(&results), vec!["a/.venv"]);
1223 assert!(matches!(results[0].status, PruneStatus::Pruned));
1224 assert!(!repo.join("a/.venv").exists());
1225 assert!(repo.join("b/.venv").exists());
1226 }
1227
1228 #[test]
1229 fn test_prune_ignores_bloat_inside_a_nested_repository() {
1230 let tmp = TempDir::new().unwrap();
1231 let repo = tmp.path().join("repo");
1232 create_git_repo_with_commit(&repo);
1233 create_python_project(&repo.join("outer"));
1234
1235 let nested = repo.join("nested");
1238 create_git_repo_with_commit(&nested);
1239 create_python_project(&nested);
1240
1241 let results = prune_repo(&repo, 15, true, true);
1242 assert_eq!(labels(&results), vec!["outer/.venv"]);
1243 }
1244
1245 #[test]
1246 fn test_prune_repo_no_adapters() {
1247 let tmp = TempDir::new().unwrap();
1248 let repo = tmp.path().join("repo");
1249 create_git_repo_with_commit(&repo);
1250 let results = prune_repo(&repo, 15, false, true);
1252 assert!(
1253 results
1254 .iter()
1255 .any(|r| matches!(r.status, PruneStatus::NoBloat))
1256 );
1257 }
1258
1259 #[test]
1260 fn test_prune_all_disabled() {
1261 let tmp = TempDir::new().unwrap();
1262 let _registry_path = tmp.path().join("registry.json");
1263
1264 let mut registry = Registry::default();
1265 let repo_path = PathBuf::from("/nonexistent/repo");
1266 registry.add_repo(repo_path.clone());
1267 registry.repositories.get_mut(&repo_path).unwrap().enabled = false;
1268
1269 let results = prune_all(&mut registry, false, false);
1270 assert!(
1271 results
1272 .iter()
1273 .any(|r| matches!(r.status, PruneStatus::Disabled))
1274 );
1275 }
1276
1277 #[test]
1278 fn test_restore_project_no_adapters() {
1279 let tmp = TempDir::new().unwrap();
1280 let result = restore_project(tmp.path());
1281 assert!(result.is_err());
1282 }
1283
1284 #[test]
1285 fn test_restore_project_with_npm() {
1286 let tmp = TempDir::new().unwrap();
1287 fs::write(tmp.path().join("package.json"), "{}").unwrap();
1288 fs::write(tmp.path().join("package-lock.json"), "{}").unwrap();
1289 let results = restore_project(tmp.path());
1290 assert!(results.is_ok());
1292 let results = results.unwrap();
1293 assert!(!results.is_empty());
1294 assert_eq!(results[0].0, "npm");
1295 }
1296}