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