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::SkippedDryRun => "skipped_dry_run",
38 PruneStatus::LockfileError(_) => "lockfile_error",
39 PruneStatus::ActivityCheckError(_) => "activity_check_error",
40 PruneStatus::PathMissing => "path_missing",
41 PruneStatus::NoBloat => "no_bloat",
42 PruneStatus::Disabled => "disabled",
43 PruneStatus::SkippedIgnored => "ignored",
44 PruneStatus::DeleteError(_) => "delete_error",
45 PruneStatus::ConfigError(_) => "config_error",
46 PruneStatus::SkippedSymlink(_) => "skipped_symlink",
47 PruneStatus::SkippedDeclaration(_) => "skipped_declaration",
48 PruneStatus::SkippedNestedRepo(_) => "skipped_nested_repo",
49 }
50}
51
52fn status_message(status: &PruneStatus) -> Option<&str> {
54 match status {
55 PruneStatus::LockfileError(e)
56 | PruneStatus::ActivityCheckError(e)
57 | PruneStatus::DeleteError(e)
58 | PruneStatus::ConfigError(e)
59 | PruneStatus::SkippedSymlink(e)
60 | PruneStatus::SkippedDeclaration(e)
61 | PruneStatus::SkippedNestedRepo(e) => Some(e.trim()),
62 _ => None,
63 }
64}
65
66pub fn lockfile_fix_command(adapter: &str) -> Option<&'static str> {
77 Some(match adapter {
78 "npm" => "npm install --package-lock-only --ignore-scripts",
79 "pnpm" => "pnpm install --lockfile-only",
80 "yarn" => "yarn install --mode update-lockfile",
81 "bun" => "bun install",
84 "uv" => "uv lock",
85 "poetry" => "poetry lock",
86 "pdm" => "pdm lock",
87 "pipenv" => "pipenv lock",
88 "cargo" => "cargo generate-lockfile",
89 "go" => "go mod tidy",
90 "composer" => "composer update --no-install",
91 "bundler" => "bundle lock",
92 "cocoapods" => "pod install",
93 "mix" | "mix_build" => "mix deps.get",
95 "terraform" => "terraform providers lock",
98 "dart" => "dart pub get",
101 _ => return None,
107 })
108}
109
110fn result_value(result: &PruneResult) -> Value {
111 let mut obj = json!({
112 "repository": clean_path(&result.repo_path),
113 "adapter": result.adapter_name,
114 "directory": result.bloat_dir,
115 "status": status_tag(&result.status),
116 "bytes": result.size_freed,
117 "shared_bytes": result.shared_bytes,
118 });
119
120 if let Some(message) = status_message(&result.status) {
121 obj["message"] = json!(message);
122 }
123 if matches!(result.status, PruneStatus::LockfileError(_))
124 && let Some(fix) = lockfile_fix_command(&result.adapter_name)
125 {
126 obj["fix_command"] = json!(fix);
127 }
128 obj
129}
130
131pub fn run_document(results: &[PruneResult], dry_run: bool) -> Value {
136 let bytes_freed: u64 = results
137 .iter()
138 .filter(|r| matches!(r.status, PruneStatus::Pruned))
139 .map(|r| r.size_freed)
140 .sum();
141 let directories_pruned = results
142 .iter()
143 .filter(|r| matches!(r.status, PruneStatus::Pruned))
144 .count();
145 let bytes_reclaimable: u64 = results
146 .iter()
147 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
148 .map(|r| r.size_freed)
149 .sum();
150 let errors = results
151 .iter()
152 .filter(|r| {
153 matches!(
154 r.status,
155 PruneStatus::LockfileError(_)
156 | PruneStatus::ActivityCheckError(_)
157 | PruneStatus::DeleteError(_)
158 | PruneStatus::ConfigError(_)
159 )
160 })
161 .count();
162
163 json!({
164 "schema": SCHEMA_VERSION,
165 "version": constants::VERSION,
166 "command": "run",
167 "dry_run": dry_run,
168 "results": results.iter().map(result_value).collect::<Vec<_>>(),
169 "summary": {
170 "bytes_freed": bytes_freed,
171 "bytes_reclaimable": bytes_reclaimable,
172 "directories_pruned": directories_pruned,
173 "errors": errors,
174 },
175 })
176}
177
178fn reason_tag(reason: &SkipReason) -> &'static str {
180 match reason {
181 SkipReason::Candidate => "candidate",
182 SkipReason::Active => "active",
183 SkipReason::Ignored => "ignored",
184 SkipReason::NoBloat => "no_bloat",
185 SkipReason::PathMissing => "path_missing",
186 SkipReason::ConfigError(_) => "config_error",
187 }
188}
189
190fn settings_value(settings: &Settings) -> Value {
191 json!({
192 "idle_days": settings.idle_days,
193 "check_interval_days": settings.check_interval_days,
194 "auto_setup": settings.auto_setup,
195 "auto_hooks": settings.auto_hooks,
196 "auto_daemon": settings.auto_daemon,
197 "require_confirmation": settings.require_confirmation,
198 "command_timeout_secs": settings.command_timeout_secs,
199 "min_size_mb": settings.min_size_mb,
200 "update_check": settings.update_check,
201 })
202}
203
204fn repo_value(registry: &Registry, entry: &RepoStatusEntry) -> Value {
205 let mut obj = json!({
206 "path": clean_path(&entry.path),
207 "state": reason_tag(&entry.reason),
208 "enabled": entry.entry.enabled,
209 "idle_days": entry.idle_days,
210 "last_activity": entry.last_activity.map(|t| t.to_rfc3339()),
211 "last_pruned_at": entry.entry.last_pruned_at.map(|t| t.to_rfc3339()),
212 "bytes_freed": entry.entry.total_freed_bytes,
213 "added_at": entry.entry.added_at.to_rfc3339(),
214 "adapters": entry.adapters,
215 "reclaimable_bytes": entry.reclaimable_bytes,
216 "directories": entry.bloat_dirs.iter().map(|b| json!({
217 "name": b.name,
218 "path": clean_path(&b.path),
219 "bytes": b.size_bytes,
220 "shared_bytes": b.shared_bytes,
221 })).collect::<Vec<_>>(),
222 "restore_estimate_secs": registry
225 .estimate_restore(&entry.reclaimable_by_adapter)
226 .map(|(secs, _)| secs.round() as u64),
227 });
228
229 if let SkipReason::ConfigError(e) = &entry.reason {
234 obj["error"] = json!(e);
235 }
236 obj
237}
238
239pub fn status_document(
249 registry: &Registry,
250 repos: &[RepoStatusEntry],
251 daemon: &str,
252 hooks: &str,
253 top: Option<usize>,
254) -> Value {
255 let reclaimable: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
256 let candidates = repos
257 .iter()
258 .filter(|r| matches!(r.reason, SkipReason::Candidate))
259 .count();
260 let listed = crate::engine::take_top(repos, top);
261
262 let mut by_adapter: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
264 for repo in repos {
265 for (adapter, bytes) in &repo.reclaimable_by_adapter {
266 *by_adapter.entry(adapter.clone()).or_default() += bytes;
267 }
268 }
269 let estimate = registry.estimate_restore(&by_adapter.into_iter().collect::<Vec<_>>());
270
271 let mut doc = json!({
272 "schema": SCHEMA_VERSION,
273 "version": constants::VERSION,
274 "command": "status",
275 "config_path": Registry::registry_path().map(|p| clean_path(&p)).ok(),
276 "integrations": { "daemon": daemon, "git_hooks": hooks },
277 "settings": settings_value(®istry.settings),
278 "totals": {
279 "repositories": registry.repo_count(),
280 "candidates": candidates,
281 "reclaimable_bytes": reclaimable,
282 "historical_bytes_freed": registry.total_freed_bytes,
283 "prune_passes": registry.total_pruned_count,
284 "restore_estimate": estimate.map(|(secs, covered)| json!({
289 "seconds": secs.round() as u64,
290 "covered_bytes": covered,
291 "samples": registry.restore_rates.values().map(|r| r.samples as u64).sum::<u64>(),
292 })),
293 },
294 "repositories": listed.iter().map(|r| repo_value(registry, r)).collect::<Vec<_>>(),
295 });
296
297 if let Some(n) = top {
300 doc["top"] = json!(n);
301 }
302 doc
303}
304
305pub fn stats_document(registry: &Registry) -> Value {
315 let mut repos: Vec<(&std::path::PathBuf, &crate::config::RepoEntry)> =
316 registry.repositories.iter().collect();
317 repos.sort_by(|a, b| {
318 b.1.total_freed_bytes
319 .cmp(&a.1.total_freed_bytes)
320 .then_with(|| a.0.cmp(b.0))
321 });
322
323 json!({
324 "schema": SCHEMA_VERSION,
325 "version": constants::VERSION,
326 "command": "stats",
327 "history_starts_at": constants::HISTORY_STARTS_AT,
328 "lifetime": {
329 "bytes_freed": registry.total_freed_bytes,
330 "cache_bytes_freed": registry.total_cache_freed_bytes,
334 "prune_passes": registry.total_pruned_count,
337 "repositories": registry.repo_count(),
338 },
339 "last_prune": registry.last_prune.as_ref().map(|p| json!({
340 "at": p.at.to_rfc3339(),
341 "bytes_freed": p.dirs.iter().map(|d| d.size_freed).sum::<u64>(),
342 "directories": p.dirs.len(),
343 })),
344 "recent_passes": registry.prune_history.iter().rev().map(|p| json!({
345 "at": p.at.to_rfc3339(),
346 "bytes_freed": p.bytes_freed,
347 "directories": p.dirs_removed,
348 "repositories": p.repos_touched,
349 })).collect::<Vec<_>>(),
350 "repositories": repos.iter().map(|(path, entry)| json!({
351 "path": clean_path(path),
352 "bytes_freed": entry.total_freed_bytes,
353 "last_pruned_at": entry.last_pruned_at.map(|t| t.to_rfc3339()),
354 })).collect::<Vec<_>>(),
355 })
356}
357
358fn container_engines(reports: &[crate::commands::containers::EngineReport]) -> Vec<Value> {
370 use crate::commands::containers::EngineState;
371 reports
372 .iter()
373 .map(|report| match &report.state {
374 EngineState::Unavailable(reason) => json!({
375 "engine": report.name,
376 "available": false,
377 "reason": reason,
378 }),
379 EngineState::Ready(rows) => json!({
380 "engine": report.name,
381 "available": true,
382 "rows": rows.iter().map(|row| {
383 let mut obj = json!({ "kind": row.kind });
384 if let Some(n) = row.total {
389 obj["total"] = json!(n);
390 }
391 if let Some(n) = row.active {
392 obj["active"] = json!(n);
393 }
394 if let Some(n) = row.bytes {
395 obj["bytes"] = json!(n);
396 }
397 if let Some(n) = row.reclaimable {
398 obj["reclaimable_bytes"] = json!(n);
399 }
400 obj
401 }).collect::<Vec<_>>(),
402 "total_bytes": report.total_bytes().unwrap_or(0),
403 "reclaimable_bytes": report.reclaimable_bytes().unwrap_or(0),
404 }),
405 })
406 .collect()
407}
408
409pub fn containers_document(
421 reports: &[crate::commands::containers::EngineReport],
422 kubernetes_contexts: &[String],
423) -> Value {
424 let total: u64 = reports.iter().filter_map(|r| r.total_bytes()).sum();
425 let reclaimable: u64 = reports.iter().filter_map(|r| r.reclaimable_bytes()).sum();
426
427 json!({
428 "schema": SCHEMA_VERSION,
429 "version": constants::VERSION,
430 "command": "caches containers",
431 "engines": container_engines(reports),
432 "kubernetes_contexts": kubernetes_contexts,
433 "summary": {
434 "total_bytes": total,
435 "reclaimable_bytes": reclaimable,
436 "engines": reports.len(),
437 },
438 })
439}
440
441pub fn caches_document(
452 reports: &[crate::commands::caches::CacheReport],
453 registered_repositories: Option<usize>,
454 containers: &[crate::commands::containers::EngineReport],
455 volume: Option<&std::path::Path>,
456) -> Value {
457 let total: u64 = reports.iter().map(|r| r.bytes).sum();
458
459 let caches: Vec<Value> = reports
460 .iter()
461 .map(|r| {
462 let mut obj = json!({
463 "manager": r.manager,
464 "kind": r.kind,
465 "path": clean_path(&r.path),
466 "bytes": r.bytes,
467 "clear_command": &r.clear_command,
468 });
469 if let Some(root) = crate::commands::caches::volume_root(&r.path) {
473 obj["volume"] = json!(clean_path(&root));
474 }
475 if let Some(note) = r.note {
476 obj["note"] = json!(note);
477 }
478 if let Some(gb) = r.cap_gb {
481 obj["cap_gb"] = json!(gb);
482 obj["over_cap"] = json!(r.over_cap);
483 }
484 if let Some(n) = r.dependents {
488 obj["dependents"] = json!(n);
489 }
490 obj
491 })
492 .collect();
493
494 let mut summary = json!({
495 "total_bytes": total,
496 "count": reports.len(),
497 });
498 if let Some(n) = registered_repositories {
499 summary["registered_repositories"] = json!(n);
500 }
501 if let Some(root) = volume {
504 summary["volume"] = json!(clean_path(root));
505 }
506
507 let mut doc = json!({
511 "schema": SCHEMA_VERSION,
512 "version": constants::VERSION,
513 "command": "caches",
514 "caches": caches,
515 "summary": summary,
516 });
517 if volume.is_none() {
523 doc["containers"] = json!(container_engines(containers));
524 }
525 doc
526}
527fn kept_caches(kept: &[crate::commands::caches::CacheReport]) -> Vec<Value> {
532 use crate::commands::caches::Clear;
533 kept.iter()
534 .filter_map(|r| {
535 let Clear::Manual { why } = r.clear else {
536 return None;
537 };
538 Some(json!({
539 "manager": r.manager,
540 "kind": r.kind,
541 "path": clean_path(&r.path),
542 "bytes": r.bytes,
543 "clear_command": &r.clear_command,
544 "reason": why,
545 }))
546 })
547 .collect()
548}
549
550pub fn caches_clear_plan_document(
552 reports: &[crate::commands::caches::CacheReport],
553 kept: &[crate::commands::caches::CacheReport],
554) -> Value {
555 let total: u64 = reports.iter().map(|r| r.bytes).sum();
556
557 let caches: Vec<Value> = reports
558 .iter()
559 .map(|r| {
560 let mut obj = json!({
561 "manager": r.manager,
562 "kind": r.kind,
563 "path": clean_path(&r.path),
564 "bytes": r.bytes,
565 "clear_command": &r.clear_command,
566 });
567 if let Some(n) = r.dependents {
568 obj["dependents"] = json!(n);
569 }
570 obj
571 })
572 .collect();
573
574 json!({
575 "schema": SCHEMA_VERSION,
576 "version": constants::VERSION,
577 "command": "caches clear",
578 "dry_run": true,
579 "caches": caches,
580 "kept": kept_caches(kept),
581 "summary": {
582 "total_bytes": total,
583 "count": reports.len(),
584 },
585 })
586}
587
588pub fn caches_clear_document(
593 outcomes: &[crate::commands::caches::ClearOutcome],
594 kept: &[crate::commands::caches::CacheReport],
595) -> Value {
596 let freed: u64 = outcomes.iter().map(|o| o.freed()).sum();
597 let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
598
599 let caches: Vec<Value> = outcomes
600 .iter()
601 .map(|o| {
602 let mut obj = json!({
603 "manager": o.manager,
604 "kind": o.kind,
605 "path": clean_path(&o.path),
606 "bytes_before": o.before,
607 "bytes_after": o.after,
608 "freed_bytes": o.freed(),
609 "cleared": o.problem.is_none(),
610 });
611 if let Some(problem) = &o.problem {
612 obj["error"] = json!(problem);
613 }
614 obj
615 })
616 .collect();
617
618 json!({
619 "schema": SCHEMA_VERSION,
620 "version": constants::VERSION,
621 "command": "caches clear",
622 "dry_run": false,
623 "caches": caches,
624 "kept": kept_caches(kept),
625 "summary": {
626 "freed_bytes": freed,
627 "count": outcomes.len(),
628 "failed": failed,
629 },
630 })
631}
632pub fn trust_document(report: &crate::commands::trust::TrustReport) -> Value {
638 let rows = |rows: &[crate::commands::trust::TrustRow]| -> Vec<Value> {
639 rows.iter()
640 .map(|r| {
641 json!({
642 "key": r.key,
643 "subject": r.subject,
644 "state": r.state,
645 "verdict": r.verdict_key(),
646 })
647 })
648 .collect()
649 };
650
651 let widened = report.widened();
652
653 let binaries: Vec<Value> = report
658 .binaries
659 .iter()
660 .map(|b| {
661 json!({
662 "role": b.role,
663 "name": b.name,
664 "path": b.path,
665 "sha256": b.sha256,
666 "running": b.running,
667 "scan_report": b.scan_url(),
668 "note": b.note,
669 })
670 })
671 .collect();
672
673 json!({
674 "schema": SCHEMA_VERSION,
675 "version": constants::VERSION,
676 "command": "trust",
677 "guarantees": rows(&report.guarantees),
678 "machine": rows(&report.machine),
679 "binaries": binaries,
680 "summary": {
681 "widened": widened,
682 "widened_count": widened.len(),
683 },
684 })
685}
686
687pub fn drift_document(findings: &[crate::commands::status::ProjectDrift]) -> Value {
694 let unrecorded_total: usize = findings.iter().map(|f| f.report.unrecorded.len()).sum();
695
696 json!({
697 "schema": SCHEMA_VERSION,
698 "version": constants::VERSION,
699 "command": "status --drift",
700 "drift": findings.iter().map(|f| json!({
701 "repository": clean_path(&f.repository),
702 "project": f.project,
703 "adapter": f.adapter,
704 "directory": f.report.directory,
705 "unrecorded": f.report.unrecorded,
706 "record_command": f.report.record_command,
707 })).collect::<Vec<_>>(),
708 "summary": {
709 "projects_with_drift": findings.len(),
710 "unrecorded_packages": unrecorded_total,
711 },
712 })
713}
714
715pub fn emit(document: &Value) -> anyhow::Result<()> {
726 use std::io::IsTerminal;
727 let text = serde_json::to_string_pretty(document)?;
728 println!("{text}");
729 if std::io::stdout().is_terminal() && copy_to_clipboard(&text) {
730 use colored::Colorize;
731 eprintln!("{}", "(also copied to your clipboard)".dimmed());
732 }
733 Ok(())
734}
735
736fn copy_to_clipboard(text: &str) -> bool {
743 let bytes: Vec<u8> = if cfg!(windows) {
747 let mut utf16 = vec![0xFF, 0xFE];
748 for unit in text.encode_utf16() {
749 utf16.extend_from_slice(&unit.to_le_bytes());
750 }
751 utf16
752 } else {
753 text.as_bytes().to_vec()
754 };
755
756 let windows_clip = std::env::var("SystemRoot")
762 .map(|root| format!("{root}\\System32\\clip.exe"))
763 .unwrap_or_else(|_| String::from("C:\\Windows\\System32\\clip.exe"));
764 let tools: Vec<Vec<&str>> = if cfg!(windows) {
765 vec![vec![windows_clip.as_str()]]
766 } else if cfg!(target_os = "macos") {
767 vec![vec!["pbcopy"]]
768 } else {
769 vec![
770 vec!["wl-copy"],
771 vec!["xclip", "-selection", "clipboard"],
772 vec!["xsel", "--clipboard", "--input"],
773 ]
774 };
775 tools.iter().any(|tool| pipe_into(tool, &bytes))
776}
777
778fn pipe_into(command: &[&str], bytes: &[u8]) -> bool {
780 use std::io::Write;
781 use std::process::Stdio;
782 let Ok(mut child) = crate::spawn::command(command[0])
783 .args(&command[1..])
784 .stdin(Stdio::piped())
785 .stdout(Stdio::null())
786 .stderr(Stdio::null())
787 .spawn()
788 else {
789 return false;
790 };
791 let wrote = child
792 .stdin
793 .take()
794 .is_some_and(|mut stdin| stdin.write_all(bytes).is_ok());
795 let exited_cleanly = child.wait().map(|status| status.success()).unwrap_or(false);
796 wrote && exited_cleanly
797}
798
799#[cfg(test)]
800mod tests {
801 use super::*;
802 use std::path::PathBuf;
803
804 fn result(status: PruneStatus, bytes: u64) -> PruneResult {
805 PruneResult {
806 repo_path: PathBuf::from("/tmp/repo"),
807 adapter_name: "pnpm".to_string(),
808 bloat_dir: "node_modules".to_string(),
809 size_freed: bytes,
810 shared_bytes: 0,
811 runtime: None,
812 status,
813 }
814 }
815
816 #[test]
817 fn every_status_has_a_distinct_stable_tag() {
818 let all = [
819 PruneStatus::Pruned,
820 PruneStatus::SkippedActive,
821 PruneStatus::SkippedDryRun,
822 PruneStatus::LockfileError("x".into()),
823 PruneStatus::ActivityCheckError("x".into()),
824 PruneStatus::PathMissing,
825 PruneStatus::NoBloat,
826 PruneStatus::Disabled,
827 PruneStatus::SkippedIgnored,
828 PruneStatus::DeleteError("x".into()),
829 PruneStatus::ConfigError("x".into()),
830 PruneStatus::SkippedSymlink("x".into()),
831 PruneStatus::SkippedDeclaration("x".into()),
832 PruneStatus::SkippedNestedRepo("x".into()),
833 ];
834 let mut tags: Vec<&str> = all.iter().map(status_tag).collect();
835 let count = tags.len();
836 tags.sort_unstable();
837 tags.dedup();
838 assert_eq!(tags.len(), count, "two statuses share a JSON tag");
839 }
840
841 #[test]
842 fn every_repository_state_has_a_distinct_stable_tag() {
843 let all = [
844 SkipReason::Candidate,
845 SkipReason::Active,
846 SkipReason::Ignored,
847 SkipReason::NoBloat,
848 SkipReason::PathMissing,
849 SkipReason::ConfigError("x".into()),
850 ];
851 let mut tags: Vec<&str> = all.iter().map(reason_tag).collect();
852 let count = tags.len();
853 tags.sort_unstable();
854 tags.dedup();
855 assert_eq!(tags.len(), count, "two repository states share a JSON tag");
856 }
857
858 #[test]
859 fn only_an_unreadable_config_carries_an_error_field() {
860 let entry = |reason| RepoStatusEntry {
861 path: PathBuf::from("/tmp/repo"),
862 entry: crate::config::RepoEntry::new(),
863 reason,
864 adapters: Vec::new(),
865 bloat_dirs: Vec::new(),
866 reclaimable_bytes: 0,
867 reclaimable_by_adapter: Vec::new(),
868 last_activity: None,
869 idle_days: 15,
870 };
871
872 let registry = Registry::default();
873 let broken = repo_value(
874 ®istry,
875 &entry(SkipReason::ConfigError("bad json".into())),
876 );
877 assert_eq!(broken["state"], "config_error");
878 assert_eq!(broken["error"], "bad json");
879
880 let healthy = repo_value(®istry, &entry(SkipReason::Candidate));
882 assert!(healthy.get("error").is_none());
883 }
884
885 #[test]
886 fn run_summary_counts_only_real_deletions() {
887 let doc = run_document(
888 &[
889 result(PruneStatus::Pruned, 100),
890 result(PruneStatus::Pruned, 50),
891 result(PruneStatus::SkippedActive, 0),
892 result(PruneStatus::LockfileError("nope".into()), 0),
893 ],
894 false,
895 );
896 assert_eq!(doc["summary"]["bytes_freed"], 150);
897 assert_eq!(doc["summary"]["directories_pruned"], 2);
898 assert_eq!(doc["summary"]["errors"], 1);
899 }
900
901 #[test]
902 fn dry_run_bytes_land_in_reclaimable_not_freed() {
903 let doc = run_document(&[result(PruneStatus::SkippedDryRun, 4096)], true);
906 assert_eq!(doc["summary"]["bytes_freed"], 0);
907 assert_eq!(doc["summary"]["bytes_reclaimable"], 4096);
908 assert_eq!(doc["dry_run"], true);
909 }
910
911 #[test]
912 fn lockfile_errors_carry_the_fix_command() {
913 let doc = run_document(
914 &[result(PruneStatus::LockfileError("boom".into()), 0)],
915 false,
916 );
917 assert_eq!(doc["results"][0]["message"], "boom");
918 assert_eq!(
919 doc["results"][0]["fix_command"],
920 "pnpm install --lockfile-only"
921 );
922 }
923
924 #[test]
925 fn a_successful_result_carries_no_message_or_fix() {
926 let doc = run_document(&[result(PruneStatus::Pruned, 1)], false);
927 assert!(doc["results"][0].get("message").is_none());
928 assert!(doc["results"][0].get("fix_command").is_none());
929 }
930
931 #[test]
932 fn venv_has_no_mechanical_lockfile_fix() {
933 assert!(lockfile_fix_command("venv").is_none());
936 assert!(lockfile_fix_command("nonsense").is_none());
937 }
938
939 #[test]
940 fn the_cache_report_totals_what_it_lists() {
941 use crate::commands::caches::{CacheReport, Clear};
942
943 let doc = caches_document(
944 &[
945 CacheReport {
946 manager: "go",
947 kind: "module cache",
948 path: PathBuf::from("/home/dev/go/pkg/mod"),
949 bytes: 4_000,
950 clear_command: "go clean -modcache".to_string(),
951 clear: Clear::Command("go", &["clean", "-modcache"]),
952 note: None,
953 cap_gb: None,
954 over_cap: false,
955 dependents: None,
956 extra_args: Vec::new(),
957 },
958 CacheReport {
959 manager: "pnpm",
960 kind: "store",
961 path: PathBuf::from("/home/dev/.pnpm-store"),
962 bytes: 1_000,
963 clear_command: "pnpm store prune".to_string(),
964 clear: Clear::Command("pnpm", &["store", "prune"]),
965 note: Some("hardlinked"),
966 cap_gb: None,
967 over_cap: false,
968 dependents: None,
969 extra_args: Vec::new(),
970 },
971 ],
972 Some(3),
973 &[],
974 None,
975 );
976
977 assert_eq!(doc["command"], "caches");
978 assert_eq!(doc["summary"]["total_bytes"], 5_000);
979 assert_eq!(doc["summary"]["count"], 2);
980 assert!(doc["caches"][0].get("note").is_none());
983 assert_eq!(doc["caches"][1]["note"], "hardlinked");
984 assert_eq!(doc["caches"][0]["clear_command"], "go clean -modcache");
985 }
986
987 #[test]
988 fn an_empty_cache_report_is_still_a_document() {
989 let doc = caches_document(&[], None, &[], None);
992 assert_eq!(doc["summary"]["total_bytes"], 0);
993 assert_eq!(doc["caches"].as_array().unwrap().len(), 0);
994 assert_eq!(doc["containers"].as_array().unwrap().len(), 0);
995 }
996
997 #[test]
998 fn cache_clears_are_reported_beside_the_prune_total_not_inside_it() {
999 let mut registry = crate::config::Registry {
1000 total_freed_bytes: 12_000_000_000,
1001 ..Default::default()
1002 };
1003 registry.record_cache_clear(6_000_000_000);
1004
1005 let doc = stats_document(®istry);
1006
1007 assert_eq!(doc["lifetime"]["bytes_freed"], 12_000_000_000u64);
1008 assert_eq!(doc["lifetime"]["cache_bytes_freed"], 6_000_000_000u64);
1009 }
1010
1011 #[test]
1012 fn container_disk_stays_out_of_the_cache_total() {
1013 use crate::commands::containers::{EngineReport, EngineState, Row};
1014
1015 let docker = EngineReport {
1016 name: "docker",
1017 state: EngineState::Ready(vec![Row {
1018 kind: "Images".to_string(),
1019 total: Some(9),
1020 active: Some(2),
1021 bytes: Some(40_000_000_000),
1022 reclaimable: Some(38_000_000_000),
1023 }]),
1024 };
1025 let doc = caches_document(&[], None, std::slice::from_ref(&docker), None);
1026
1027 assert_eq!(doc["summary"]["total_bytes"], 0);
1031 assert_eq!(doc["containers"][0]["engine"], "docker");
1032 assert_eq!(doc["containers"][0]["total_bytes"], 40_000_000_000u64);
1033 assert_eq!(doc["containers"][0]["rows"][0]["kind"], "Images");
1034 }
1035
1036 #[test]
1037 fn an_engine_that_did_not_answer_carries_no_zero() {
1038 use crate::commands::containers::{EngineReport, EngineState};
1039
1040 let doc = containers_document(
1041 &[EngineReport {
1042 name: "docker",
1043 state: EngineState::Unavailable("daemon is not running".to_string()),
1044 }],
1045 &[],
1046 );
1047
1048 assert_eq!(doc["command"], "caches containers");
1049 assert_eq!(doc["engines"][0]["available"], false);
1050 assert_eq!(doc["engines"][0]["reason"], "daemon is not running");
1051 assert!(doc["engines"][0].get("total_bytes").is_none());
1054 assert_eq!(doc["summary"]["total_bytes"], 0);
1055 }
1056
1057 #[test]
1058 fn no_prune_command_reaches_the_json_contract() {
1059 use crate::commands::containers::{EngineReport, EngineState, Row};
1060
1061 let doc = containers_document(
1062 &[EngineReport {
1063 name: "docker",
1064 state: EngineState::Ready(vec![Row {
1065 kind: "Build Cache".to_string(),
1066 total: Some(41),
1067 active: Some(0),
1068 bytes: Some(6_750_000_000),
1069 reclaimable: Some(6_750_000_000),
1070 }]),
1071 }],
1072 &["kind-dev".to_string()],
1073 );
1074
1075 let text = serde_json::to_string(&doc).unwrap();
1078 assert!(!text.contains("prune"), "{text}");
1079 assert_eq!(doc["kubernetes_contexts"][0], "kind-dev");
1080 assert_eq!(doc["summary"]["reclaimable_bytes"], 6_750_000_000u64);
1081 }
1082
1083 #[test]
1084 fn every_adapter_with_a_lockfile_has_a_fix_command() {
1085 for adapter in crate::adapters::get_all_adapters() {
1086 if matches!(
1090 adapter.name(),
1091 "venv" | "gradle" | "maven" | "swift" | "vcpkg" | "cmake_build"
1092 ) {
1093 continue;
1094 }
1095 assert!(
1096 lockfile_fix_command(adapter.name()).is_some(),
1097 "{} has no fix command",
1098 adapter.name()
1099 );
1100 }
1101 }
1102}