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