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,
117 })
118}
119
120fn result_value(result: &PruneResult) -> Value {
121 let mut obj = json!({
122 "repository": clean_path(&result.repo_path),
123 "adapter": result.adapter_name,
124 "directory": result.bloat_dir,
125 "status": status_tag(&result.status),
126 "bytes": result.size_freed,
127 "shared_bytes": result.shared_bytes,
128 });
129
130 if let Some(message) = status_message(&result.status) {
131 obj["message"] = json!(message);
132 }
133 if matches!(result.status, PruneStatus::LockfileError(_))
134 && let Some(fix) = lockfile_fix_command(&result.adapter_name)
135 {
136 obj["fix_command"] = json!(fix);
137 }
138 obj
139}
140
141pub fn run_document(results: &[PruneResult], dry_run: bool) -> Value {
146 let bytes_freed: u64 = results
147 .iter()
148 .filter(|r| matches!(r.status, PruneStatus::Pruned))
149 .map(|r| r.size_freed)
150 .sum();
151 let directories_pruned = results
152 .iter()
153 .filter(|r| matches!(r.status, PruneStatus::Pruned))
154 .count();
155 let bytes_reclaimable: u64 = results
156 .iter()
157 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
158 .map(|r| r.size_freed)
159 .sum();
160 let errors = results
161 .iter()
162 .filter(|r| {
163 matches!(
164 r.status,
165 PruneStatus::LockfileError(_)
166 | PruneStatus::ActivityCheckError(_)
167 | PruneStatus::DeleteError(_)
168 | PruneStatus::ConfigError(_)
169 )
170 })
171 .count();
172
173 json!({
174 "schema": SCHEMA_VERSION,
175 "version": constants::VERSION,
176 "command": "run",
177 "dry_run": dry_run,
178 "results": results.iter().map(result_value).collect::<Vec<_>>(),
179 "summary": {
180 "bytes_freed": bytes_freed,
181 "bytes_reclaimable": bytes_reclaimable,
182 "directories_pruned": directories_pruned,
183 "errors": errors,
184 },
185 })
186}
187
188pub fn attach_recommendations(doc: &mut Value, recommendations: &[(String, usize)]) {
195 if recommendations.is_empty() {
196 return;
197 }
198 if let Some(obj) = doc.as_object_mut() {
199 let entries: Vec<Value> = recommendations
200 .iter()
201 .map(|(adapter, repositories)| {
202 json!({
203 "adapter": adapter,
204 "setting": format!("enable_{adapter}"),
205 "command": format!("devp config set enable_{adapter} true"),
206 "repositories": repositories,
207 })
208 })
209 .collect();
210 obj.insert("recommendations".to_string(), Value::Array(entries));
211 }
212}
213
214fn reason_tag(reason: &SkipReason) -> &'static str {
216 match reason {
217 SkipReason::Candidate => "candidate",
218 SkipReason::Active => "active",
219 SkipReason::Ignored => "ignored",
220 SkipReason::NoBloat => "no_bloat",
221 SkipReason::PathMissing => "path_missing",
222 SkipReason::ConfigError(_) => "config_error",
223 }
224}
225
226fn settings_value(settings: &Settings) -> Value {
227 json!({
228 "idle_days": settings.idle_days,
229 "check_interval_days": settings.check_interval_days,
230 "auto_setup": settings.auto_setup,
231 "auto_hooks": settings.auto_hooks,
232 "auto_daemon": settings.auto_daemon,
233 "require_confirmation": settings.require_confirmation,
234 "command_timeout_secs": settings.command_timeout_secs,
235 "min_size_mb": settings.min_size_mb,
236 "update_check": settings.update_check,
237 })
238}
239
240fn repo_value(registry: &Registry, entry: &RepoStatusEntry) -> Value {
241 let mut obj = json!({
242 "path": clean_path(&entry.path),
243 "state": reason_tag(&entry.reason),
244 "enabled": entry.entry.enabled,
245 "idle_days": entry.idle_days,
246 "last_activity": entry.last_activity.map(|t| t.to_rfc3339()),
247 "last_pruned_at": entry.entry.last_pruned_at.map(|t| t.to_rfc3339()),
248 "bytes_freed": entry.entry.total_freed_bytes,
249 "added_at": entry.entry.added_at.to_rfc3339(),
250 "adapters": entry.adapters,
251 "reclaimable_bytes": entry.reclaimable_bytes,
252 "directories": entry.bloat_dirs.iter().map(|b| json!({
253 "name": b.name,
254 "path": clean_path(&b.path),
255 "bytes": b.size_bytes,
256 "shared_bytes": b.shared_bytes,
257 })).collect::<Vec<_>>(),
258 "restore_estimate_secs": registry
261 .estimate_restore(&entry.reclaimable_by_adapter)
262 .map(|(secs, _)| secs.round() as u64),
263 "tools": entry.tools.iter().map(|t| json!({
268 "name": t.name,
269 "path": clean_path(&t.path),
270 "bytes": t.size_bytes,
271 })).collect::<Vec<_>>(),
272 });
273
274 if let SkipReason::ConfigError(e) = &entry.reason {
279 obj["error"] = json!(e);
280 }
281 obj
282}
283
284pub fn status_document(
294 registry: &Registry,
295 repos: &[RepoStatusEntry],
296 daemon: &str,
297 hooks: &str,
298 top: Option<usize>,
299) -> Value {
300 let reclaimable: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
301 let candidates = repos
302 .iter()
303 .filter(|r| matches!(r.reason, SkipReason::Candidate))
304 .count();
305 let listed = crate::engine::take_top(repos, top);
306
307 let mut by_adapter: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
309 for repo in repos {
310 for (adapter, bytes) in &repo.reclaimable_by_adapter {
311 *by_adapter.entry(adapter.clone()).or_default() += bytes;
312 }
313 }
314 let estimate = registry.estimate_restore(&by_adapter.into_iter().collect::<Vec<_>>());
315
316 let mut doc = json!({
317 "schema": SCHEMA_VERSION,
318 "version": constants::VERSION,
319 "command": "status",
320 "config_path": Registry::registry_path().map(|p| clean_path(&p)).ok(),
321 "integrations": { "daemon": daemon, "git_hooks": hooks },
322 "settings": settings_value(®istry.settings),
323 "totals": {
324 "repositories": registry.repo_count(),
325 "candidates": candidates,
326 "reclaimable_bytes": reclaimable,
327 "historical_bytes_freed": registry.total_freed_bytes,
328 "prune_passes": registry.total_pruned_count,
329 "restore_estimate": estimate.map(|(secs, covered)| json!({
334 "seconds": secs.round() as u64,
335 "covered_bytes": covered,
336 "samples": registry.restore_rates.values().map(|r| r.samples as u64).sum::<u64>(),
337 })),
338 },
339 "repositories": listed.iter().map(|r| repo_value(registry, r)).collect::<Vec<_>>(),
340 });
341
342 if let Some(n) = top {
345 doc["top"] = json!(n);
346 }
347 doc
348}
349
350pub fn stats_document(registry: &Registry, passes: &[crate::history::Pass]) -> Value {
365 let (managers, managers_uncounted) = crate::commands::stats::rank_managers(passes);
366 let (triggers, triggers_uncounted) = crate::commands::stats::split_by_trigger(passes);
367
368 let mut repos: Vec<(&std::path::PathBuf, &crate::config::RepoEntry)> =
369 registry.repositories.iter().collect();
370 repos.sort_by(|a, b| {
371 b.1.total_freed_bytes
372 .cmp(&a.1.total_freed_bytes)
373 .then_with(|| a.0.cmp(b.0))
374 });
375
376 json!({
377 "schema": SCHEMA_VERSION,
378 "version": constants::VERSION,
379 "command": "stats",
380 "history_starts_at": constants::HISTORY_STARTS_AT,
381 "detail_starts_at": constants::PRUNE_LOG_STARTS_AT,
384 "lifetime": {
385 "bytes_freed": registry.total_freed_bytes,
386 "cache_bytes_freed": registry.total_cache_freed_bytes,
390 "container_bytes_freed": registry.total_container_freed_bytes,
391 "prune_passes": registry.total_pruned_count,
394 "repositories": registry.repo_count(),
395 },
396 "last_prune": registry.last_prune.as_ref().map(|p| json!({
397 "at": p.at.to_rfc3339(),
398 "bytes_freed": p.dirs.iter().map(|d| d.size_freed).sum::<u64>(),
399 "directories": p.dirs.len(),
400 })),
401 "recent_passes": registry.prune_history.iter().rev().map(|p| json!({
402 "at": p.at.to_rfc3339(),
403 "bytes_freed": p.bytes_freed,
404 "directories": p.dirs_removed,
405 "repositories": p.repos_touched,
406 })).collect::<Vec<_>>(),
407 "repositories": repos.iter().map(|(path, entry)| json!({
408 "path": clean_path(path),
409 "bytes_freed": entry.total_freed_bytes,
410 "last_pruned_at": entry.last_pruned_at.map(|t| t.to_rfc3339()),
411 })).collect::<Vec<_>>(),
412 "by_manager": {
415 "passes_not_counted": managers_uncounted,
416 "managers": managers.iter().map(|m| json!({
417 "manager": m.manager,
418 "bytes_freed": m.bytes,
419 "directories": m.dirs,
420 })).collect::<Vec<_>>(),
421 },
422 "by_trigger": {
423 "passes_not_counted": triggers_uncounted,
424 "triggers": triggers.iter().map(|t| json!({
427 "trigger": t.trigger.label(),
428 "bytes_freed": t.bytes,
429 "passes": t.passes,
430 })).collect::<Vec<_>>(),
431 },
432 })
433}
434
435pub fn history_document(passes: &[crate::history::Pass], only: Option<usize>) -> Value {
449 use crate::history::Pass;
450 json!({
451 "schema": SCHEMA_VERSION,
452 "version": constants::VERSION,
453 "command": "history",
454 "detail_starts_at": constants::PRUNE_LOG_STARTS_AT,
455 "passes": passes.iter().enumerate()
456 .filter(|(index, _)| only.is_none_or(|n| n == index + 1))
457 .map(|(index, pass)| {
458 let mut entry = json!({
459 "pass": index + 1,
461 "at": pass.at().to_rfc3339(),
462 "bytes_freed": pass.bytes_freed(),
463 "directories": pass.dirs_removed(),
464 "repositories": pass.repos_touched(),
465 "detail": pass.dirs().is_some(),
466 });
467 let map = entry.as_object_mut().expect("json! built an object");
468 if let Pass::Detailed(record) = pass {
469 map.insert("trigger".into(), json!(record.trigger.label()));
470 map.insert("argv".into(), json!(record.argv));
471 map.insert("command_line".into(), json!(record.command_line()));
472 map.insert("dev_prune_version".into(), json!(record.version));
473 }
474 if let Some(dirs) = pass.dirs() {
475 map.insert("removed".into(), json!(dirs.iter().map(|d| json!({
476 "repo_path": clean_path(&d.repo_path),
477 "directory": d.bloat_dir,
478 "adapter": d.adapter,
479 "bytes_freed": d.size_freed,
480 "runtime": d.runtime,
481 })).collect::<Vec<_>>()));
482 }
483 entry
484 }).collect::<Vec<_>>(),
485 })
486}
487
488fn container_engines(reports: &[crate::commands::containers::EngineReport]) -> Vec<Value> {
500 use crate::commands::containers::EngineState;
501 reports
502 .iter()
503 .map(|report| match &report.state {
504 EngineState::Unavailable(reason) => json!({
505 "engine": report.name,
506 "available": false,
507 "reason": reason,
508 }),
509 EngineState::Ready(rows) => json!({
510 "engine": report.name,
511 "available": true,
512 "rows": rows.iter().map(|row| {
513 let mut obj = json!({ "kind": row.kind });
514 if let Some(n) = row.total {
519 obj["total"] = json!(n);
520 }
521 if let Some(n) = row.active {
522 obj["active"] = json!(n);
523 }
524 if let Some(n) = row.bytes {
525 obj["bytes"] = json!(n);
526 }
527 if let Some(n) = row.reclaimable {
528 obj["reclaimable_bytes"] = json!(n);
529 }
530 obj
531 }).collect::<Vec<_>>(),
532 "total_bytes": report.total_bytes().unwrap_or(0),
533 "reclaimable_bytes": report.reclaimable_bytes().unwrap_or(0),
534 }),
535 })
536 .collect()
537}
538
539pub fn containers_document(
551 reports: &[crate::commands::containers::EngineReport],
552 kubernetes_contexts: &[String],
553) -> Value {
554 let total: u64 = reports.iter().filter_map(|r| r.total_bytes()).sum();
555 let reclaimable: u64 = reports.iter().filter_map(|r| r.reclaimable_bytes()).sum();
556
557 json!({
558 "schema": SCHEMA_VERSION,
559 "version": constants::VERSION,
560 "command": "caches containers",
561 "engines": container_engines(reports),
562 "kubernetes_contexts": kubernetes_contexts,
563 "summary": {
564 "total_bytes": total,
565 "reclaimable_bytes": reclaimable,
566 "engines": reports.len(),
567 },
568 })
569}
570
571pub fn caches_document(
582 reports: &[crate::commands::caches::CacheReport],
583 registered_repositories: Option<usize>,
584 containers: &[crate::commands::containers::EngineReport],
585 volume: Option<&std::path::Path>,
586) -> Value {
587 let total: u64 = reports.iter().map(|r| r.bytes).sum();
588
589 let caches: Vec<Value> = reports
590 .iter()
591 .map(|r| {
592 let mut obj = json!({
593 "manager": r.manager,
594 "kind": r.kind,
595 "path": clean_path(&r.path),
596 "bytes": r.bytes,
597 "clear_command": &r.clear_command,
598 });
599 if let Some(root) = crate::commands::caches::volume_root(&r.path) {
603 obj["volume"] = json!(clean_path(&root));
604 }
605 if let Some(note) = r.note {
606 obj["note"] = json!(note);
607 }
608 if let Some(gb) = r.cap_gb {
611 obj["cap_gb"] = json!(gb);
612 obj["over_cap"] = json!(r.over_cap);
613 }
614 if let Some(n) = r.dependents {
618 obj["dependents"] = json!(n);
619 }
620 obj
621 })
622 .collect();
623
624 let mut summary = json!({
625 "total_bytes": total,
626 "count": reports.len(),
627 });
628 if let Some(n) = registered_repositories {
629 summary["registered_repositories"] = json!(n);
630 }
631 if let Some(root) = volume {
634 summary["volume"] = json!(clean_path(root));
635 }
636
637 let mut doc = json!({
641 "schema": SCHEMA_VERSION,
642 "version": constants::VERSION,
643 "command": "caches",
644 "caches": caches,
645 "summary": summary,
646 });
647 if volume.is_none() {
653 doc["containers"] = json!(container_engines(containers));
654 }
655 doc
656}
657fn kept_caches(kept: &[crate::commands::caches::CacheReport]) -> Vec<Value> {
662 use crate::commands::caches::Clear;
663 kept.iter()
664 .filter_map(|r| {
665 let Clear::Manual { why } = r.clear else {
666 return None;
667 };
668 Some(json!({
669 "manager": r.manager,
670 "kind": r.kind,
671 "path": clean_path(&r.path),
672 "bytes": r.bytes,
673 "clear_command": &r.clear_command,
674 "reason": why,
675 }))
676 })
677 .collect()
678}
679
680pub fn caches_clear_plan_document(
682 reports: &[crate::commands::caches::CacheReport],
683 kept: &[crate::commands::caches::CacheReport],
684) -> Value {
685 let total: u64 = reports.iter().map(|r| r.bytes).sum();
686
687 let caches: Vec<Value> = reports
688 .iter()
689 .map(|r| {
690 let mut obj = json!({
691 "manager": r.manager,
692 "kind": r.kind,
693 "path": clean_path(&r.path),
694 "bytes": r.bytes,
695 "clear_command": &r.clear_command,
696 });
697 if let Some(n) = r.dependents {
698 obj["dependents"] = json!(n);
699 }
700 obj
701 })
702 .collect();
703
704 json!({
705 "schema": SCHEMA_VERSION,
706 "version": constants::VERSION,
707 "command": "caches clear",
708 "dry_run": true,
709 "caches": caches,
710 "kept": kept_caches(kept),
711 "summary": {
712 "total_bytes": total,
713 "count": reports.len(),
714 },
715 })
716}
717
718pub fn caches_clear_document(
723 outcomes: &[crate::commands::caches::ClearOutcome],
724 kept: &[crate::commands::caches::CacheReport],
725) -> Value {
726 let freed: u64 = outcomes.iter().map(|o| o.freed()).sum();
727 let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
728
729 let caches: Vec<Value> = outcomes
730 .iter()
731 .map(|o| {
732 let mut obj = json!({
733 "manager": o.manager,
734 "kind": o.kind,
735 "path": clean_path(&o.path),
736 "bytes_before": o.before,
737 "bytes_after": o.after,
738 "freed_bytes": o.freed(),
739 "cleared": o.problem.is_none(),
740 });
741 if let Some(problem) = &o.problem {
742 obj["error"] = json!(problem);
743 }
744 obj
745 })
746 .collect();
747
748 json!({
749 "schema": SCHEMA_VERSION,
750 "version": constants::VERSION,
751 "command": "caches clear",
752 "dry_run": false,
753 "caches": caches,
754 "kept": kept_caches(kept),
755 "summary": {
756 "freed_bytes": freed,
757 "count": outcomes.len(),
758 "failed": failed,
759 },
760 })
761}
762pub fn containers_clear_document(
773 outcome: &crate::commands::containers::ClearOutcome,
774 dry_run: bool,
775) -> Value {
776 let steps: Vec<Value> = outcome
777 .steps
778 .iter()
779 .map(|s| {
780 let mut obj = json!({
781 "command": s.command,
782 "reclaims": s.what,
783 "ran": !dry_run && s.problem.is_none(),
784 });
785 if let Some(problem) = &s.problem {
786 obj["error"] = json!(problem);
787 }
788 obj
789 })
790 .collect();
791
792 json!({
793 "schema": SCHEMA_VERSION,
794 "version": constants::VERSION,
795 "command": "caches clear",
796 "dry_run": dry_run,
797 "engine": outcome.engine,
798 "steps": steps,
799 "summary": {
800 "bytes_before": outcome.before,
801 "bytes_after": outcome.after,
802 "freed_bytes": if dry_run { 0 } else { outcome.freed() },
803 "failed": outcome.steps.iter().filter(|s| s.problem.is_some()).count(),
804 "volumes_untouched": true,
805 },
806 })
807}
808
809pub fn trust_document(report: &crate::commands::trust::TrustReport) -> Value {
815 let rows = |rows: &[crate::commands::trust::TrustRow]| -> Vec<Value> {
816 rows.iter()
817 .map(|r| {
818 json!({
819 "key": r.key,
820 "subject": r.subject,
821 "state": r.state,
822 "verdict": r.verdict_key(),
823 })
824 })
825 .collect()
826 };
827
828 let widened = report.widened();
829
830 let binaries: Vec<Value> = report
835 .binaries
836 .iter()
837 .map(|b| {
838 json!({
839 "role": b.role,
840 "name": b.name,
841 "path": b.path,
842 "channel": b.channel,
843 "sha256": b.sha256,
844 "version": b.version,
848 "marker": b.marker,
849 "running": b.running,
850 "scan_report": b.scan_url(),
851 "note": b.note,
852 })
853 })
854 .collect();
855
856 json!({
857 "schema": SCHEMA_VERSION,
858 "version": constants::VERSION,
859 "command": "trust",
860 "guarantees": rows(&report.guarantees),
861 "machine": rows(&report.machine),
862 "binaries": binaries,
863 "summary": {
864 "widened": widened,
865 "widened_count": widened.len(),
866 },
867 })
868}
869
870pub fn drift_document(findings: &[crate::commands::status::ProjectDrift]) -> Value {
877 let unrecorded_total: usize = findings.iter().map(|f| f.report.unrecorded.len()).sum();
878
879 json!({
880 "schema": SCHEMA_VERSION,
881 "version": constants::VERSION,
882 "command": "status --drift",
883 "drift": findings.iter().map(|f| json!({
884 "repository": clean_path(&f.repository),
885 "project": f.project,
886 "adapter": f.adapter,
887 "directory": f.report.directory,
888 "unrecorded": f.report.unrecorded,
889 "record_command": f.report.record_command,
890 })).collect::<Vec<_>>(),
891 "summary": {
892 "projects_with_drift": findings.len(),
893 "unrecorded_packages": unrecorded_total,
894 },
895 })
896}
897
898pub fn emit(document: &Value) -> anyhow::Result<()> {
909 use std::io::IsTerminal;
910 let text = serde_json::to_string_pretty(document)?;
911 println!("{text}");
912 if std::io::stdout().is_terminal() && copy_to_clipboard(&text) {
913 use colored::Colorize;
914 eprintln!("{}", "(also copied to your clipboard)".dimmed());
915 }
916 Ok(())
917}
918
919fn copy_to_clipboard(text: &str) -> bool {
926 let bytes: Vec<u8> = if cfg!(windows) {
930 let mut utf16 = vec![0xFF, 0xFE];
931 for unit in text.encode_utf16() {
932 utf16.extend_from_slice(&unit.to_le_bytes());
933 }
934 utf16
935 } else {
936 text.as_bytes().to_vec()
937 };
938
939 let windows_clip = std::env::var("SystemRoot")
945 .map(|root| format!("{root}\\System32\\clip.exe"))
946 .unwrap_or_else(|_| String::from("C:\\Windows\\System32\\clip.exe"));
947 let tools: Vec<Vec<&str>> = if cfg!(windows) {
948 vec![vec![windows_clip.as_str()]]
949 } else if cfg!(target_os = "macos") {
950 vec![vec!["pbcopy"]]
951 } else {
952 vec![
953 vec!["wl-copy"],
954 vec!["xclip", "-selection", "clipboard"],
955 vec!["xsel", "--clipboard", "--input"],
956 ]
957 };
958 tools.iter().any(|tool| pipe_into(tool, &bytes))
959}
960
961fn pipe_into(command: &[&str], bytes: &[u8]) -> bool {
963 use std::io::Write;
964 use std::process::Stdio;
965 let Ok(mut child) = crate::spawn::command(command[0])
966 .args(&command[1..])
967 .stdin(Stdio::piped())
968 .stdout(Stdio::null())
969 .stderr(Stdio::null())
970 .spawn()
971 else {
972 return false;
973 };
974 let wrote = child
975 .stdin
976 .take()
977 .is_some_and(|mut stdin| stdin.write_all(bytes).is_ok());
978 let exited_cleanly = child.wait().map(|status| status.success()).unwrap_or(false);
979 wrote && exited_cleanly
980}
981
982#[cfg(test)]
983mod tests {
984 use super::*;
985 use std::path::PathBuf;
986
987 fn result(status: PruneStatus, bytes: u64) -> PruneResult {
988 PruneResult {
989 repo_path: PathBuf::from("/tmp/repo"),
990 adapter_name: "pnpm".to_string(),
991 bloat_dir: "node_modules".to_string(),
992 size_freed: bytes,
993 shared_bytes: 0,
994 runtime: None,
995 status,
996 }
997 }
998
999 #[test]
1000 fn every_status_has_a_distinct_stable_tag() {
1001 let all = [
1002 PruneStatus::Pruned,
1003 PruneStatus::SkippedActive,
1004 PruneStatus::SkippedAdapterWindow(45),
1005 PruneStatus::SkippedDryRun,
1006 PruneStatus::LockfileError("x".into()),
1007 PruneStatus::ActivityCheckError("x".into()),
1008 PruneStatus::PathMissing,
1009 PruneStatus::NotARepo,
1010 PruneStatus::NoBloat,
1011 PruneStatus::Disabled,
1012 PruneStatus::SkippedIgnored,
1013 PruneStatus::DeleteError("x".into()),
1014 PruneStatus::ConfigError("x".into()),
1015 PruneStatus::SkippedSymlink("x".into()),
1016 PruneStatus::SkippedDeclaration("x".into()),
1017 PruneStatus::SkippedNestedRepo("x".into()),
1018 ];
1019 let mut tags: Vec<&str> = all.iter().map(status_tag).collect();
1020 let count = tags.len();
1021 tags.sort_unstable();
1022 tags.dedup();
1023 assert_eq!(tags.len(), count, "two statuses share a JSON tag");
1024 }
1025
1026 #[test]
1027 fn every_repository_state_has_a_distinct_stable_tag() {
1028 let all = [
1029 SkipReason::Candidate,
1030 SkipReason::Active,
1031 SkipReason::Ignored,
1032 SkipReason::NoBloat,
1033 SkipReason::PathMissing,
1034 SkipReason::ConfigError("x".into()),
1035 ];
1036 let mut tags: Vec<&str> = all.iter().map(reason_tag).collect();
1037 let count = tags.len();
1038 tags.sort_unstable();
1039 tags.dedup();
1040 assert_eq!(tags.len(), count, "two repository states share a JSON tag");
1041 }
1042
1043 #[test]
1044 fn only_an_unreadable_config_carries_an_error_field() {
1045 let entry = |reason| RepoStatusEntry {
1046 path: PathBuf::from("/tmp/repo"),
1047 entry: crate::config::RepoEntry::new(),
1048 reason,
1049 adapters: Vec::new(),
1050 bloat_dirs: Vec::new(),
1051 reclaimable_bytes: 0,
1052 reclaimable_by_adapter: Vec::new(),
1053 last_activity: None,
1054 idle_days: 15,
1055 tools: Vec::new(),
1056 };
1057
1058 let registry = Registry::default();
1059 let broken = repo_value(
1060 ®istry,
1061 &entry(SkipReason::ConfigError("bad json".into())),
1062 );
1063 assert_eq!(broken["state"], "config_error");
1064 assert_eq!(broken["error"], "bad json");
1065
1066 let healthy = repo_value(®istry, &entry(SkipReason::Candidate));
1068 assert!(healthy.get("error").is_none());
1069 }
1070
1071 #[test]
1072 fn run_summary_counts_only_real_deletions() {
1073 let doc = run_document(
1074 &[
1075 result(PruneStatus::Pruned, 100),
1076 result(PruneStatus::Pruned, 50),
1077 result(PruneStatus::SkippedActive, 0),
1078 result(PruneStatus::LockfileError("nope".into()), 0),
1079 ],
1080 false,
1081 );
1082 assert_eq!(doc["summary"]["bytes_freed"], 150);
1083 assert_eq!(doc["summary"]["directories_pruned"], 2);
1084 assert_eq!(doc["summary"]["errors"], 1);
1085 }
1086
1087 #[test]
1088 fn dry_run_bytes_land_in_reclaimable_not_freed() {
1089 let doc = run_document(&[result(PruneStatus::SkippedDryRun, 4096)], true);
1092 assert_eq!(doc["summary"]["bytes_freed"], 0);
1093 assert_eq!(doc["summary"]["bytes_reclaimable"], 4096);
1094 assert_eq!(doc["dry_run"], true);
1095 }
1096
1097 #[test]
1098 fn lockfile_errors_carry_the_fix_command() {
1099 let doc = run_document(
1100 &[result(PruneStatus::LockfileError("boom".into()), 0)],
1101 false,
1102 );
1103 assert_eq!(doc["results"][0]["message"], "boom");
1104 assert_eq!(
1105 doc["results"][0]["fix_command"],
1106 "pnpm install --lockfile-only"
1107 );
1108 }
1109
1110 #[test]
1111 fn a_successful_result_carries_no_message_or_fix() {
1112 let doc = run_document(&[result(PruneStatus::Pruned, 1)], false);
1113 assert!(doc["results"][0].get("message").is_none());
1114 assert!(doc["results"][0].get("fix_command").is_none());
1115 }
1116
1117 #[test]
1118 fn venv_has_no_mechanical_lockfile_fix() {
1119 assert!(lockfile_fix_command("venv").is_none());
1122 assert!(lockfile_fix_command("nonsense").is_none());
1123 }
1124
1125 #[test]
1126 fn the_cache_report_totals_what_it_lists() {
1127 use crate::commands::caches::{CacheReport, Clear};
1128
1129 let doc = caches_document(
1130 &[
1131 CacheReport {
1132 manager: "go",
1133 kind: "module cache",
1134 path: PathBuf::from("/home/dev/go/pkg/mod"),
1135 bytes: 4_000,
1136 clear_command: "go clean -modcache".to_string(),
1137 clear: Clear::Command("go", &["clean", "-modcache"]),
1138 note: None,
1139 cap_gb: None,
1140 over_cap: false,
1141 dependents: None,
1142 extra_args: Vec::new(),
1143 },
1144 CacheReport {
1145 manager: "pnpm",
1146 kind: "store",
1147 path: PathBuf::from("/home/dev/.pnpm-store"),
1148 bytes: 1_000,
1149 clear_command: "pnpm store prune".to_string(),
1150 clear: Clear::Command("pnpm", &["store", "prune"]),
1151 note: Some("hardlinked"),
1152 cap_gb: None,
1153 over_cap: false,
1154 dependents: None,
1155 extra_args: Vec::new(),
1156 },
1157 ],
1158 Some(3),
1159 &[],
1160 None,
1161 );
1162
1163 assert_eq!(doc["command"], "caches");
1164 assert_eq!(doc["summary"]["total_bytes"], 5_000);
1165 assert_eq!(doc["summary"]["count"], 2);
1166 assert!(doc["caches"][0].get("note").is_none());
1169 assert_eq!(doc["caches"][1]["note"], "hardlinked");
1170 assert_eq!(doc["caches"][0]["clear_command"], "go clean -modcache");
1171 }
1172
1173 #[test]
1174 fn an_empty_cache_report_is_still_a_document() {
1175 let doc = caches_document(&[], None, &[], None);
1178 assert_eq!(doc["summary"]["total_bytes"], 0);
1179 assert_eq!(doc["caches"].as_array().unwrap().len(), 0);
1180 assert_eq!(doc["containers"].as_array().unwrap().len(), 0);
1181 }
1182
1183 #[test]
1184 fn cache_clears_are_reported_beside_the_prune_total_not_inside_it() {
1185 let mut registry = crate::config::Registry {
1186 total_freed_bytes: 12_000_000_000,
1187 ..Default::default()
1188 };
1189 registry.record_cache_clear(6_000_000_000);
1190
1191 let doc = stats_document(®istry, &[]);
1192
1193 assert_eq!(doc["lifetime"]["bytes_freed"], 12_000_000_000u64);
1194 assert_eq!(doc["lifetime"]["cache_bytes_freed"], 6_000_000_000u64);
1195 }
1196
1197 #[test]
1198 fn the_breakdowns_report_what_they_could_not_count() {
1199 use crate::config::PrunedDir;
1200 use crate::history::{Pass, PassRecord, Trigger};
1201
1202 let registry = crate::config::Registry::default();
1203 let logged = Pass::Detailed(PassRecord {
1204 at: chrono::Utc::now(),
1205 trigger: Trigger::Scheduled,
1206 argv: vec!["run".to_string()],
1207 version: "1.17.0".to_string(),
1208 dirs: vec![PrunedDir {
1209 repo_path: std::path::PathBuf::from("/tmp/api"),
1210 bloat_dir: "node_modules".to_string(),
1211 adapter: "npm".to_string(),
1212 size_freed: 900,
1213 runtime: None,
1214 }],
1215 });
1216 let recovered = Pass::Summary {
1219 at: chrono::Utc::now() - chrono::Duration::days(1),
1220 bytes_freed: 100,
1221 dirs_removed: 1,
1222 repos_touched: 1,
1223 dirs: None,
1224 };
1225
1226 let doc = stats_document(®istry, &[logged, recovered]);
1227
1228 assert_eq!(doc["by_manager"]["managers"][0]["manager"], "npm");
1229 assert_eq!(doc["by_manager"]["managers"][0]["bytes_freed"], 900);
1230 assert_eq!(doc["by_manager"]["passes_not_counted"], 1);
1231
1232 let triggers = doc["by_trigger"]["triggers"].as_array().unwrap();
1234 assert_eq!(triggers.len(), 3);
1235 let scheduled = triggers
1236 .iter()
1237 .find(|t| t["trigger"] == "scheduled")
1238 .unwrap();
1239 assert_eq!(scheduled["passes"], 1);
1240 assert_eq!(scheduled["bytes_freed"], 900);
1241 assert_eq!(doc["by_trigger"]["passes_not_counted"], 1);
1242 }
1243
1244 #[test]
1245 fn container_disk_stays_out_of_the_cache_total() {
1246 use crate::commands::containers::{EngineReport, EngineState, Row};
1247
1248 let docker = EngineReport {
1249 name: "docker",
1250 state: EngineState::Ready(vec![Row {
1251 kind: "Images".to_string(),
1252 total: Some(9),
1253 active: Some(2),
1254 bytes: Some(40_000_000_000),
1255 reclaimable: Some(38_000_000_000),
1256 }]),
1257 };
1258 let doc = caches_document(&[], None, std::slice::from_ref(&docker), None);
1259
1260 assert_eq!(doc["summary"]["total_bytes"], 0);
1264 assert_eq!(doc["containers"][0]["engine"], "docker");
1265 assert_eq!(doc["containers"][0]["total_bytes"], 40_000_000_000u64);
1266 assert_eq!(doc["containers"][0]["rows"][0]["kind"], "Images");
1267 }
1268
1269 #[test]
1270 fn an_engine_that_did_not_answer_carries_no_zero() {
1271 use crate::commands::containers::{EngineReport, EngineState};
1272
1273 let doc = containers_document(
1274 &[EngineReport {
1275 name: "docker",
1276 state: EngineState::Unavailable("daemon is not running".to_string()),
1277 }],
1278 &[],
1279 );
1280
1281 assert_eq!(doc["command"], "caches containers");
1282 assert_eq!(doc["engines"][0]["available"], false);
1283 assert_eq!(doc["engines"][0]["reason"], "daemon is not running");
1284 assert!(doc["engines"][0].get("total_bytes").is_none());
1287 assert_eq!(doc["summary"]["total_bytes"], 0);
1288 }
1289
1290 #[test]
1291 fn no_prune_command_reaches_the_json_contract() {
1292 use crate::commands::containers::{EngineReport, EngineState, Row};
1293
1294 let doc = containers_document(
1295 &[EngineReport {
1296 name: "docker",
1297 state: EngineState::Ready(vec![Row {
1298 kind: "Build Cache".to_string(),
1299 total: Some(41),
1300 active: Some(0),
1301 bytes: Some(6_750_000_000),
1302 reclaimable: Some(6_750_000_000),
1303 }]),
1304 }],
1305 &["kind-dev".to_string()],
1306 );
1307
1308 let text = serde_json::to_string(&doc).unwrap();
1311 assert!(!text.contains("prune"), "{text}");
1312 assert_eq!(doc["kubernetes_contexts"][0], "kind-dev");
1313 assert_eq!(doc["summary"]["reclaimable_bytes"], 6_750_000_000u64);
1314 }
1315
1316 #[test]
1317 fn every_adapter_with_a_lockfile_has_a_fix_command() {
1318 for adapter in crate::adapters::get_all_adapters() {
1319 if matches!(
1324 adapter.name(),
1325 "venv"
1326 | "gradle"
1327 | "maven"
1328 | "swift"
1329 | "vcpkg"
1330 | "cmake_build"
1331 | "dotnet_build"
1332 | "pycache"
1333 | "godot"
1334 | "unity"
1335 | "unreal"
1336 | "defold"
1337 | "cocos"
1338 | "zig"
1339 | "stack"
1340 | "cabal"
1341 | "sbt"
1342 ) {
1343 continue;
1344 }
1345 assert!(
1346 lockfile_fix_command(adapter.name()).is_some(),
1347 "{} has no fix command",
1348 adapter.name()
1349 );
1350 }
1351 }
1352}