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) -> Value {
456 let total: u64 = reports.iter().map(|r| r.bytes).sum();
457
458 let caches: Vec<Value> = reports
459 .iter()
460 .map(|r| {
461 let mut obj = json!({
462 "manager": r.manager,
463 "kind": r.kind,
464 "path": clean_path(&r.path),
465 "bytes": r.bytes,
466 "clear_command": &r.clear_command,
467 });
468 if let Some(note) = r.note {
469 obj["note"] = json!(note);
470 }
471 if let Some(gb) = r.cap_gb {
474 obj["cap_gb"] = json!(gb);
475 obj["over_cap"] = json!(r.over_cap);
476 }
477 if let Some(n) = r.dependents {
481 obj["dependents"] = json!(n);
482 }
483 obj
484 })
485 .collect();
486
487 let mut summary = json!({
488 "total_bytes": total,
489 "count": reports.len(),
490 });
491 if let Some(n) = registered_repositories {
492 summary["registered_repositories"] = json!(n);
493 }
494
495 json!({
499 "schema": SCHEMA_VERSION,
500 "version": constants::VERSION,
501 "command": "caches",
502 "caches": caches,
503 "containers": container_engines(containers),
504 "summary": summary,
505 })
506}
507fn kept_caches(kept: &[crate::commands::caches::CacheReport]) -> Vec<Value> {
512 use crate::commands::caches::Clear;
513 kept.iter()
514 .filter_map(|r| {
515 let Clear::Manual { why } = r.clear else {
516 return None;
517 };
518 Some(json!({
519 "manager": r.manager,
520 "kind": r.kind,
521 "path": clean_path(&r.path),
522 "bytes": r.bytes,
523 "clear_command": &r.clear_command,
524 "reason": why,
525 }))
526 })
527 .collect()
528}
529
530pub fn caches_clear_plan_document(
532 reports: &[crate::commands::caches::CacheReport],
533 kept: &[crate::commands::caches::CacheReport],
534) -> Value {
535 let total: u64 = reports.iter().map(|r| r.bytes).sum();
536
537 let caches: Vec<Value> = reports
538 .iter()
539 .map(|r| {
540 let mut obj = json!({
541 "manager": r.manager,
542 "kind": r.kind,
543 "path": clean_path(&r.path),
544 "bytes": r.bytes,
545 "clear_command": &r.clear_command,
546 });
547 if let Some(n) = r.dependents {
548 obj["dependents"] = json!(n);
549 }
550 obj
551 })
552 .collect();
553
554 json!({
555 "schema": SCHEMA_VERSION,
556 "version": constants::VERSION,
557 "command": "caches clear",
558 "dry_run": true,
559 "caches": caches,
560 "kept": kept_caches(kept),
561 "summary": {
562 "total_bytes": total,
563 "count": reports.len(),
564 },
565 })
566}
567
568pub fn caches_clear_document(
573 outcomes: &[crate::commands::caches::ClearOutcome],
574 kept: &[crate::commands::caches::CacheReport],
575) -> Value {
576 let freed: u64 = outcomes.iter().map(|o| o.freed()).sum();
577 let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
578
579 let caches: Vec<Value> = outcomes
580 .iter()
581 .map(|o| {
582 let mut obj = json!({
583 "manager": o.manager,
584 "kind": o.kind,
585 "path": clean_path(&o.path),
586 "bytes_before": o.before,
587 "bytes_after": o.after,
588 "freed_bytes": o.freed(),
589 "cleared": o.problem.is_none(),
590 });
591 if let Some(problem) = &o.problem {
592 obj["error"] = json!(problem);
593 }
594 obj
595 })
596 .collect();
597
598 json!({
599 "schema": SCHEMA_VERSION,
600 "version": constants::VERSION,
601 "command": "caches clear",
602 "dry_run": false,
603 "caches": caches,
604 "kept": kept_caches(kept),
605 "summary": {
606 "freed_bytes": freed,
607 "count": outcomes.len(),
608 "failed": failed,
609 },
610 })
611}
612pub fn trust_document(report: &crate::commands::trust::TrustReport) -> Value {
618 let rows = |rows: &[crate::commands::trust::TrustRow]| -> Vec<Value> {
619 rows.iter()
620 .map(|r| {
621 json!({
622 "key": r.key,
623 "subject": r.subject,
624 "state": r.state,
625 "verdict": r.verdict_key(),
626 })
627 })
628 .collect()
629 };
630
631 let widened = report.widened();
632
633 json!({
634 "schema": SCHEMA_VERSION,
635 "version": constants::VERSION,
636 "command": "trust",
637 "guarantees": rows(&report.guarantees),
638 "machine": rows(&report.machine),
639 "summary": {
640 "widened": widened,
641 "widened_count": widened.len(),
642 },
643 })
644}
645
646pub fn drift_document(findings: &[crate::commands::status::ProjectDrift]) -> Value {
653 let unrecorded_total: usize = findings.iter().map(|f| f.report.unrecorded.len()).sum();
654
655 json!({
656 "schema": SCHEMA_VERSION,
657 "version": constants::VERSION,
658 "command": "status --drift",
659 "drift": findings.iter().map(|f| json!({
660 "repository": clean_path(&f.repository),
661 "project": f.project,
662 "adapter": f.adapter,
663 "directory": f.report.directory,
664 "unrecorded": f.report.unrecorded,
665 "record_command": f.report.record_command,
666 })).collect::<Vec<_>>(),
667 "summary": {
668 "projects_with_drift": findings.len(),
669 "unrecorded_packages": unrecorded_total,
670 },
671 })
672}
673
674pub fn emit(document: &Value) -> anyhow::Result<()> {
685 use std::io::IsTerminal;
686 let text = serde_json::to_string_pretty(document)?;
687 println!("{text}");
688 if std::io::stdout().is_terminal() && copy_to_clipboard(&text) {
689 use colored::Colorize;
690 eprintln!("{}", "(also copied to your clipboard)".dimmed());
691 }
692 Ok(())
693}
694
695fn copy_to_clipboard(text: &str) -> bool {
702 let bytes: Vec<u8> = if cfg!(windows) {
706 let mut utf16 = vec![0xFF, 0xFE];
707 for unit in text.encode_utf16() {
708 utf16.extend_from_slice(&unit.to_le_bytes());
709 }
710 utf16
711 } else {
712 text.as_bytes().to_vec()
713 };
714
715 let windows_clip = std::env::var("SystemRoot")
721 .map(|root| format!("{root}\\System32\\clip.exe"))
722 .unwrap_or_else(|_| String::from("C:\\Windows\\System32\\clip.exe"));
723 let tools: Vec<Vec<&str>> = if cfg!(windows) {
724 vec![vec![windows_clip.as_str()]]
725 } else if cfg!(target_os = "macos") {
726 vec![vec!["pbcopy"]]
727 } else {
728 vec![
729 vec!["wl-copy"],
730 vec!["xclip", "-selection", "clipboard"],
731 vec!["xsel", "--clipboard", "--input"],
732 ]
733 };
734 tools.iter().any(|tool| pipe_into(tool, &bytes))
735}
736
737fn pipe_into(command: &[&str], bytes: &[u8]) -> bool {
739 use std::io::Write;
740 use std::process::Stdio;
741 let Ok(mut child) = crate::spawn::command(command[0])
742 .args(&command[1..])
743 .stdin(Stdio::piped())
744 .stdout(Stdio::null())
745 .stderr(Stdio::null())
746 .spawn()
747 else {
748 return false;
749 };
750 let wrote = child
751 .stdin
752 .take()
753 .is_some_and(|mut stdin| stdin.write_all(bytes).is_ok());
754 let exited_cleanly = child.wait().map(|status| status.success()).unwrap_or(false);
755 wrote && exited_cleanly
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761 use std::path::PathBuf;
762
763 fn result(status: PruneStatus, bytes: u64) -> PruneResult {
764 PruneResult {
765 repo_path: PathBuf::from("/tmp/repo"),
766 adapter_name: "pnpm".to_string(),
767 bloat_dir: "node_modules".to_string(),
768 size_freed: bytes,
769 shared_bytes: 0,
770 runtime: None,
771 status,
772 }
773 }
774
775 #[test]
776 fn every_status_has_a_distinct_stable_tag() {
777 let all = [
778 PruneStatus::Pruned,
779 PruneStatus::SkippedActive,
780 PruneStatus::SkippedDryRun,
781 PruneStatus::LockfileError("x".into()),
782 PruneStatus::ActivityCheckError("x".into()),
783 PruneStatus::PathMissing,
784 PruneStatus::NoBloat,
785 PruneStatus::Disabled,
786 PruneStatus::SkippedIgnored,
787 PruneStatus::DeleteError("x".into()),
788 PruneStatus::ConfigError("x".into()),
789 PruneStatus::SkippedSymlink("x".into()),
790 PruneStatus::SkippedDeclaration("x".into()),
791 PruneStatus::SkippedNestedRepo("x".into()),
792 ];
793 let mut tags: Vec<&str> = all.iter().map(status_tag).collect();
794 let count = tags.len();
795 tags.sort_unstable();
796 tags.dedup();
797 assert_eq!(tags.len(), count, "two statuses share a JSON tag");
798 }
799
800 #[test]
801 fn every_repository_state_has_a_distinct_stable_tag() {
802 let all = [
803 SkipReason::Candidate,
804 SkipReason::Active,
805 SkipReason::Ignored,
806 SkipReason::NoBloat,
807 SkipReason::PathMissing,
808 SkipReason::ConfigError("x".into()),
809 ];
810 let mut tags: Vec<&str> = all.iter().map(reason_tag).collect();
811 let count = tags.len();
812 tags.sort_unstable();
813 tags.dedup();
814 assert_eq!(tags.len(), count, "two repository states share a JSON tag");
815 }
816
817 #[test]
818 fn only_an_unreadable_config_carries_an_error_field() {
819 let entry = |reason| RepoStatusEntry {
820 path: PathBuf::from("/tmp/repo"),
821 entry: crate::config::RepoEntry::new(),
822 reason,
823 adapters: Vec::new(),
824 bloat_dirs: Vec::new(),
825 reclaimable_bytes: 0,
826 reclaimable_by_adapter: Vec::new(),
827 last_activity: None,
828 idle_days: 15,
829 };
830
831 let registry = Registry::default();
832 let broken = repo_value(
833 ®istry,
834 &entry(SkipReason::ConfigError("bad json".into())),
835 );
836 assert_eq!(broken["state"], "config_error");
837 assert_eq!(broken["error"], "bad json");
838
839 let healthy = repo_value(®istry, &entry(SkipReason::Candidate));
841 assert!(healthy.get("error").is_none());
842 }
843
844 #[test]
845 fn run_summary_counts_only_real_deletions() {
846 let doc = run_document(
847 &[
848 result(PruneStatus::Pruned, 100),
849 result(PruneStatus::Pruned, 50),
850 result(PruneStatus::SkippedActive, 0),
851 result(PruneStatus::LockfileError("nope".into()), 0),
852 ],
853 false,
854 );
855 assert_eq!(doc["summary"]["bytes_freed"], 150);
856 assert_eq!(doc["summary"]["directories_pruned"], 2);
857 assert_eq!(doc["summary"]["errors"], 1);
858 }
859
860 #[test]
861 fn dry_run_bytes_land_in_reclaimable_not_freed() {
862 let doc = run_document(&[result(PruneStatus::SkippedDryRun, 4096)], true);
865 assert_eq!(doc["summary"]["bytes_freed"], 0);
866 assert_eq!(doc["summary"]["bytes_reclaimable"], 4096);
867 assert_eq!(doc["dry_run"], true);
868 }
869
870 #[test]
871 fn lockfile_errors_carry_the_fix_command() {
872 let doc = run_document(
873 &[result(PruneStatus::LockfileError("boom".into()), 0)],
874 false,
875 );
876 assert_eq!(doc["results"][0]["message"], "boom");
877 assert_eq!(
878 doc["results"][0]["fix_command"],
879 "pnpm install --lockfile-only"
880 );
881 }
882
883 #[test]
884 fn a_successful_result_carries_no_message_or_fix() {
885 let doc = run_document(&[result(PruneStatus::Pruned, 1)], false);
886 assert!(doc["results"][0].get("message").is_none());
887 assert!(doc["results"][0].get("fix_command").is_none());
888 }
889
890 #[test]
891 fn venv_has_no_mechanical_lockfile_fix() {
892 assert!(lockfile_fix_command("venv").is_none());
895 assert!(lockfile_fix_command("nonsense").is_none());
896 }
897
898 #[test]
899 fn the_cache_report_totals_what_it_lists() {
900 use crate::commands::caches::{CacheReport, Clear};
901
902 let doc = caches_document(
903 &[
904 CacheReport {
905 manager: "go",
906 kind: "module cache",
907 path: PathBuf::from("/home/dev/go/pkg/mod"),
908 bytes: 4_000,
909 clear_command: "go clean -modcache".to_string(),
910 clear: Clear::Command("go", &["clean", "-modcache"]),
911 note: None,
912 cap_gb: None,
913 over_cap: false,
914 dependents: None,
915 extra_args: Vec::new(),
916 },
917 CacheReport {
918 manager: "pnpm",
919 kind: "store",
920 path: PathBuf::from("/home/dev/.pnpm-store"),
921 bytes: 1_000,
922 clear_command: "pnpm store prune".to_string(),
923 clear: Clear::Command("pnpm", &["store", "prune"]),
924 note: Some("hardlinked"),
925 cap_gb: None,
926 over_cap: false,
927 dependents: None,
928 extra_args: Vec::new(),
929 },
930 ],
931 Some(3),
932 &[],
933 );
934
935 assert_eq!(doc["command"], "caches");
936 assert_eq!(doc["summary"]["total_bytes"], 5_000);
937 assert_eq!(doc["summary"]["count"], 2);
938 assert!(doc["caches"][0].get("note").is_none());
941 assert_eq!(doc["caches"][1]["note"], "hardlinked");
942 assert_eq!(doc["caches"][0]["clear_command"], "go clean -modcache");
943 }
944
945 #[test]
946 fn an_empty_cache_report_is_still_a_document() {
947 let doc = caches_document(&[], None, &[]);
950 assert_eq!(doc["summary"]["total_bytes"], 0);
951 assert_eq!(doc["caches"].as_array().unwrap().len(), 0);
952 assert_eq!(doc["containers"].as_array().unwrap().len(), 0);
953 }
954
955 #[test]
956 fn cache_clears_are_reported_beside_the_prune_total_not_inside_it() {
957 let mut registry = crate::config::Registry {
958 total_freed_bytes: 12_000_000_000,
959 ..Default::default()
960 };
961 registry.record_cache_clear(6_000_000_000);
962
963 let doc = stats_document(®istry);
964
965 assert_eq!(doc["lifetime"]["bytes_freed"], 12_000_000_000u64);
966 assert_eq!(doc["lifetime"]["cache_bytes_freed"], 6_000_000_000u64);
967 }
968
969 #[test]
970 fn container_disk_stays_out_of_the_cache_total() {
971 use crate::commands::containers::{EngineReport, EngineState, Row};
972
973 let docker = EngineReport {
974 name: "docker",
975 state: EngineState::Ready(vec![Row {
976 kind: "Images".to_string(),
977 total: Some(9),
978 active: Some(2),
979 bytes: Some(40_000_000_000),
980 reclaimable: Some(38_000_000_000),
981 }]),
982 };
983 let doc = caches_document(&[], None, std::slice::from_ref(&docker));
984
985 assert_eq!(doc["summary"]["total_bytes"], 0);
989 assert_eq!(doc["containers"][0]["engine"], "docker");
990 assert_eq!(doc["containers"][0]["total_bytes"], 40_000_000_000u64);
991 assert_eq!(doc["containers"][0]["rows"][0]["kind"], "Images");
992 }
993
994 #[test]
995 fn an_engine_that_did_not_answer_carries_no_zero() {
996 use crate::commands::containers::{EngineReport, EngineState};
997
998 let doc = containers_document(
999 &[EngineReport {
1000 name: "docker",
1001 state: EngineState::Unavailable("daemon is not running".to_string()),
1002 }],
1003 &[],
1004 );
1005
1006 assert_eq!(doc["command"], "caches containers");
1007 assert_eq!(doc["engines"][0]["available"], false);
1008 assert_eq!(doc["engines"][0]["reason"], "daemon is not running");
1009 assert!(doc["engines"][0].get("total_bytes").is_none());
1012 assert_eq!(doc["summary"]["total_bytes"], 0);
1013 }
1014
1015 #[test]
1016 fn no_prune_command_reaches_the_json_contract() {
1017 use crate::commands::containers::{EngineReport, EngineState, Row};
1018
1019 let doc = containers_document(
1020 &[EngineReport {
1021 name: "docker",
1022 state: EngineState::Ready(vec![Row {
1023 kind: "Build Cache".to_string(),
1024 total: Some(41),
1025 active: Some(0),
1026 bytes: Some(6_750_000_000),
1027 reclaimable: Some(6_750_000_000),
1028 }]),
1029 }],
1030 &["kind-dev".to_string()],
1031 );
1032
1033 let text = serde_json::to_string(&doc).unwrap();
1036 assert!(!text.contains("prune"), "{text}");
1037 assert_eq!(doc["kubernetes_contexts"][0], "kind-dev");
1038 assert_eq!(doc["summary"]["reclaimable_bytes"], 6_750_000_000u64);
1039 }
1040
1041 #[test]
1042 fn every_adapter_with_a_lockfile_has_a_fix_command() {
1043 for adapter in crate::adapters::get_all_adapters() {
1044 if matches!(
1048 adapter.name(),
1049 "venv" | "gradle" | "maven" | "swift" | "vcpkg" | "cmake_build"
1050 ) {
1051 continue;
1052 }
1053 assert!(
1054 lockfile_fix_command(adapter.name()).is_some(),
1055 "{} has no fix command",
1056 adapter.name()
1057 );
1058 }
1059 }
1060}