1use serde_json::{Value, json};
20
21use crate::config::{Registry, Settings};
22use crate::constants;
23use crate::engine::{PruneResult, PruneStatus, RepoStatusEntry, SkipReason};
24use crate::output::clean_path;
25
26pub const SCHEMA_VERSION: u32 = 1;
28
29fn status_tag(status: &PruneStatus) -> &'static str {
34 match status {
35 PruneStatus::Pruned => "pruned",
36 PruneStatus::SkippedActive => "skipped_active",
37 PruneStatus::SkippedAdapterWindow(_) => "skipped_adapter_window",
38 PruneStatus::SkippedDryRun => "skipped_dry_run",
39 PruneStatus::LockfileError(_) => "lockfile_error",
40 PruneStatus::ActivityCheckError(_) => "activity_check_error",
41 PruneStatus::PathMissing => "path_missing",
42 PruneStatus::NotARepo => "not_a_repo",
43 PruneStatus::NoBloat => "no_bloat",
44 PruneStatus::Disabled => "disabled",
45 PruneStatus::SkippedIgnored => "ignored",
46 PruneStatus::DeleteError(_) => "delete_error",
47 PruneStatus::ConfigError(_) => "config_error",
48 PruneStatus::SkippedSymlink(_) => "skipped_symlink",
49 PruneStatus::SkippedDeclaration(_) => "skipped_declaration",
50 PruneStatus::SkippedNestedRepo(_) => "skipped_nested_repo",
51 }
52}
53
54fn status_message(status: &PruneStatus) -> Option<&str> {
56 match status {
57 PruneStatus::LockfileError(e)
58 | PruneStatus::ActivityCheckError(e)
59 | PruneStatus::DeleteError(e)
60 | PruneStatus::ConfigError(e)
61 | PruneStatus::SkippedSymlink(e)
62 | PruneStatus::SkippedDeclaration(e)
63 | PruneStatus::SkippedNestedRepo(e) => Some(e.trim()),
64 _ => None,
65 }
66}
67
68pub fn lockfile_fix_command(adapter: &str) -> Option<&'static str> {
79 Some(match adapter {
80 "npm" => "npm install --package-lock-only --ignore-scripts",
81 "pnpm" => "pnpm install --lockfile-only",
82 "yarn" => "yarn install --mode update-lockfile",
83 "bun" => "bun install",
86 "deno" => "deno install",
89 "uv" => "uv lock",
90 "poetry" => "poetry lock",
91 "pdm" => "pdm lock",
92 "pipenv" => "pipenv lock",
93 "cargo" => "cargo generate-lockfile",
94 "go" => "go mod tidy",
95 "composer" => "composer update --no-install",
96 "bundler" => "bundle lock",
97 "cocoapods" => "pod install",
98 "mix" | "mix_build" => "mix deps.get",
100 "terraform" => "terraform providers lock",
103 "dart" => "dart pub get",
106 _ => return None,
112 })
113}
114
115fn result_value(result: &PruneResult) -> Value {
116 let mut obj = json!({
117 "repository": clean_path(&result.repo_path),
118 "adapter": result.adapter_name,
119 "directory": result.bloat_dir,
120 "status": status_tag(&result.status),
121 "bytes": result.size_freed,
122 "shared_bytes": result.shared_bytes,
123 });
124
125 if let Some(message) = status_message(&result.status) {
126 obj["message"] = json!(message);
127 }
128 if matches!(result.status, PruneStatus::LockfileError(_))
129 && let Some(fix) = lockfile_fix_command(&result.adapter_name)
130 {
131 obj["fix_command"] = json!(fix);
132 }
133 obj
134}
135
136pub fn run_document(results: &[PruneResult], dry_run: bool) -> Value {
141 let bytes_freed: u64 = results
142 .iter()
143 .filter(|r| matches!(r.status, PruneStatus::Pruned))
144 .map(|r| r.size_freed)
145 .sum();
146 let directories_pruned = results
147 .iter()
148 .filter(|r| matches!(r.status, PruneStatus::Pruned))
149 .count();
150 let bytes_reclaimable: u64 = results
151 .iter()
152 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
153 .map(|r| r.size_freed)
154 .sum();
155 let errors = results
156 .iter()
157 .filter(|r| {
158 matches!(
159 r.status,
160 PruneStatus::LockfileError(_)
161 | PruneStatus::ActivityCheckError(_)
162 | PruneStatus::DeleteError(_)
163 | PruneStatus::ConfigError(_)
164 )
165 })
166 .count();
167
168 json!({
169 "schema": SCHEMA_VERSION,
170 "version": constants::VERSION,
171 "command": "run",
172 "dry_run": dry_run,
173 "results": results.iter().map(result_value).collect::<Vec<_>>(),
174 "summary": {
175 "bytes_freed": bytes_freed,
176 "bytes_reclaimable": bytes_reclaimable,
177 "directories_pruned": directories_pruned,
178 "errors": errors,
179 },
180 })
181}
182
183fn reason_tag(reason: &SkipReason) -> &'static str {
185 match reason {
186 SkipReason::Candidate => "candidate",
187 SkipReason::Active => "active",
188 SkipReason::Ignored => "ignored",
189 SkipReason::NoBloat => "no_bloat",
190 SkipReason::PathMissing => "path_missing",
191 SkipReason::ConfigError(_) => "config_error",
192 }
193}
194
195fn settings_value(settings: &Settings) -> Value {
196 json!({
197 "idle_days": settings.idle_days,
198 "check_interval_days": settings.check_interval_days,
199 "auto_setup": settings.auto_setup,
200 "auto_hooks": settings.auto_hooks,
201 "auto_daemon": settings.auto_daemon,
202 "require_confirmation": settings.require_confirmation,
203 "command_timeout_secs": settings.command_timeout_secs,
204 "min_size_mb": settings.min_size_mb,
205 "update_check": settings.update_check,
206 })
207}
208
209fn repo_value(registry: &Registry, entry: &RepoStatusEntry) -> Value {
210 let mut obj = json!({
211 "path": clean_path(&entry.path),
212 "state": reason_tag(&entry.reason),
213 "enabled": entry.entry.enabled,
214 "idle_days": entry.idle_days,
215 "last_activity": entry.last_activity.map(|t| t.to_rfc3339()),
216 "last_pruned_at": entry.entry.last_pruned_at.map(|t| t.to_rfc3339()),
217 "bytes_freed": entry.entry.total_freed_bytes,
218 "added_at": entry.entry.added_at.to_rfc3339(),
219 "adapters": entry.adapters,
220 "reclaimable_bytes": entry.reclaimable_bytes,
221 "directories": entry.bloat_dirs.iter().map(|b| json!({
222 "name": b.name,
223 "path": clean_path(&b.path),
224 "bytes": b.size_bytes,
225 "shared_bytes": b.shared_bytes,
226 })).collect::<Vec<_>>(),
227 "restore_estimate_secs": registry
230 .estimate_restore(&entry.reclaimable_by_adapter)
231 .map(|(secs, _)| secs.round() as u64),
232 });
233
234 if let SkipReason::ConfigError(e) = &entry.reason {
239 obj["error"] = json!(e);
240 }
241 obj
242}
243
244pub fn status_document(
254 registry: &Registry,
255 repos: &[RepoStatusEntry],
256 daemon: &str,
257 hooks: &str,
258 top: Option<usize>,
259) -> Value {
260 let reclaimable: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
261 let candidates = repos
262 .iter()
263 .filter(|r| matches!(r.reason, SkipReason::Candidate))
264 .count();
265 let listed = crate::engine::take_top(repos, top);
266
267 let mut by_adapter: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
269 for repo in repos {
270 for (adapter, bytes) in &repo.reclaimable_by_adapter {
271 *by_adapter.entry(adapter.clone()).or_default() += bytes;
272 }
273 }
274 let estimate = registry.estimate_restore(&by_adapter.into_iter().collect::<Vec<_>>());
275
276 let mut doc = json!({
277 "schema": SCHEMA_VERSION,
278 "version": constants::VERSION,
279 "command": "status",
280 "config_path": Registry::registry_path().map(|p| clean_path(&p)).ok(),
281 "integrations": { "daemon": daemon, "git_hooks": hooks },
282 "settings": settings_value(®istry.settings),
283 "totals": {
284 "repositories": registry.repo_count(),
285 "candidates": candidates,
286 "reclaimable_bytes": reclaimable,
287 "historical_bytes_freed": registry.total_freed_bytes,
288 "prune_passes": registry.total_pruned_count,
289 "restore_estimate": estimate.map(|(secs, covered)| json!({
294 "seconds": secs.round() as u64,
295 "covered_bytes": covered,
296 "samples": registry.restore_rates.values().map(|r| r.samples as u64).sum::<u64>(),
297 })),
298 },
299 "repositories": listed.iter().map(|r| repo_value(registry, r)).collect::<Vec<_>>(),
300 });
301
302 if let Some(n) = top {
305 doc["top"] = json!(n);
306 }
307 doc
308}
309
310pub fn stats_document(registry: &Registry, passes: &[crate::history::Pass]) -> Value {
325 let (managers, managers_uncounted) = crate::commands::stats::rank_managers(passes);
326 let (triggers, triggers_uncounted) = crate::commands::stats::split_by_trigger(passes);
327
328 let mut repos: Vec<(&std::path::PathBuf, &crate::config::RepoEntry)> =
329 registry.repositories.iter().collect();
330 repos.sort_by(|a, b| {
331 b.1.total_freed_bytes
332 .cmp(&a.1.total_freed_bytes)
333 .then_with(|| a.0.cmp(b.0))
334 });
335
336 json!({
337 "schema": SCHEMA_VERSION,
338 "version": constants::VERSION,
339 "command": "stats",
340 "history_starts_at": constants::HISTORY_STARTS_AT,
341 "detail_starts_at": constants::PRUNE_LOG_STARTS_AT,
344 "lifetime": {
345 "bytes_freed": registry.total_freed_bytes,
346 "cache_bytes_freed": registry.total_cache_freed_bytes,
350 "container_bytes_freed": registry.total_container_freed_bytes,
351 "prune_passes": registry.total_pruned_count,
354 "repositories": registry.repo_count(),
355 },
356 "last_prune": registry.last_prune.as_ref().map(|p| json!({
357 "at": p.at.to_rfc3339(),
358 "bytes_freed": p.dirs.iter().map(|d| d.size_freed).sum::<u64>(),
359 "directories": p.dirs.len(),
360 })),
361 "recent_passes": registry.prune_history.iter().rev().map(|p| json!({
362 "at": p.at.to_rfc3339(),
363 "bytes_freed": p.bytes_freed,
364 "directories": p.dirs_removed,
365 "repositories": p.repos_touched,
366 })).collect::<Vec<_>>(),
367 "repositories": repos.iter().map(|(path, entry)| json!({
368 "path": clean_path(path),
369 "bytes_freed": entry.total_freed_bytes,
370 "last_pruned_at": entry.last_pruned_at.map(|t| t.to_rfc3339()),
371 })).collect::<Vec<_>>(),
372 "by_manager": {
375 "passes_not_counted": managers_uncounted,
376 "managers": managers.iter().map(|m| json!({
377 "manager": m.manager,
378 "bytes_freed": m.bytes,
379 "directories": m.dirs,
380 })).collect::<Vec<_>>(),
381 },
382 "by_trigger": {
383 "passes_not_counted": triggers_uncounted,
384 "triggers": triggers.iter().map(|t| json!({
387 "trigger": t.trigger.label(),
388 "bytes_freed": t.bytes,
389 "passes": t.passes,
390 })).collect::<Vec<_>>(),
391 },
392 })
393}
394
395pub fn history_document(passes: &[crate::history::Pass], only: Option<usize>) -> Value {
409 use crate::history::Pass;
410 json!({
411 "schema": SCHEMA_VERSION,
412 "version": constants::VERSION,
413 "command": "history",
414 "detail_starts_at": constants::PRUNE_LOG_STARTS_AT,
415 "passes": passes.iter().enumerate()
416 .filter(|(index, _)| only.is_none_or(|n| n == index + 1))
417 .map(|(index, pass)| {
418 let mut entry = json!({
419 "pass": index + 1,
421 "at": pass.at().to_rfc3339(),
422 "bytes_freed": pass.bytes_freed(),
423 "directories": pass.dirs_removed(),
424 "repositories": pass.repos_touched(),
425 "detail": pass.dirs().is_some(),
426 });
427 let map = entry.as_object_mut().expect("json! built an object");
428 if let Pass::Detailed(record) = pass {
429 map.insert("trigger".into(), json!(record.trigger.label()));
430 map.insert("argv".into(), json!(record.argv));
431 map.insert("command_line".into(), json!(record.command_line()));
432 map.insert("dev_prune_version".into(), json!(record.version));
433 }
434 if let Some(dirs) = pass.dirs() {
435 map.insert("removed".into(), json!(dirs.iter().map(|d| json!({
436 "repo_path": clean_path(&d.repo_path),
437 "directory": d.bloat_dir,
438 "adapter": d.adapter,
439 "bytes_freed": d.size_freed,
440 "runtime": d.runtime,
441 })).collect::<Vec<_>>()));
442 }
443 entry
444 }).collect::<Vec<_>>(),
445 })
446}
447
448fn container_engines(reports: &[crate::commands::containers::EngineReport]) -> Vec<Value> {
460 use crate::commands::containers::EngineState;
461 reports
462 .iter()
463 .map(|report| match &report.state {
464 EngineState::Unavailable(reason) => json!({
465 "engine": report.name,
466 "available": false,
467 "reason": reason,
468 }),
469 EngineState::Ready(rows) => json!({
470 "engine": report.name,
471 "available": true,
472 "rows": rows.iter().map(|row| {
473 let mut obj = json!({ "kind": row.kind });
474 if let Some(n) = row.total {
479 obj["total"] = json!(n);
480 }
481 if let Some(n) = row.active {
482 obj["active"] = json!(n);
483 }
484 if let Some(n) = row.bytes {
485 obj["bytes"] = json!(n);
486 }
487 if let Some(n) = row.reclaimable {
488 obj["reclaimable_bytes"] = json!(n);
489 }
490 obj
491 }).collect::<Vec<_>>(),
492 "total_bytes": report.total_bytes().unwrap_or(0),
493 "reclaimable_bytes": report.reclaimable_bytes().unwrap_or(0),
494 }),
495 })
496 .collect()
497}
498
499pub fn containers_document(
511 reports: &[crate::commands::containers::EngineReport],
512 kubernetes_contexts: &[String],
513) -> Value {
514 let total: u64 = reports.iter().filter_map(|r| r.total_bytes()).sum();
515 let reclaimable: u64 = reports.iter().filter_map(|r| r.reclaimable_bytes()).sum();
516
517 json!({
518 "schema": SCHEMA_VERSION,
519 "version": constants::VERSION,
520 "command": "caches containers",
521 "engines": container_engines(reports),
522 "kubernetes_contexts": kubernetes_contexts,
523 "summary": {
524 "total_bytes": total,
525 "reclaimable_bytes": reclaimable,
526 "engines": reports.len(),
527 },
528 })
529}
530
531pub fn caches_document(
542 reports: &[crate::commands::caches::CacheReport],
543 registered_repositories: Option<usize>,
544 containers: &[crate::commands::containers::EngineReport],
545 volume: Option<&std::path::Path>,
546) -> Value {
547 let total: u64 = reports.iter().map(|r| r.bytes).sum();
548
549 let caches: Vec<Value> = reports
550 .iter()
551 .map(|r| {
552 let mut obj = json!({
553 "manager": r.manager,
554 "kind": r.kind,
555 "path": clean_path(&r.path),
556 "bytes": r.bytes,
557 "clear_command": &r.clear_command,
558 });
559 if let Some(root) = crate::commands::caches::volume_root(&r.path) {
563 obj["volume"] = json!(clean_path(&root));
564 }
565 if let Some(note) = r.note {
566 obj["note"] = json!(note);
567 }
568 if let Some(gb) = r.cap_gb {
571 obj["cap_gb"] = json!(gb);
572 obj["over_cap"] = json!(r.over_cap);
573 }
574 if let Some(n) = r.dependents {
578 obj["dependents"] = json!(n);
579 }
580 obj
581 })
582 .collect();
583
584 let mut summary = json!({
585 "total_bytes": total,
586 "count": reports.len(),
587 });
588 if let Some(n) = registered_repositories {
589 summary["registered_repositories"] = json!(n);
590 }
591 if let Some(root) = volume {
594 summary["volume"] = json!(clean_path(root));
595 }
596
597 let mut doc = json!({
601 "schema": SCHEMA_VERSION,
602 "version": constants::VERSION,
603 "command": "caches",
604 "caches": caches,
605 "summary": summary,
606 });
607 if volume.is_none() {
613 doc["containers"] = json!(container_engines(containers));
614 }
615 doc
616}
617fn kept_caches(kept: &[crate::commands::caches::CacheReport]) -> Vec<Value> {
622 use crate::commands::caches::Clear;
623 kept.iter()
624 .filter_map(|r| {
625 let Clear::Manual { why } = r.clear else {
626 return None;
627 };
628 Some(json!({
629 "manager": r.manager,
630 "kind": r.kind,
631 "path": clean_path(&r.path),
632 "bytes": r.bytes,
633 "clear_command": &r.clear_command,
634 "reason": why,
635 }))
636 })
637 .collect()
638}
639
640pub fn caches_clear_plan_document(
642 reports: &[crate::commands::caches::CacheReport],
643 kept: &[crate::commands::caches::CacheReport],
644) -> Value {
645 let total: u64 = reports.iter().map(|r| r.bytes).sum();
646
647 let caches: Vec<Value> = reports
648 .iter()
649 .map(|r| {
650 let mut obj = json!({
651 "manager": r.manager,
652 "kind": r.kind,
653 "path": clean_path(&r.path),
654 "bytes": r.bytes,
655 "clear_command": &r.clear_command,
656 });
657 if let Some(n) = r.dependents {
658 obj["dependents"] = json!(n);
659 }
660 obj
661 })
662 .collect();
663
664 json!({
665 "schema": SCHEMA_VERSION,
666 "version": constants::VERSION,
667 "command": "caches clear",
668 "dry_run": true,
669 "caches": caches,
670 "kept": kept_caches(kept),
671 "summary": {
672 "total_bytes": total,
673 "count": reports.len(),
674 },
675 })
676}
677
678pub fn caches_clear_document(
683 outcomes: &[crate::commands::caches::ClearOutcome],
684 kept: &[crate::commands::caches::CacheReport],
685) -> Value {
686 let freed: u64 = outcomes.iter().map(|o| o.freed()).sum();
687 let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
688
689 let caches: Vec<Value> = outcomes
690 .iter()
691 .map(|o| {
692 let mut obj = json!({
693 "manager": o.manager,
694 "kind": o.kind,
695 "path": clean_path(&o.path),
696 "bytes_before": o.before,
697 "bytes_after": o.after,
698 "freed_bytes": o.freed(),
699 "cleared": o.problem.is_none(),
700 });
701 if let Some(problem) = &o.problem {
702 obj["error"] = json!(problem);
703 }
704 obj
705 })
706 .collect();
707
708 json!({
709 "schema": SCHEMA_VERSION,
710 "version": constants::VERSION,
711 "command": "caches clear",
712 "dry_run": false,
713 "caches": caches,
714 "kept": kept_caches(kept),
715 "summary": {
716 "freed_bytes": freed,
717 "count": outcomes.len(),
718 "failed": failed,
719 },
720 })
721}
722pub fn containers_clear_document(
733 outcome: &crate::commands::containers::ClearOutcome,
734 dry_run: bool,
735) -> Value {
736 let steps: Vec<Value> = outcome
737 .steps
738 .iter()
739 .map(|s| {
740 let mut obj = json!({
741 "command": s.command,
742 "reclaims": s.what,
743 "ran": !dry_run && s.problem.is_none(),
744 });
745 if let Some(problem) = &s.problem {
746 obj["error"] = json!(problem);
747 }
748 obj
749 })
750 .collect();
751
752 json!({
753 "schema": SCHEMA_VERSION,
754 "version": constants::VERSION,
755 "command": "caches clear",
756 "dry_run": dry_run,
757 "engine": outcome.engine,
758 "steps": steps,
759 "summary": {
760 "bytes_before": outcome.before,
761 "bytes_after": outcome.after,
762 "freed_bytes": if dry_run { 0 } else { outcome.freed() },
763 "failed": outcome.steps.iter().filter(|s| s.problem.is_some()).count(),
764 "volumes_untouched": true,
765 },
766 })
767}
768
769pub fn trust_document(report: &crate::commands::trust::TrustReport) -> Value {
775 let rows = |rows: &[crate::commands::trust::TrustRow]| -> Vec<Value> {
776 rows.iter()
777 .map(|r| {
778 json!({
779 "key": r.key,
780 "subject": r.subject,
781 "state": r.state,
782 "verdict": r.verdict_key(),
783 })
784 })
785 .collect()
786 };
787
788 let widened = report.widened();
789
790 let binaries: Vec<Value> = report
795 .binaries
796 .iter()
797 .map(|b| {
798 json!({
799 "role": b.role,
800 "name": b.name,
801 "path": b.path,
802 "channel": b.channel,
803 "sha256": b.sha256,
804 "version": b.version,
808 "marker": b.marker,
809 "running": b.running,
810 "scan_report": b.scan_url(),
811 "note": b.note,
812 })
813 })
814 .collect();
815
816 json!({
817 "schema": SCHEMA_VERSION,
818 "version": constants::VERSION,
819 "command": "trust",
820 "guarantees": rows(&report.guarantees),
821 "machine": rows(&report.machine),
822 "binaries": binaries,
823 "summary": {
824 "widened": widened,
825 "widened_count": widened.len(),
826 },
827 })
828}
829
830pub fn drift_document(findings: &[crate::commands::status::ProjectDrift]) -> Value {
837 let unrecorded_total: usize = findings.iter().map(|f| f.report.unrecorded.len()).sum();
838
839 json!({
840 "schema": SCHEMA_VERSION,
841 "version": constants::VERSION,
842 "command": "status --drift",
843 "drift": findings.iter().map(|f| json!({
844 "repository": clean_path(&f.repository),
845 "project": f.project,
846 "adapter": f.adapter,
847 "directory": f.report.directory,
848 "unrecorded": f.report.unrecorded,
849 "record_command": f.report.record_command,
850 })).collect::<Vec<_>>(),
851 "summary": {
852 "projects_with_drift": findings.len(),
853 "unrecorded_packages": unrecorded_total,
854 },
855 })
856}
857
858pub fn emit(document: &Value) -> anyhow::Result<()> {
869 use std::io::IsTerminal;
870 let text = serde_json::to_string_pretty(document)?;
871 println!("{text}");
872 if std::io::stdout().is_terminal() && copy_to_clipboard(&text) {
873 use colored::Colorize;
874 eprintln!("{}", "(also copied to your clipboard)".dimmed());
875 }
876 Ok(())
877}
878
879fn copy_to_clipboard(text: &str) -> bool {
886 let bytes: Vec<u8> = if cfg!(windows) {
890 let mut utf16 = vec![0xFF, 0xFE];
891 for unit in text.encode_utf16() {
892 utf16.extend_from_slice(&unit.to_le_bytes());
893 }
894 utf16
895 } else {
896 text.as_bytes().to_vec()
897 };
898
899 let windows_clip = std::env::var("SystemRoot")
905 .map(|root| format!("{root}\\System32\\clip.exe"))
906 .unwrap_or_else(|_| String::from("C:\\Windows\\System32\\clip.exe"));
907 let tools: Vec<Vec<&str>> = if cfg!(windows) {
908 vec![vec![windows_clip.as_str()]]
909 } else if cfg!(target_os = "macos") {
910 vec![vec!["pbcopy"]]
911 } else {
912 vec![
913 vec!["wl-copy"],
914 vec!["xclip", "-selection", "clipboard"],
915 vec!["xsel", "--clipboard", "--input"],
916 ]
917 };
918 tools.iter().any(|tool| pipe_into(tool, &bytes))
919}
920
921fn pipe_into(command: &[&str], bytes: &[u8]) -> bool {
923 use std::io::Write;
924 use std::process::Stdio;
925 let Ok(mut child) = crate::spawn::command(command[0])
926 .args(&command[1..])
927 .stdin(Stdio::piped())
928 .stdout(Stdio::null())
929 .stderr(Stdio::null())
930 .spawn()
931 else {
932 return false;
933 };
934 let wrote = child
935 .stdin
936 .take()
937 .is_some_and(|mut stdin| stdin.write_all(bytes).is_ok());
938 let exited_cleanly = child.wait().map(|status| status.success()).unwrap_or(false);
939 wrote && exited_cleanly
940}
941
942#[cfg(test)]
943mod tests {
944 use super::*;
945 use std::path::PathBuf;
946
947 fn result(status: PruneStatus, bytes: u64) -> PruneResult {
948 PruneResult {
949 repo_path: PathBuf::from("/tmp/repo"),
950 adapter_name: "pnpm".to_string(),
951 bloat_dir: "node_modules".to_string(),
952 size_freed: bytes,
953 shared_bytes: 0,
954 runtime: None,
955 status,
956 }
957 }
958
959 #[test]
960 fn every_status_has_a_distinct_stable_tag() {
961 let all = [
962 PruneStatus::Pruned,
963 PruneStatus::SkippedActive,
964 PruneStatus::SkippedAdapterWindow(45),
965 PruneStatus::SkippedDryRun,
966 PruneStatus::LockfileError("x".into()),
967 PruneStatus::ActivityCheckError("x".into()),
968 PruneStatus::PathMissing,
969 PruneStatus::NotARepo,
970 PruneStatus::NoBloat,
971 PruneStatus::Disabled,
972 PruneStatus::SkippedIgnored,
973 PruneStatus::DeleteError("x".into()),
974 PruneStatus::ConfigError("x".into()),
975 PruneStatus::SkippedSymlink("x".into()),
976 PruneStatus::SkippedDeclaration("x".into()),
977 PruneStatus::SkippedNestedRepo("x".into()),
978 ];
979 let mut tags: Vec<&str> = all.iter().map(status_tag).collect();
980 let count = tags.len();
981 tags.sort_unstable();
982 tags.dedup();
983 assert_eq!(tags.len(), count, "two statuses share a JSON tag");
984 }
985
986 #[test]
987 fn every_repository_state_has_a_distinct_stable_tag() {
988 let all = [
989 SkipReason::Candidate,
990 SkipReason::Active,
991 SkipReason::Ignored,
992 SkipReason::NoBloat,
993 SkipReason::PathMissing,
994 SkipReason::ConfigError("x".into()),
995 ];
996 let mut tags: Vec<&str> = all.iter().map(reason_tag).collect();
997 let count = tags.len();
998 tags.sort_unstable();
999 tags.dedup();
1000 assert_eq!(tags.len(), count, "two repository states share a JSON tag");
1001 }
1002
1003 #[test]
1004 fn only_an_unreadable_config_carries_an_error_field() {
1005 let entry = |reason| RepoStatusEntry {
1006 path: PathBuf::from("/tmp/repo"),
1007 entry: crate::config::RepoEntry::new(),
1008 reason,
1009 adapters: Vec::new(),
1010 bloat_dirs: Vec::new(),
1011 reclaimable_bytes: 0,
1012 reclaimable_by_adapter: Vec::new(),
1013 last_activity: None,
1014 idle_days: 15,
1015 };
1016
1017 let registry = Registry::default();
1018 let broken = repo_value(
1019 ®istry,
1020 &entry(SkipReason::ConfigError("bad json".into())),
1021 );
1022 assert_eq!(broken["state"], "config_error");
1023 assert_eq!(broken["error"], "bad json");
1024
1025 let healthy = repo_value(®istry, &entry(SkipReason::Candidate));
1027 assert!(healthy.get("error").is_none());
1028 }
1029
1030 #[test]
1031 fn run_summary_counts_only_real_deletions() {
1032 let doc = run_document(
1033 &[
1034 result(PruneStatus::Pruned, 100),
1035 result(PruneStatus::Pruned, 50),
1036 result(PruneStatus::SkippedActive, 0),
1037 result(PruneStatus::LockfileError("nope".into()), 0),
1038 ],
1039 false,
1040 );
1041 assert_eq!(doc["summary"]["bytes_freed"], 150);
1042 assert_eq!(doc["summary"]["directories_pruned"], 2);
1043 assert_eq!(doc["summary"]["errors"], 1);
1044 }
1045
1046 #[test]
1047 fn dry_run_bytes_land_in_reclaimable_not_freed() {
1048 let doc = run_document(&[result(PruneStatus::SkippedDryRun, 4096)], true);
1051 assert_eq!(doc["summary"]["bytes_freed"], 0);
1052 assert_eq!(doc["summary"]["bytes_reclaimable"], 4096);
1053 assert_eq!(doc["dry_run"], true);
1054 }
1055
1056 #[test]
1057 fn lockfile_errors_carry_the_fix_command() {
1058 let doc = run_document(
1059 &[result(PruneStatus::LockfileError("boom".into()), 0)],
1060 false,
1061 );
1062 assert_eq!(doc["results"][0]["message"], "boom");
1063 assert_eq!(
1064 doc["results"][0]["fix_command"],
1065 "pnpm install --lockfile-only"
1066 );
1067 }
1068
1069 #[test]
1070 fn a_successful_result_carries_no_message_or_fix() {
1071 let doc = run_document(&[result(PruneStatus::Pruned, 1)], false);
1072 assert!(doc["results"][0].get("message").is_none());
1073 assert!(doc["results"][0].get("fix_command").is_none());
1074 }
1075
1076 #[test]
1077 fn venv_has_no_mechanical_lockfile_fix() {
1078 assert!(lockfile_fix_command("venv").is_none());
1081 assert!(lockfile_fix_command("nonsense").is_none());
1082 }
1083
1084 #[test]
1085 fn the_cache_report_totals_what_it_lists() {
1086 use crate::commands::caches::{CacheReport, Clear};
1087
1088 let doc = caches_document(
1089 &[
1090 CacheReport {
1091 manager: "go",
1092 kind: "module cache",
1093 path: PathBuf::from("/home/dev/go/pkg/mod"),
1094 bytes: 4_000,
1095 clear_command: "go clean -modcache".to_string(),
1096 clear: Clear::Command("go", &["clean", "-modcache"]),
1097 note: None,
1098 cap_gb: None,
1099 over_cap: false,
1100 dependents: None,
1101 extra_args: Vec::new(),
1102 },
1103 CacheReport {
1104 manager: "pnpm",
1105 kind: "store",
1106 path: PathBuf::from("/home/dev/.pnpm-store"),
1107 bytes: 1_000,
1108 clear_command: "pnpm store prune".to_string(),
1109 clear: Clear::Command("pnpm", &["store", "prune"]),
1110 note: Some("hardlinked"),
1111 cap_gb: None,
1112 over_cap: false,
1113 dependents: None,
1114 extra_args: Vec::new(),
1115 },
1116 ],
1117 Some(3),
1118 &[],
1119 None,
1120 );
1121
1122 assert_eq!(doc["command"], "caches");
1123 assert_eq!(doc["summary"]["total_bytes"], 5_000);
1124 assert_eq!(doc["summary"]["count"], 2);
1125 assert!(doc["caches"][0].get("note").is_none());
1128 assert_eq!(doc["caches"][1]["note"], "hardlinked");
1129 assert_eq!(doc["caches"][0]["clear_command"], "go clean -modcache");
1130 }
1131
1132 #[test]
1133 fn an_empty_cache_report_is_still_a_document() {
1134 let doc = caches_document(&[], None, &[], None);
1137 assert_eq!(doc["summary"]["total_bytes"], 0);
1138 assert_eq!(doc["caches"].as_array().unwrap().len(), 0);
1139 assert_eq!(doc["containers"].as_array().unwrap().len(), 0);
1140 }
1141
1142 #[test]
1143 fn cache_clears_are_reported_beside_the_prune_total_not_inside_it() {
1144 let mut registry = crate::config::Registry {
1145 total_freed_bytes: 12_000_000_000,
1146 ..Default::default()
1147 };
1148 registry.record_cache_clear(6_000_000_000);
1149
1150 let doc = stats_document(®istry, &[]);
1151
1152 assert_eq!(doc["lifetime"]["bytes_freed"], 12_000_000_000u64);
1153 assert_eq!(doc["lifetime"]["cache_bytes_freed"], 6_000_000_000u64);
1154 }
1155
1156 #[test]
1157 fn the_breakdowns_report_what_they_could_not_count() {
1158 use crate::config::PrunedDir;
1159 use crate::history::{Pass, PassRecord, Trigger};
1160
1161 let registry = crate::config::Registry::default();
1162 let logged = Pass::Detailed(PassRecord {
1163 at: chrono::Utc::now(),
1164 trigger: Trigger::Scheduled,
1165 argv: vec!["run".to_string()],
1166 version: "1.17.0".to_string(),
1167 dirs: vec![PrunedDir {
1168 repo_path: std::path::PathBuf::from("/tmp/api"),
1169 bloat_dir: "node_modules".to_string(),
1170 adapter: "npm".to_string(),
1171 size_freed: 900,
1172 runtime: None,
1173 }],
1174 });
1175 let recovered = Pass::Summary {
1178 at: chrono::Utc::now() - chrono::Duration::days(1),
1179 bytes_freed: 100,
1180 dirs_removed: 1,
1181 repos_touched: 1,
1182 dirs: None,
1183 };
1184
1185 let doc = stats_document(®istry, &[logged, recovered]);
1186
1187 assert_eq!(doc["by_manager"]["managers"][0]["manager"], "npm");
1188 assert_eq!(doc["by_manager"]["managers"][0]["bytes_freed"], 900);
1189 assert_eq!(doc["by_manager"]["passes_not_counted"], 1);
1190
1191 let triggers = doc["by_trigger"]["triggers"].as_array().unwrap();
1193 assert_eq!(triggers.len(), 3);
1194 let scheduled = triggers
1195 .iter()
1196 .find(|t| t["trigger"] == "scheduled")
1197 .unwrap();
1198 assert_eq!(scheduled["passes"], 1);
1199 assert_eq!(scheduled["bytes_freed"], 900);
1200 assert_eq!(doc["by_trigger"]["passes_not_counted"], 1);
1201 }
1202
1203 #[test]
1204 fn container_disk_stays_out_of_the_cache_total() {
1205 use crate::commands::containers::{EngineReport, EngineState, Row};
1206
1207 let docker = EngineReport {
1208 name: "docker",
1209 state: EngineState::Ready(vec![Row {
1210 kind: "Images".to_string(),
1211 total: Some(9),
1212 active: Some(2),
1213 bytes: Some(40_000_000_000),
1214 reclaimable: Some(38_000_000_000),
1215 }]),
1216 };
1217 let doc = caches_document(&[], None, std::slice::from_ref(&docker), None);
1218
1219 assert_eq!(doc["summary"]["total_bytes"], 0);
1223 assert_eq!(doc["containers"][0]["engine"], "docker");
1224 assert_eq!(doc["containers"][0]["total_bytes"], 40_000_000_000u64);
1225 assert_eq!(doc["containers"][0]["rows"][0]["kind"], "Images");
1226 }
1227
1228 #[test]
1229 fn an_engine_that_did_not_answer_carries_no_zero() {
1230 use crate::commands::containers::{EngineReport, EngineState};
1231
1232 let doc = containers_document(
1233 &[EngineReport {
1234 name: "docker",
1235 state: EngineState::Unavailable("daemon is not running".to_string()),
1236 }],
1237 &[],
1238 );
1239
1240 assert_eq!(doc["command"], "caches containers");
1241 assert_eq!(doc["engines"][0]["available"], false);
1242 assert_eq!(doc["engines"][0]["reason"], "daemon is not running");
1243 assert!(doc["engines"][0].get("total_bytes").is_none());
1246 assert_eq!(doc["summary"]["total_bytes"], 0);
1247 }
1248
1249 #[test]
1250 fn no_prune_command_reaches_the_json_contract() {
1251 use crate::commands::containers::{EngineReport, EngineState, Row};
1252
1253 let doc = containers_document(
1254 &[EngineReport {
1255 name: "docker",
1256 state: EngineState::Ready(vec![Row {
1257 kind: "Build Cache".to_string(),
1258 total: Some(41),
1259 active: Some(0),
1260 bytes: Some(6_750_000_000),
1261 reclaimable: Some(6_750_000_000),
1262 }]),
1263 }],
1264 &["kind-dev".to_string()],
1265 );
1266
1267 let text = serde_json::to_string(&doc).unwrap();
1270 assert!(!text.contains("prune"), "{text}");
1271 assert_eq!(doc["kubernetes_contexts"][0], "kind-dev");
1272 assert_eq!(doc["summary"]["reclaimable_bytes"], 6_750_000_000u64);
1273 }
1274
1275 #[test]
1276 fn every_adapter_with_a_lockfile_has_a_fix_command() {
1277 for adapter in crate::adapters::get_all_adapters() {
1278 if matches!(
1282 adapter.name(),
1283 "venv" | "gradle" | "maven" | "swift" | "vcpkg" | "cmake_build" | "dotnet_build"
1284 ) {
1285 continue;
1286 }
1287 assert!(
1288 lockfile_fix_command(adapter.name()).is_some(),
1289 "{} has no fix command",
1290 adapter.name()
1291 );
1292 }
1293 }
1294}