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::SkippedAdapterWindow(_) => "skipped_adapter_window",
38 PruneStatus::SkippedDryRun => "skipped_dry_run",
39 PruneStatus::LockfileError(_) => "lockfile_error",
40 PruneStatus::ActivityCheckError(_) => "activity_check_error",
41 PruneStatus::PathMissing => "path_missing",
42 PruneStatus::NotARepo => "not_a_repo",
43 PruneStatus::NoBloat => "no_bloat",
44 PruneStatus::Disabled => "disabled",
45 PruneStatus::SkippedIgnored => "ignored",
46 PruneStatus::DeleteError(_) => "delete_error",
47 PruneStatus::ConfigError(_) => "config_error",
48 PruneStatus::SkippedSymlink(_) => "skipped_symlink",
49 PruneStatus::SkippedDeclaration(_) => "skipped_declaration",
50 PruneStatus::SkippedNestedRepo(_) => "skipped_nested_repo",
51 }
52}
53
54fn status_message(status: &PruneStatus) -> Option<&str> {
56 match status {
57 PruneStatus::LockfileError(e)
58 | PruneStatus::ActivityCheckError(e)
59 | PruneStatus::DeleteError(e)
60 | PruneStatus::ConfigError(e)
61 | PruneStatus::SkippedSymlink(e)
62 | PruneStatus::SkippedDeclaration(e)
63 | PruneStatus::SkippedNestedRepo(e) => Some(e.trim()),
64 _ => None,
65 }
66}
67
68pub fn lockfile_fix_command(adapter: &str) -> Option<&'static str> {
79 Some(match adapter {
80 "npm" => "npm install --package-lock-only --ignore-scripts",
81 "pnpm" => "pnpm install --lockfile-only",
82 "yarn" => "yarn install --mode update-lockfile",
83 "bun" => "bun install",
86 "deno" => "deno install",
89 "uv" => "uv lock",
90 "poetry" => "poetry lock",
91 "pdm" => "pdm lock",
92 "pipenv" => "pipenv lock",
93 "cargo" => "cargo generate-lockfile",
94 "go" => "go mod tidy",
95 "composer" => "composer update --no-install",
96 "bundler" => "bundle lock",
97 "cocoapods" => "pod install",
98 "mix" | "mix_build" => "mix deps.get",
100 "terraform" => "terraform providers lock",
103 "dart" => "dart pub get",
106 "pixi" => "pixi lock",
109 _ => return None,
115 })
116}
117
118fn result_value(result: &PruneResult) -> Value {
119 let mut obj = json!({
120 "repository": clean_path(&result.repo_path),
121 "adapter": result.adapter_name,
122 "directory": result.bloat_dir,
123 "status": status_tag(&result.status),
124 "bytes": result.size_freed,
125 "shared_bytes": result.shared_bytes,
126 });
127
128 if let Some(message) = status_message(&result.status) {
129 obj["message"] = json!(message);
130 }
131 if matches!(result.status, PruneStatus::LockfileError(_))
132 && let Some(fix) = lockfile_fix_command(&result.adapter_name)
133 {
134 obj["fix_command"] = json!(fix);
135 }
136 obj
137}
138
139pub fn run_document(results: &[PruneResult], dry_run: bool) -> Value {
144 let bytes_freed: u64 = results
145 .iter()
146 .filter(|r| matches!(r.status, PruneStatus::Pruned))
147 .map(|r| r.size_freed)
148 .sum();
149 let directories_pruned = results
150 .iter()
151 .filter(|r| matches!(r.status, PruneStatus::Pruned))
152 .count();
153 let bytes_reclaimable: u64 = results
154 .iter()
155 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
156 .map(|r| r.size_freed)
157 .sum();
158 let errors = results
159 .iter()
160 .filter(|r| {
161 matches!(
162 r.status,
163 PruneStatus::LockfileError(_)
164 | PruneStatus::ActivityCheckError(_)
165 | PruneStatus::DeleteError(_)
166 | PruneStatus::ConfigError(_)
167 )
168 })
169 .count();
170
171 json!({
172 "schema": SCHEMA_VERSION,
173 "version": constants::VERSION,
174 "command": "run",
175 "dry_run": dry_run,
176 "results": results.iter().map(result_value).collect::<Vec<_>>(),
177 "summary": {
178 "bytes_freed": bytes_freed,
179 "bytes_reclaimable": bytes_reclaimable,
180 "directories_pruned": directories_pruned,
181 "errors": errors,
182 },
183 })
184}
185
186pub fn attach_recommendations(doc: &mut Value, recommendations: &[(String, usize)]) {
193 if recommendations.is_empty() {
194 return;
195 }
196 if let Some(obj) = doc.as_object_mut() {
197 let entries: Vec<Value> = recommendations
198 .iter()
199 .map(|(adapter, repositories)| {
200 json!({
201 "adapter": adapter,
202 "setting": format!("enable_{adapter}"),
203 "command": format!("devp config set enable_{adapter} true"),
204 "repositories": repositories,
205 })
206 })
207 .collect();
208 obj.insert("recommendations".to_string(), Value::Array(entries));
209 }
210}
211
212fn reason_tag(reason: &SkipReason) -> &'static str {
214 match reason {
215 SkipReason::Candidate => "candidate",
216 SkipReason::Active => "active",
217 SkipReason::Ignored => "ignored",
218 SkipReason::NoBloat => "no_bloat",
219 SkipReason::PathMissing => "path_missing",
220 SkipReason::ConfigError(_) => "config_error",
221 }
222}
223
224fn settings_value(settings: &Settings) -> Value {
225 json!({
226 "idle_days": settings.idle_days,
227 "check_interval_days": settings.check_interval_days,
228 "auto_setup": settings.auto_setup,
229 "auto_hooks": settings.auto_hooks,
230 "auto_daemon": settings.auto_daemon,
231 "require_confirmation": settings.require_confirmation,
232 "command_timeout_secs": settings.command_timeout_secs,
233 "min_size_mb": settings.min_size_mb,
234 "update_check": settings.update_check,
235 })
236}
237
238fn repo_value(registry: &Registry, entry: &RepoStatusEntry) -> Value {
239 let mut obj = json!({
240 "path": clean_path(&entry.path),
241 "state": reason_tag(&entry.reason),
242 "enabled": entry.entry.enabled,
243 "idle_days": entry.idle_days,
244 "last_activity": entry.last_activity.map(|t| t.to_rfc3339()),
245 "last_pruned_at": entry.entry.last_pruned_at.map(|t| t.to_rfc3339()),
246 "bytes_freed": entry.entry.total_freed_bytes,
247 "added_at": entry.entry.added_at.to_rfc3339(),
248 "adapters": entry.adapters,
249 "reclaimable_bytes": entry.reclaimable_bytes,
250 "directories": entry.bloat_dirs.iter().map(|b| json!({
251 "name": b.name,
252 "path": clean_path(&b.path),
253 "bytes": b.size_bytes,
254 "shared_bytes": b.shared_bytes,
255 })).collect::<Vec<_>>(),
256 "restore_estimate_secs": registry
259 .estimate_restore(&entry.reclaimable_by_adapter)
260 .map(|(secs, _)| secs.round() as u64),
261 });
262
263 if let SkipReason::ConfigError(e) = &entry.reason {
268 obj["error"] = json!(e);
269 }
270 obj
271}
272
273pub fn status_document(
283 registry: &Registry,
284 repos: &[RepoStatusEntry],
285 daemon: &str,
286 hooks: &str,
287 top: Option<usize>,
288) -> Value {
289 let reclaimable: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
290 let candidates = repos
291 .iter()
292 .filter(|r| matches!(r.reason, SkipReason::Candidate))
293 .count();
294 let listed = crate::engine::take_top(repos, top);
295
296 let mut by_adapter: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
298 for repo in repos {
299 for (adapter, bytes) in &repo.reclaimable_by_adapter {
300 *by_adapter.entry(adapter.clone()).or_default() += bytes;
301 }
302 }
303 let estimate = registry.estimate_restore(&by_adapter.into_iter().collect::<Vec<_>>());
304
305 let mut doc = json!({
306 "schema": SCHEMA_VERSION,
307 "version": constants::VERSION,
308 "command": "status",
309 "config_path": Registry::registry_path().map(|p| clean_path(&p)).ok(),
310 "integrations": { "daemon": daemon, "git_hooks": hooks },
311 "settings": settings_value(®istry.settings),
312 "totals": {
313 "repositories": registry.repo_count(),
314 "candidates": candidates,
315 "reclaimable_bytes": reclaimable,
316 "historical_bytes_freed": registry.total_freed_bytes,
317 "prune_passes": registry.total_pruned_count,
318 "restore_estimate": estimate.map(|(secs, covered)| json!({
323 "seconds": secs.round() as u64,
324 "covered_bytes": covered,
325 "samples": registry.restore_rates.values().map(|r| r.samples as u64).sum::<u64>(),
326 })),
327 },
328 "repositories": listed.iter().map(|r| repo_value(registry, r)).collect::<Vec<_>>(),
329 });
330
331 if let Some(n) = top {
334 doc["top"] = json!(n);
335 }
336 doc
337}
338
339pub fn stats_document(registry: &Registry, passes: &[crate::history::Pass]) -> Value {
354 let (managers, managers_uncounted) = crate::commands::stats::rank_managers(passes);
355 let (triggers, triggers_uncounted) = crate::commands::stats::split_by_trigger(passes);
356
357 let mut repos: Vec<(&std::path::PathBuf, &crate::config::RepoEntry)> =
358 registry.repositories.iter().collect();
359 repos.sort_by(|a, b| {
360 b.1.total_freed_bytes
361 .cmp(&a.1.total_freed_bytes)
362 .then_with(|| a.0.cmp(b.0))
363 });
364
365 json!({
366 "schema": SCHEMA_VERSION,
367 "version": constants::VERSION,
368 "command": "stats",
369 "history_starts_at": constants::HISTORY_STARTS_AT,
370 "detail_starts_at": constants::PRUNE_LOG_STARTS_AT,
373 "lifetime": {
374 "bytes_freed": registry.total_freed_bytes,
375 "cache_bytes_freed": registry.total_cache_freed_bytes,
379 "container_bytes_freed": registry.total_container_freed_bytes,
380 "prune_passes": registry.total_pruned_count,
383 "repositories": registry.repo_count(),
384 },
385 "last_prune": registry.last_prune.as_ref().map(|p| json!({
386 "at": p.at.to_rfc3339(),
387 "bytes_freed": p.dirs.iter().map(|d| d.size_freed).sum::<u64>(),
388 "directories": p.dirs.len(),
389 })),
390 "recent_passes": registry.prune_history.iter().rev().map(|p| json!({
391 "at": p.at.to_rfc3339(),
392 "bytes_freed": p.bytes_freed,
393 "directories": p.dirs_removed,
394 "repositories": p.repos_touched,
395 })).collect::<Vec<_>>(),
396 "repositories": repos.iter().map(|(path, entry)| json!({
397 "path": clean_path(path),
398 "bytes_freed": entry.total_freed_bytes,
399 "last_pruned_at": entry.last_pruned_at.map(|t| t.to_rfc3339()),
400 })).collect::<Vec<_>>(),
401 "by_manager": {
404 "passes_not_counted": managers_uncounted,
405 "managers": managers.iter().map(|m| json!({
406 "manager": m.manager,
407 "bytes_freed": m.bytes,
408 "directories": m.dirs,
409 })).collect::<Vec<_>>(),
410 },
411 "by_trigger": {
412 "passes_not_counted": triggers_uncounted,
413 "triggers": triggers.iter().map(|t| json!({
416 "trigger": t.trigger.label(),
417 "bytes_freed": t.bytes,
418 "passes": t.passes,
419 })).collect::<Vec<_>>(),
420 },
421 })
422}
423
424pub fn history_document(passes: &[crate::history::Pass], only: Option<usize>) -> Value {
438 use crate::history::Pass;
439 json!({
440 "schema": SCHEMA_VERSION,
441 "version": constants::VERSION,
442 "command": "history",
443 "detail_starts_at": constants::PRUNE_LOG_STARTS_AT,
444 "passes": passes.iter().enumerate()
445 .filter(|(index, _)| only.is_none_or(|n| n == index + 1))
446 .map(|(index, pass)| {
447 let mut entry = json!({
448 "pass": index + 1,
450 "at": pass.at().to_rfc3339(),
451 "bytes_freed": pass.bytes_freed(),
452 "directories": pass.dirs_removed(),
453 "repositories": pass.repos_touched(),
454 "detail": pass.dirs().is_some(),
455 });
456 let map = entry.as_object_mut().expect("json! built an object");
457 if let Pass::Detailed(record) = pass {
458 map.insert("trigger".into(), json!(record.trigger.label()));
459 map.insert("argv".into(), json!(record.argv));
460 map.insert("command_line".into(), json!(record.command_line()));
461 map.insert("dev_prune_version".into(), json!(record.version));
462 }
463 if let Some(dirs) = pass.dirs() {
464 map.insert("removed".into(), json!(dirs.iter().map(|d| json!({
465 "repo_path": clean_path(&d.repo_path),
466 "directory": d.bloat_dir,
467 "adapter": d.adapter,
468 "bytes_freed": d.size_freed,
469 "runtime": d.runtime,
470 })).collect::<Vec<_>>()));
471 }
472 entry
473 }).collect::<Vec<_>>(),
474 })
475}
476
477fn container_engines(reports: &[crate::commands::containers::EngineReport]) -> Vec<Value> {
489 use crate::commands::containers::EngineState;
490 reports
491 .iter()
492 .map(|report| match &report.state {
493 EngineState::Unavailable(reason) => json!({
494 "engine": report.name,
495 "available": false,
496 "reason": reason,
497 }),
498 EngineState::Ready(rows) => json!({
499 "engine": report.name,
500 "available": true,
501 "rows": rows.iter().map(|row| {
502 let mut obj = json!({ "kind": row.kind });
503 if let Some(n) = row.total {
508 obj["total"] = json!(n);
509 }
510 if let Some(n) = row.active {
511 obj["active"] = json!(n);
512 }
513 if let Some(n) = row.bytes {
514 obj["bytes"] = json!(n);
515 }
516 if let Some(n) = row.reclaimable {
517 obj["reclaimable_bytes"] = json!(n);
518 }
519 obj
520 }).collect::<Vec<_>>(),
521 "total_bytes": report.total_bytes().unwrap_or(0),
522 "reclaimable_bytes": report.reclaimable_bytes().unwrap_or(0),
523 }),
524 })
525 .collect()
526}
527
528pub fn containers_document(
540 reports: &[crate::commands::containers::EngineReport],
541 kubernetes_contexts: &[String],
542) -> Value {
543 let total: u64 = reports.iter().filter_map(|r| r.total_bytes()).sum();
544 let reclaimable: u64 = reports.iter().filter_map(|r| r.reclaimable_bytes()).sum();
545
546 json!({
547 "schema": SCHEMA_VERSION,
548 "version": constants::VERSION,
549 "command": "caches containers",
550 "engines": container_engines(reports),
551 "kubernetes_contexts": kubernetes_contexts,
552 "summary": {
553 "total_bytes": total,
554 "reclaimable_bytes": reclaimable,
555 "engines": reports.len(),
556 },
557 })
558}
559
560pub fn caches_document(
571 reports: &[crate::commands::caches::CacheReport],
572 registered_repositories: Option<usize>,
573 containers: &[crate::commands::containers::EngineReport],
574 volume: Option<&std::path::Path>,
575) -> Value {
576 let total: u64 = reports.iter().map(|r| r.bytes).sum();
577
578 let caches: Vec<Value> = reports
579 .iter()
580 .map(|r| {
581 let mut obj = json!({
582 "manager": r.manager,
583 "kind": r.kind,
584 "path": clean_path(&r.path),
585 "bytes": r.bytes,
586 "clear_command": &r.clear_command,
587 });
588 if let Some(root) = crate::commands::caches::volume_root(&r.path) {
592 obj["volume"] = json!(clean_path(&root));
593 }
594 if let Some(note) = r.note {
595 obj["note"] = json!(note);
596 }
597 if let Some(gb) = r.cap_gb {
600 obj["cap_gb"] = json!(gb);
601 obj["over_cap"] = json!(r.over_cap);
602 }
603 if let Some(n) = r.dependents {
607 obj["dependents"] = json!(n);
608 }
609 obj
610 })
611 .collect();
612
613 let mut summary = json!({
614 "total_bytes": total,
615 "count": reports.len(),
616 });
617 if let Some(n) = registered_repositories {
618 summary["registered_repositories"] = json!(n);
619 }
620 if let Some(root) = volume {
623 summary["volume"] = json!(clean_path(root));
624 }
625
626 let mut doc = json!({
630 "schema": SCHEMA_VERSION,
631 "version": constants::VERSION,
632 "command": "caches",
633 "caches": caches,
634 "summary": summary,
635 });
636 if volume.is_none() {
642 doc["containers"] = json!(container_engines(containers));
643 }
644 doc
645}
646fn kept_caches(kept: &[crate::commands::caches::CacheReport]) -> Vec<Value> {
651 use crate::commands::caches::Clear;
652 kept.iter()
653 .filter_map(|r| {
654 let Clear::Manual { why } = r.clear else {
655 return None;
656 };
657 Some(json!({
658 "manager": r.manager,
659 "kind": r.kind,
660 "path": clean_path(&r.path),
661 "bytes": r.bytes,
662 "clear_command": &r.clear_command,
663 "reason": why,
664 }))
665 })
666 .collect()
667}
668
669pub fn caches_clear_plan_document(
671 reports: &[crate::commands::caches::CacheReport],
672 kept: &[crate::commands::caches::CacheReport],
673) -> Value {
674 let total: u64 = reports.iter().map(|r| r.bytes).sum();
675
676 let caches: Vec<Value> = reports
677 .iter()
678 .map(|r| {
679 let mut obj = json!({
680 "manager": r.manager,
681 "kind": r.kind,
682 "path": clean_path(&r.path),
683 "bytes": r.bytes,
684 "clear_command": &r.clear_command,
685 });
686 if let Some(n) = r.dependents {
687 obj["dependents"] = json!(n);
688 }
689 obj
690 })
691 .collect();
692
693 json!({
694 "schema": SCHEMA_VERSION,
695 "version": constants::VERSION,
696 "command": "caches clear",
697 "dry_run": true,
698 "caches": caches,
699 "kept": kept_caches(kept),
700 "summary": {
701 "total_bytes": total,
702 "count": reports.len(),
703 },
704 })
705}
706
707pub fn caches_clear_document(
712 outcomes: &[crate::commands::caches::ClearOutcome],
713 kept: &[crate::commands::caches::CacheReport],
714) -> Value {
715 let freed: u64 = outcomes.iter().map(|o| o.freed()).sum();
716 let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
717
718 let caches: Vec<Value> = outcomes
719 .iter()
720 .map(|o| {
721 let mut obj = json!({
722 "manager": o.manager,
723 "kind": o.kind,
724 "path": clean_path(&o.path),
725 "bytes_before": o.before,
726 "bytes_after": o.after,
727 "freed_bytes": o.freed(),
728 "cleared": o.problem.is_none(),
729 });
730 if let Some(problem) = &o.problem {
731 obj["error"] = json!(problem);
732 }
733 obj
734 })
735 .collect();
736
737 json!({
738 "schema": SCHEMA_VERSION,
739 "version": constants::VERSION,
740 "command": "caches clear",
741 "dry_run": false,
742 "caches": caches,
743 "kept": kept_caches(kept),
744 "summary": {
745 "freed_bytes": freed,
746 "count": outcomes.len(),
747 "failed": failed,
748 },
749 })
750}
751pub fn containers_clear_document(
762 outcome: &crate::commands::containers::ClearOutcome,
763 dry_run: bool,
764) -> Value {
765 let steps: Vec<Value> = outcome
766 .steps
767 .iter()
768 .map(|s| {
769 let mut obj = json!({
770 "command": s.command,
771 "reclaims": s.what,
772 "ran": !dry_run && s.problem.is_none(),
773 });
774 if let Some(problem) = &s.problem {
775 obj["error"] = json!(problem);
776 }
777 obj
778 })
779 .collect();
780
781 json!({
782 "schema": SCHEMA_VERSION,
783 "version": constants::VERSION,
784 "command": "caches clear",
785 "dry_run": dry_run,
786 "engine": outcome.engine,
787 "steps": steps,
788 "summary": {
789 "bytes_before": outcome.before,
790 "bytes_after": outcome.after,
791 "freed_bytes": if dry_run { 0 } else { outcome.freed() },
792 "failed": outcome.steps.iter().filter(|s| s.problem.is_some()).count(),
793 "volumes_untouched": true,
794 },
795 })
796}
797
798pub fn trust_document(report: &crate::commands::trust::TrustReport) -> Value {
804 let rows = |rows: &[crate::commands::trust::TrustRow]| -> Vec<Value> {
805 rows.iter()
806 .map(|r| {
807 json!({
808 "key": r.key,
809 "subject": r.subject,
810 "state": r.state,
811 "verdict": r.verdict_key(),
812 })
813 })
814 .collect()
815 };
816
817 let widened = report.widened();
818
819 let binaries: Vec<Value> = report
824 .binaries
825 .iter()
826 .map(|b| {
827 json!({
828 "role": b.role,
829 "name": b.name,
830 "path": b.path,
831 "channel": b.channel,
832 "sha256": b.sha256,
833 "version": b.version,
837 "marker": b.marker,
838 "running": b.running,
839 "scan_report": b.scan_url(),
840 "note": b.note,
841 })
842 })
843 .collect();
844
845 json!({
846 "schema": SCHEMA_VERSION,
847 "version": constants::VERSION,
848 "command": "trust",
849 "guarantees": rows(&report.guarantees),
850 "machine": rows(&report.machine),
851 "binaries": binaries,
852 "summary": {
853 "widened": widened,
854 "widened_count": widened.len(),
855 },
856 })
857}
858
859pub fn drift_document(findings: &[crate::commands::status::ProjectDrift]) -> Value {
866 let unrecorded_total: usize = findings.iter().map(|f| f.report.unrecorded.len()).sum();
867
868 json!({
869 "schema": SCHEMA_VERSION,
870 "version": constants::VERSION,
871 "command": "status --drift",
872 "drift": findings.iter().map(|f| json!({
873 "repository": clean_path(&f.repository),
874 "project": f.project,
875 "adapter": f.adapter,
876 "directory": f.report.directory,
877 "unrecorded": f.report.unrecorded,
878 "record_command": f.report.record_command,
879 })).collect::<Vec<_>>(),
880 "summary": {
881 "projects_with_drift": findings.len(),
882 "unrecorded_packages": unrecorded_total,
883 },
884 })
885}
886
887pub fn emit(document: &Value) -> anyhow::Result<()> {
898 use std::io::IsTerminal;
899 let text = serde_json::to_string_pretty(document)?;
900 println!("{text}");
901 if std::io::stdout().is_terminal() && copy_to_clipboard(&text) {
902 use colored::Colorize;
903 eprintln!("{}", "(also copied to your clipboard)".dimmed());
904 }
905 Ok(())
906}
907
908fn copy_to_clipboard(text: &str) -> bool {
915 let bytes: Vec<u8> = if cfg!(windows) {
919 let mut utf16 = vec![0xFF, 0xFE];
920 for unit in text.encode_utf16() {
921 utf16.extend_from_slice(&unit.to_le_bytes());
922 }
923 utf16
924 } else {
925 text.as_bytes().to_vec()
926 };
927
928 let windows_clip = std::env::var("SystemRoot")
934 .map(|root| format!("{root}\\System32\\clip.exe"))
935 .unwrap_or_else(|_| String::from("C:\\Windows\\System32\\clip.exe"));
936 let tools: Vec<Vec<&str>> = if cfg!(windows) {
937 vec![vec![windows_clip.as_str()]]
938 } else if cfg!(target_os = "macos") {
939 vec![vec!["pbcopy"]]
940 } else {
941 vec![
942 vec!["wl-copy"],
943 vec!["xclip", "-selection", "clipboard"],
944 vec!["xsel", "--clipboard", "--input"],
945 ]
946 };
947 tools.iter().any(|tool| pipe_into(tool, &bytes))
948}
949
950fn pipe_into(command: &[&str], bytes: &[u8]) -> bool {
952 use std::io::Write;
953 use std::process::Stdio;
954 let Ok(mut child) = crate::spawn::command(command[0])
955 .args(&command[1..])
956 .stdin(Stdio::piped())
957 .stdout(Stdio::null())
958 .stderr(Stdio::null())
959 .spawn()
960 else {
961 return false;
962 };
963 let wrote = child
964 .stdin
965 .take()
966 .is_some_and(|mut stdin| stdin.write_all(bytes).is_ok());
967 let exited_cleanly = child.wait().map(|status| status.success()).unwrap_or(false);
968 wrote && exited_cleanly
969}
970
971#[cfg(test)]
972mod tests {
973 use super::*;
974 use std::path::PathBuf;
975
976 fn result(status: PruneStatus, bytes: u64) -> PruneResult {
977 PruneResult {
978 repo_path: PathBuf::from("/tmp/repo"),
979 adapter_name: "pnpm".to_string(),
980 bloat_dir: "node_modules".to_string(),
981 size_freed: bytes,
982 shared_bytes: 0,
983 runtime: None,
984 status,
985 }
986 }
987
988 #[test]
989 fn every_status_has_a_distinct_stable_tag() {
990 let all = [
991 PruneStatus::Pruned,
992 PruneStatus::SkippedActive,
993 PruneStatus::SkippedAdapterWindow(45),
994 PruneStatus::SkippedDryRun,
995 PruneStatus::LockfileError("x".into()),
996 PruneStatus::ActivityCheckError("x".into()),
997 PruneStatus::PathMissing,
998 PruneStatus::NotARepo,
999 PruneStatus::NoBloat,
1000 PruneStatus::Disabled,
1001 PruneStatus::SkippedIgnored,
1002 PruneStatus::DeleteError("x".into()),
1003 PruneStatus::ConfigError("x".into()),
1004 PruneStatus::SkippedSymlink("x".into()),
1005 PruneStatus::SkippedDeclaration("x".into()),
1006 PruneStatus::SkippedNestedRepo("x".into()),
1007 ];
1008 let mut tags: Vec<&str> = all.iter().map(status_tag).collect();
1009 let count = tags.len();
1010 tags.sort_unstable();
1011 tags.dedup();
1012 assert_eq!(tags.len(), count, "two statuses share a JSON tag");
1013 }
1014
1015 #[test]
1016 fn every_repository_state_has_a_distinct_stable_tag() {
1017 let all = [
1018 SkipReason::Candidate,
1019 SkipReason::Active,
1020 SkipReason::Ignored,
1021 SkipReason::NoBloat,
1022 SkipReason::PathMissing,
1023 SkipReason::ConfigError("x".into()),
1024 ];
1025 let mut tags: Vec<&str> = all.iter().map(reason_tag).collect();
1026 let count = tags.len();
1027 tags.sort_unstable();
1028 tags.dedup();
1029 assert_eq!(tags.len(), count, "two repository states share a JSON tag");
1030 }
1031
1032 #[test]
1033 fn only_an_unreadable_config_carries_an_error_field() {
1034 let entry = |reason| RepoStatusEntry {
1035 path: PathBuf::from("/tmp/repo"),
1036 entry: crate::config::RepoEntry::new(),
1037 reason,
1038 adapters: Vec::new(),
1039 bloat_dirs: Vec::new(),
1040 reclaimable_bytes: 0,
1041 reclaimable_by_adapter: Vec::new(),
1042 last_activity: None,
1043 idle_days: 15,
1044 };
1045
1046 let registry = Registry::default();
1047 let broken = repo_value(
1048 ®istry,
1049 &entry(SkipReason::ConfigError("bad json".into())),
1050 );
1051 assert_eq!(broken["state"], "config_error");
1052 assert_eq!(broken["error"], "bad json");
1053
1054 let healthy = repo_value(®istry, &entry(SkipReason::Candidate));
1056 assert!(healthy.get("error").is_none());
1057 }
1058
1059 #[test]
1060 fn run_summary_counts_only_real_deletions() {
1061 let doc = run_document(
1062 &[
1063 result(PruneStatus::Pruned, 100),
1064 result(PruneStatus::Pruned, 50),
1065 result(PruneStatus::SkippedActive, 0),
1066 result(PruneStatus::LockfileError("nope".into()), 0),
1067 ],
1068 false,
1069 );
1070 assert_eq!(doc["summary"]["bytes_freed"], 150);
1071 assert_eq!(doc["summary"]["directories_pruned"], 2);
1072 assert_eq!(doc["summary"]["errors"], 1);
1073 }
1074
1075 #[test]
1076 fn dry_run_bytes_land_in_reclaimable_not_freed() {
1077 let doc = run_document(&[result(PruneStatus::SkippedDryRun, 4096)], true);
1080 assert_eq!(doc["summary"]["bytes_freed"], 0);
1081 assert_eq!(doc["summary"]["bytes_reclaimable"], 4096);
1082 assert_eq!(doc["dry_run"], true);
1083 }
1084
1085 #[test]
1086 fn lockfile_errors_carry_the_fix_command() {
1087 let doc = run_document(
1088 &[result(PruneStatus::LockfileError("boom".into()), 0)],
1089 false,
1090 );
1091 assert_eq!(doc["results"][0]["message"], "boom");
1092 assert_eq!(
1093 doc["results"][0]["fix_command"],
1094 "pnpm install --lockfile-only"
1095 );
1096 }
1097
1098 #[test]
1099 fn a_successful_result_carries_no_message_or_fix() {
1100 let doc = run_document(&[result(PruneStatus::Pruned, 1)], false);
1101 assert!(doc["results"][0].get("message").is_none());
1102 assert!(doc["results"][0].get("fix_command").is_none());
1103 }
1104
1105 #[test]
1106 fn venv_has_no_mechanical_lockfile_fix() {
1107 assert!(lockfile_fix_command("venv").is_none());
1110 assert!(lockfile_fix_command("nonsense").is_none());
1111 }
1112
1113 #[test]
1114 fn the_cache_report_totals_what_it_lists() {
1115 use crate::commands::caches::{CacheReport, Clear};
1116
1117 let doc = caches_document(
1118 &[
1119 CacheReport {
1120 manager: "go",
1121 kind: "module cache",
1122 path: PathBuf::from("/home/dev/go/pkg/mod"),
1123 bytes: 4_000,
1124 clear_command: "go clean -modcache".to_string(),
1125 clear: Clear::Command("go", &["clean", "-modcache"]),
1126 note: None,
1127 cap_gb: None,
1128 over_cap: false,
1129 dependents: None,
1130 extra_args: Vec::new(),
1131 },
1132 CacheReport {
1133 manager: "pnpm",
1134 kind: "store",
1135 path: PathBuf::from("/home/dev/.pnpm-store"),
1136 bytes: 1_000,
1137 clear_command: "pnpm store prune".to_string(),
1138 clear: Clear::Command("pnpm", &["store", "prune"]),
1139 note: Some("hardlinked"),
1140 cap_gb: None,
1141 over_cap: false,
1142 dependents: None,
1143 extra_args: Vec::new(),
1144 },
1145 ],
1146 Some(3),
1147 &[],
1148 None,
1149 );
1150
1151 assert_eq!(doc["command"], "caches");
1152 assert_eq!(doc["summary"]["total_bytes"], 5_000);
1153 assert_eq!(doc["summary"]["count"], 2);
1154 assert!(doc["caches"][0].get("note").is_none());
1157 assert_eq!(doc["caches"][1]["note"], "hardlinked");
1158 assert_eq!(doc["caches"][0]["clear_command"], "go clean -modcache");
1159 }
1160
1161 #[test]
1162 fn an_empty_cache_report_is_still_a_document() {
1163 let doc = caches_document(&[], None, &[], None);
1166 assert_eq!(doc["summary"]["total_bytes"], 0);
1167 assert_eq!(doc["caches"].as_array().unwrap().len(), 0);
1168 assert_eq!(doc["containers"].as_array().unwrap().len(), 0);
1169 }
1170
1171 #[test]
1172 fn cache_clears_are_reported_beside_the_prune_total_not_inside_it() {
1173 let mut registry = crate::config::Registry {
1174 total_freed_bytes: 12_000_000_000,
1175 ..Default::default()
1176 };
1177 registry.record_cache_clear(6_000_000_000);
1178
1179 let doc = stats_document(®istry, &[]);
1180
1181 assert_eq!(doc["lifetime"]["bytes_freed"], 12_000_000_000u64);
1182 assert_eq!(doc["lifetime"]["cache_bytes_freed"], 6_000_000_000u64);
1183 }
1184
1185 #[test]
1186 fn the_breakdowns_report_what_they_could_not_count() {
1187 use crate::config::PrunedDir;
1188 use crate::history::{Pass, PassRecord, Trigger};
1189
1190 let registry = crate::config::Registry::default();
1191 let logged = Pass::Detailed(PassRecord {
1192 at: chrono::Utc::now(),
1193 trigger: Trigger::Scheduled,
1194 argv: vec!["run".to_string()],
1195 version: "1.17.0".to_string(),
1196 dirs: vec![PrunedDir {
1197 repo_path: std::path::PathBuf::from("/tmp/api"),
1198 bloat_dir: "node_modules".to_string(),
1199 adapter: "npm".to_string(),
1200 size_freed: 900,
1201 runtime: None,
1202 }],
1203 });
1204 let recovered = Pass::Summary {
1207 at: chrono::Utc::now() - chrono::Duration::days(1),
1208 bytes_freed: 100,
1209 dirs_removed: 1,
1210 repos_touched: 1,
1211 dirs: None,
1212 };
1213
1214 let doc = stats_document(®istry, &[logged, recovered]);
1215
1216 assert_eq!(doc["by_manager"]["managers"][0]["manager"], "npm");
1217 assert_eq!(doc["by_manager"]["managers"][0]["bytes_freed"], 900);
1218 assert_eq!(doc["by_manager"]["passes_not_counted"], 1);
1219
1220 let triggers = doc["by_trigger"]["triggers"].as_array().unwrap();
1222 assert_eq!(triggers.len(), 3);
1223 let scheduled = triggers
1224 .iter()
1225 .find(|t| t["trigger"] == "scheduled")
1226 .unwrap();
1227 assert_eq!(scheduled["passes"], 1);
1228 assert_eq!(scheduled["bytes_freed"], 900);
1229 assert_eq!(doc["by_trigger"]["passes_not_counted"], 1);
1230 }
1231
1232 #[test]
1233 fn container_disk_stays_out_of_the_cache_total() {
1234 use crate::commands::containers::{EngineReport, EngineState, Row};
1235
1236 let docker = EngineReport {
1237 name: "docker",
1238 state: EngineState::Ready(vec![Row {
1239 kind: "Images".to_string(),
1240 total: Some(9),
1241 active: Some(2),
1242 bytes: Some(40_000_000_000),
1243 reclaimable: Some(38_000_000_000),
1244 }]),
1245 };
1246 let doc = caches_document(&[], None, std::slice::from_ref(&docker), None);
1247
1248 assert_eq!(doc["summary"]["total_bytes"], 0);
1252 assert_eq!(doc["containers"][0]["engine"], "docker");
1253 assert_eq!(doc["containers"][0]["total_bytes"], 40_000_000_000u64);
1254 assert_eq!(doc["containers"][0]["rows"][0]["kind"], "Images");
1255 }
1256
1257 #[test]
1258 fn an_engine_that_did_not_answer_carries_no_zero() {
1259 use crate::commands::containers::{EngineReport, EngineState};
1260
1261 let doc = containers_document(
1262 &[EngineReport {
1263 name: "docker",
1264 state: EngineState::Unavailable("daemon is not running".to_string()),
1265 }],
1266 &[],
1267 );
1268
1269 assert_eq!(doc["command"], "caches containers");
1270 assert_eq!(doc["engines"][0]["available"], false);
1271 assert_eq!(doc["engines"][0]["reason"], "daemon is not running");
1272 assert!(doc["engines"][0].get("total_bytes").is_none());
1275 assert_eq!(doc["summary"]["total_bytes"], 0);
1276 }
1277
1278 #[test]
1279 fn no_prune_command_reaches_the_json_contract() {
1280 use crate::commands::containers::{EngineReport, EngineState, Row};
1281
1282 let doc = containers_document(
1283 &[EngineReport {
1284 name: "docker",
1285 state: EngineState::Ready(vec![Row {
1286 kind: "Build Cache".to_string(),
1287 total: Some(41),
1288 active: Some(0),
1289 bytes: Some(6_750_000_000),
1290 reclaimable: Some(6_750_000_000),
1291 }]),
1292 }],
1293 &["kind-dev".to_string()],
1294 );
1295
1296 let text = serde_json::to_string(&doc).unwrap();
1299 assert!(!text.contains("prune"), "{text}");
1300 assert_eq!(doc["kubernetes_contexts"][0], "kind-dev");
1301 assert_eq!(doc["summary"]["reclaimable_bytes"], 6_750_000_000u64);
1302 }
1303
1304 #[test]
1305 fn every_adapter_with_a_lockfile_has_a_fix_command() {
1306 for adapter in crate::adapters::get_all_adapters() {
1307 if matches!(
1312 adapter.name(),
1313 "venv"
1314 | "gradle"
1315 | "maven"
1316 | "swift"
1317 | "vcpkg"
1318 | "cmake_build"
1319 | "dotnet_build"
1320 | "godot"
1321 | "unity"
1322 | "unreal"
1323 | "defold"
1324 | "cocos"
1325 | "zig"
1326 | "stack"
1327 | "cabal"
1328 | "sbt"
1329 ) {
1330 continue;
1331 }
1332 assert!(
1333 lockfile_fix_command(adapter.name()).is_some(),
1334 "{} has no fix command",
1335 adapter.name()
1336 );
1337 }
1338 }
1339}