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