1use serde_json::{Value, json};
20
21use crate::config::{Registry, Settings};
22use crate::constants;
23use crate::engine::{PruneResult, PruneStatus, RepoStatusEntry, SkipReason};
24use crate::output::clean_path;
25
26pub const SCHEMA_VERSION: u32 = 1;
28
29fn status_tag(status: &PruneStatus) -> &'static str {
34 match status {
35 PruneStatus::Pruned => "pruned",
36 PruneStatus::SkippedActive => "skipped_active",
37 PruneStatus::SkippedDryRun => "skipped_dry_run",
38 PruneStatus::LockfileError(_) => "lockfile_error",
39 PruneStatus::ActivityCheckError(_) => "activity_check_error",
40 PruneStatus::PathMissing => "path_missing",
41 PruneStatus::NoBloat => "no_bloat",
42 PruneStatus::Disabled => "disabled",
43 PruneStatus::SkippedIgnored => "ignored",
44 PruneStatus::DeleteError(_) => "delete_error",
45 PruneStatus::ConfigError(_) => "config_error",
46 PruneStatus::SkippedSymlink(_) => "skipped_symlink",
47 }
48}
49
50fn status_message(status: &PruneStatus) -> Option<&str> {
52 match status {
53 PruneStatus::LockfileError(e)
54 | PruneStatus::ActivityCheckError(e)
55 | PruneStatus::DeleteError(e)
56 | PruneStatus::ConfigError(e)
57 | PruneStatus::SkippedSymlink(e) => Some(e.trim()),
58 _ => None,
59 }
60}
61
62pub fn lockfile_fix_command(adapter: &str) -> Option<&'static str> {
73 Some(match adapter {
74 "npm" => "npm install --package-lock-only --ignore-scripts",
75 "pnpm" => "pnpm install --lockfile-only",
76 "yarn" => "yarn install --mode update-lockfile",
77 "bun" => "bun install",
80 "uv" => "uv lock",
81 "poetry" => "poetry lock",
82 "pdm" => "pdm lock",
83 "pipenv" => "pipenv lock",
84 "cargo" => "cargo generate-lockfile",
85 "go" => "go mod tidy",
86 "composer" => "composer update --no-install",
87 "bundler" => "bundle lock",
88 "cocoapods" => "pod install",
89 "mix" => "mix deps.get",
90 "terraform" => "terraform providers lock",
93 "dart" => "dart pub get",
96 _ => return None,
101 })
102}
103
104fn result_value(result: &PruneResult) -> Value {
105 let mut obj = json!({
106 "repository": clean_path(&result.repo_path),
107 "adapter": result.adapter_name,
108 "directory": result.bloat_dir,
109 "status": status_tag(&result.status),
110 "bytes": result.size_freed,
111 "shared_bytes": result.shared_bytes,
112 });
113
114 if let Some(message) = status_message(&result.status) {
115 obj["message"] = json!(message);
116 }
117 if matches!(result.status, PruneStatus::LockfileError(_))
118 && let Some(fix) = lockfile_fix_command(&result.adapter_name)
119 {
120 obj["fix_command"] = json!(fix);
121 }
122 obj
123}
124
125pub fn run_document(results: &[PruneResult], dry_run: bool) -> Value {
130 let bytes_freed: u64 = results
131 .iter()
132 .filter(|r| matches!(r.status, PruneStatus::Pruned))
133 .map(|r| r.size_freed)
134 .sum();
135 let directories_pruned = results
136 .iter()
137 .filter(|r| matches!(r.status, PruneStatus::Pruned))
138 .count();
139 let bytes_reclaimable: u64 = results
140 .iter()
141 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
142 .map(|r| r.size_freed)
143 .sum();
144 let errors = results
145 .iter()
146 .filter(|r| {
147 matches!(
148 r.status,
149 PruneStatus::LockfileError(_)
150 | PruneStatus::ActivityCheckError(_)
151 | PruneStatus::DeleteError(_)
152 | PruneStatus::ConfigError(_)
153 )
154 })
155 .count();
156
157 json!({
158 "schema": SCHEMA_VERSION,
159 "version": constants::VERSION,
160 "command": "run",
161 "dry_run": dry_run,
162 "results": results.iter().map(result_value).collect::<Vec<_>>(),
163 "summary": {
164 "bytes_freed": bytes_freed,
165 "bytes_reclaimable": bytes_reclaimable,
166 "directories_pruned": directories_pruned,
167 "errors": errors,
168 },
169 })
170}
171
172fn reason_tag(reason: &SkipReason) -> &'static str {
174 match reason {
175 SkipReason::Candidate => "candidate",
176 SkipReason::Active => "active",
177 SkipReason::Ignored => "ignored",
178 SkipReason::NoBloat => "no_bloat",
179 SkipReason::PathMissing => "path_missing",
180 SkipReason::ConfigError(_) => "config_error",
181 }
182}
183
184fn settings_value(settings: &Settings) -> Value {
185 json!({
186 "idle_days": settings.idle_days,
187 "check_interval_days": settings.check_interval_days,
188 "auto_setup": settings.auto_setup,
189 "auto_hooks": settings.auto_hooks,
190 "auto_daemon": settings.auto_daemon,
191 "require_confirmation": settings.require_confirmation,
192 "command_timeout_secs": settings.command_timeout_secs,
193 "min_size_mb": settings.min_size_mb,
194 "update_check": settings.update_check,
195 })
196}
197
198fn repo_value(registry: &Registry, entry: &RepoStatusEntry) -> Value {
199 let mut obj = json!({
200 "path": clean_path(&entry.path),
201 "state": reason_tag(&entry.reason),
202 "enabled": entry.entry.enabled,
203 "idle_days": entry.idle_days,
204 "last_activity": entry.last_activity.map(|t| t.to_rfc3339()),
205 "last_pruned_at": entry.entry.last_pruned_at.map(|t| t.to_rfc3339()),
206 "bytes_freed": entry.entry.total_freed_bytes,
207 "added_at": entry.entry.added_at.to_rfc3339(),
208 "adapters": entry.adapters,
209 "reclaimable_bytes": entry.reclaimable_bytes,
210 "directories": entry.bloat_dirs.iter().map(|b| json!({
211 "name": b.name,
212 "path": clean_path(&b.path),
213 "bytes": b.size_bytes,
214 "shared_bytes": b.shared_bytes,
215 })).collect::<Vec<_>>(),
216 "restore_estimate_secs": registry
219 .estimate_restore(&entry.reclaimable_by_adapter)
220 .map(|(secs, _)| secs.round() as u64),
221 });
222
223 if let SkipReason::ConfigError(e) = &entry.reason {
228 obj["error"] = json!(e);
229 }
230 obj
231}
232
233pub fn status_document(
243 registry: &Registry,
244 repos: &[RepoStatusEntry],
245 daemon: &str,
246 hooks: &str,
247 top: Option<usize>,
248) -> Value {
249 let reclaimable: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
250 let candidates = repos
251 .iter()
252 .filter(|r| matches!(r.reason, SkipReason::Candidate))
253 .count();
254 let listed = crate::engine::take_top(repos, top);
255
256 let mut by_adapter: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
258 for repo in repos {
259 for (adapter, bytes) in &repo.reclaimable_by_adapter {
260 *by_adapter.entry(adapter.clone()).or_default() += bytes;
261 }
262 }
263 let estimate = registry.estimate_restore(&by_adapter.into_iter().collect::<Vec<_>>());
264
265 let mut doc = json!({
266 "schema": SCHEMA_VERSION,
267 "version": constants::VERSION,
268 "command": "status",
269 "config_path": Registry::registry_path().map(|p| clean_path(&p)).ok(),
270 "integrations": { "daemon": daemon, "git_hooks": hooks },
271 "settings": settings_value(®istry.settings),
272 "totals": {
273 "repositories": registry.repo_count(),
274 "candidates": candidates,
275 "reclaimable_bytes": reclaimable,
276 "historical_bytes_freed": registry.total_freed_bytes,
277 "prune_passes": registry.total_pruned_count,
278 "restore_estimate": estimate.map(|(secs, covered)| json!({
283 "seconds": secs.round() as u64,
284 "covered_bytes": covered,
285 "samples": registry.restore_rates.values().map(|r| r.samples as u64).sum::<u64>(),
286 })),
287 },
288 "repositories": listed.iter().map(|r| repo_value(registry, r)).collect::<Vec<_>>(),
289 });
290
291 if let Some(n) = top {
294 doc["top"] = json!(n);
295 }
296 doc
297}
298
299pub fn stats_document(registry: &Registry) -> Value {
307 let mut repos: Vec<(&std::path::PathBuf, &crate::config::RepoEntry)> =
308 registry.repositories.iter().collect();
309 repos.sort_by(|a, b| {
310 b.1.total_freed_bytes
311 .cmp(&a.1.total_freed_bytes)
312 .then_with(|| a.0.cmp(b.0))
313 });
314
315 json!({
316 "schema": SCHEMA_VERSION,
317 "version": constants::VERSION,
318 "command": "stats",
319 "history_starts_at": constants::HISTORY_STARTS_AT,
320 "lifetime": {
321 "bytes_freed": registry.total_freed_bytes,
322 "prune_passes": registry.total_pruned_count,
325 "repositories": registry.repo_count(),
326 },
327 "last_prune": registry.last_prune.as_ref().map(|p| json!({
328 "at": p.at.to_rfc3339(),
329 "bytes_freed": p.dirs.iter().map(|d| d.size_freed).sum::<u64>(),
330 "directories": p.dirs.len(),
331 })),
332 "recent_passes": registry.prune_history.iter().rev().map(|p| json!({
333 "at": p.at.to_rfc3339(),
334 "bytes_freed": p.bytes_freed,
335 "directories": p.dirs_removed,
336 "repositories": p.repos_touched,
337 })).collect::<Vec<_>>(),
338 "repositories": repos.iter().map(|(path, entry)| json!({
339 "path": clean_path(path),
340 "bytes_freed": entry.total_freed_bytes,
341 "last_pruned_at": entry.last_pruned_at.map(|t| t.to_rfc3339()),
342 })).collect::<Vec<_>>(),
343 })
344}
345
346pub fn caches_document(reports: &[crate::commands::caches::CacheReport]) -> Value {
353 let total: u64 = reports.iter().map(|r| r.bytes).sum();
354
355 let caches: Vec<Value> = reports
356 .iter()
357 .map(|r| {
358 let mut obj = json!({
359 "manager": r.manager,
360 "kind": r.kind,
361 "path": clean_path(&r.path),
362 "bytes": r.bytes,
363 "clear_command": r.clear_command,
364 });
365 if let Some(note) = r.note {
366 obj["note"] = json!(note);
367 }
368 obj
369 })
370 .collect();
371
372 json!({
373 "schema": SCHEMA_VERSION,
374 "version": constants::VERSION,
375 "command": "caches",
376 "caches": caches,
377 "summary": {
378 "total_bytes": total,
379 "count": reports.len(),
380 },
381 })
382}
383pub fn caches_clear_plan_document(reports: &[crate::commands::caches::CacheReport]) -> Value {
385 let total: u64 = reports.iter().map(|r| r.bytes).sum();
386
387 let caches: Vec<Value> = reports
388 .iter()
389 .map(|r| {
390 json!({
391 "manager": r.manager,
392 "kind": r.kind,
393 "path": clean_path(&r.path),
394 "bytes": r.bytes,
395 "clear_command": r.clear_command,
396 })
397 })
398 .collect();
399
400 json!({
401 "schema": SCHEMA_VERSION,
402 "version": constants::VERSION,
403 "command": "caches clear",
404 "dry_run": true,
405 "caches": caches,
406 "summary": {
407 "total_bytes": total,
408 "count": reports.len(),
409 },
410 })
411}
412
413pub fn caches_clear_document(outcomes: &[crate::commands::caches::ClearOutcome]) -> Value {
418 let freed: u64 = outcomes.iter().map(|o| o.freed()).sum();
419 let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
420
421 let caches: Vec<Value> = outcomes
422 .iter()
423 .map(|o| {
424 let mut obj = json!({
425 "manager": o.manager,
426 "kind": o.kind,
427 "path": clean_path(&o.path),
428 "bytes_before": o.before,
429 "bytes_after": o.after,
430 "freed_bytes": o.freed(),
431 "cleared": o.problem.is_none(),
432 });
433 if let Some(problem) = &o.problem {
434 obj["error"] = json!(problem);
435 }
436 obj
437 })
438 .collect();
439
440 json!({
441 "schema": SCHEMA_VERSION,
442 "version": constants::VERSION,
443 "command": "caches clear",
444 "dry_run": false,
445 "caches": caches,
446 "summary": {
447 "freed_bytes": freed,
448 "count": outcomes.len(),
449 "failed": failed,
450 },
451 })
452}
453pub fn trust_document(report: &crate::commands::trust::TrustReport) -> Value {
459 let rows = |rows: &[crate::commands::trust::TrustRow]| -> Vec<Value> {
460 rows.iter()
461 .map(|r| {
462 json!({
463 "key": r.key,
464 "subject": r.subject,
465 "state": r.state,
466 "verdict": r.verdict_key(),
467 })
468 })
469 .collect()
470 };
471
472 let widened = report.widened();
473
474 json!({
475 "schema": SCHEMA_VERSION,
476 "version": constants::VERSION,
477 "command": "trust",
478 "guarantees": rows(&report.guarantees),
479 "machine": rows(&report.machine),
480 "summary": {
481 "widened": widened,
482 "widened_count": widened.len(),
483 },
484 })
485}
486
487pub fn drift_document(findings: &[crate::commands::status::ProjectDrift]) -> Value {
494 let unrecorded_total: usize = findings.iter().map(|f| f.report.unrecorded.len()).sum();
495
496 json!({
497 "schema": SCHEMA_VERSION,
498 "version": constants::VERSION,
499 "command": "status --drift",
500 "drift": findings.iter().map(|f| json!({
501 "repository": clean_path(&f.repository),
502 "project": f.project,
503 "adapter": f.adapter,
504 "directory": f.report.directory,
505 "unrecorded": f.report.unrecorded,
506 "record_command": f.report.record_command,
507 })).collect::<Vec<_>>(),
508 "summary": {
509 "projects_with_drift": findings.len(),
510 "unrecorded_packages": unrecorded_total,
511 },
512 })
513}
514
515pub fn emit(document: &Value) -> anyhow::Result<()> {
526 use std::io::IsTerminal;
527 let text = serde_json::to_string_pretty(document)?;
528 println!("{text}");
529 if std::io::stdout().is_terminal() && copy_to_clipboard(&text) {
530 use colored::Colorize;
531 eprintln!("{}", "(also copied to your clipboard)".dimmed());
532 }
533 Ok(())
534}
535
536fn copy_to_clipboard(text: &str) -> bool {
543 let bytes: Vec<u8> = if cfg!(windows) {
547 let mut utf16 = vec![0xFF, 0xFE];
548 for unit in text.encode_utf16() {
549 utf16.extend_from_slice(&unit.to_le_bytes());
550 }
551 utf16
552 } else {
553 text.as_bytes().to_vec()
554 };
555
556 let windows_clip = std::env::var("SystemRoot")
562 .map(|root| format!("{root}\\System32\\clip.exe"))
563 .unwrap_or_else(|_| String::from("C:\\Windows\\System32\\clip.exe"));
564 let tools: Vec<Vec<&str>> = if cfg!(windows) {
565 vec![vec![windows_clip.as_str()]]
566 } else if cfg!(target_os = "macos") {
567 vec![vec!["pbcopy"]]
568 } else {
569 vec![
570 vec!["wl-copy"],
571 vec!["xclip", "-selection", "clipboard"],
572 vec!["xsel", "--clipboard", "--input"],
573 ]
574 };
575 tools.iter().any(|tool| pipe_into(tool, &bytes))
576}
577
578fn pipe_into(command: &[&str], bytes: &[u8]) -> bool {
580 use std::io::Write;
581 use std::process::Stdio;
582 let Ok(mut child) = crate::spawn::command(command[0])
583 .args(&command[1..])
584 .stdin(Stdio::piped())
585 .stdout(Stdio::null())
586 .stderr(Stdio::null())
587 .spawn()
588 else {
589 return false;
590 };
591 let wrote = child
592 .stdin
593 .take()
594 .is_some_and(|mut stdin| stdin.write_all(bytes).is_ok());
595 let exited_cleanly = child.wait().map(|status| status.success()).unwrap_or(false);
596 wrote && exited_cleanly
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 use std::path::PathBuf;
603
604 fn result(status: PruneStatus, bytes: u64) -> PruneResult {
605 PruneResult {
606 repo_path: PathBuf::from("/tmp/repo"),
607 adapter_name: "pnpm".to_string(),
608 bloat_dir: "node_modules".to_string(),
609 size_freed: bytes,
610 shared_bytes: 0,
611 runtime: None,
612 status,
613 }
614 }
615
616 #[test]
617 fn every_status_has_a_distinct_stable_tag() {
618 let all = [
619 PruneStatus::Pruned,
620 PruneStatus::SkippedActive,
621 PruneStatus::SkippedDryRun,
622 PruneStatus::LockfileError("x".into()),
623 PruneStatus::ActivityCheckError("x".into()),
624 PruneStatus::PathMissing,
625 PruneStatus::NoBloat,
626 PruneStatus::Disabled,
627 PruneStatus::SkippedIgnored,
628 PruneStatus::DeleteError("x".into()),
629 PruneStatus::ConfigError("x".into()),
630 PruneStatus::SkippedSymlink("x".into()),
631 ];
632 let mut tags: Vec<&str> = all.iter().map(status_tag).collect();
633 let count = tags.len();
634 tags.sort_unstable();
635 tags.dedup();
636 assert_eq!(tags.len(), count, "two statuses share a JSON tag");
637 }
638
639 #[test]
640 fn every_repository_state_has_a_distinct_stable_tag() {
641 let all = [
642 SkipReason::Candidate,
643 SkipReason::Active,
644 SkipReason::Ignored,
645 SkipReason::NoBloat,
646 SkipReason::PathMissing,
647 SkipReason::ConfigError("x".into()),
648 ];
649 let mut tags: Vec<&str> = all.iter().map(reason_tag).collect();
650 let count = tags.len();
651 tags.sort_unstable();
652 tags.dedup();
653 assert_eq!(tags.len(), count, "two repository states share a JSON tag");
654 }
655
656 #[test]
657 fn only_an_unreadable_config_carries_an_error_field() {
658 let entry = |reason| RepoStatusEntry {
659 path: PathBuf::from("/tmp/repo"),
660 entry: crate::config::RepoEntry::new(),
661 reason,
662 adapters: Vec::new(),
663 bloat_dirs: Vec::new(),
664 reclaimable_bytes: 0,
665 reclaimable_by_adapter: Vec::new(),
666 last_activity: None,
667 idle_days: 15,
668 };
669
670 let registry = Registry::default();
671 let broken = repo_value(
672 ®istry,
673 &entry(SkipReason::ConfigError("bad json".into())),
674 );
675 assert_eq!(broken["state"], "config_error");
676 assert_eq!(broken["error"], "bad json");
677
678 let healthy = repo_value(®istry, &entry(SkipReason::Candidate));
680 assert!(healthy.get("error").is_none());
681 }
682
683 #[test]
684 fn run_summary_counts_only_real_deletions() {
685 let doc = run_document(
686 &[
687 result(PruneStatus::Pruned, 100),
688 result(PruneStatus::Pruned, 50),
689 result(PruneStatus::SkippedActive, 0),
690 result(PruneStatus::LockfileError("nope".into()), 0),
691 ],
692 false,
693 );
694 assert_eq!(doc["summary"]["bytes_freed"], 150);
695 assert_eq!(doc["summary"]["directories_pruned"], 2);
696 assert_eq!(doc["summary"]["errors"], 1);
697 }
698
699 #[test]
700 fn dry_run_bytes_land_in_reclaimable_not_freed() {
701 let doc = run_document(&[result(PruneStatus::SkippedDryRun, 4096)], true);
704 assert_eq!(doc["summary"]["bytes_freed"], 0);
705 assert_eq!(doc["summary"]["bytes_reclaimable"], 4096);
706 assert_eq!(doc["dry_run"], true);
707 }
708
709 #[test]
710 fn lockfile_errors_carry_the_fix_command() {
711 let doc = run_document(
712 &[result(PruneStatus::LockfileError("boom".into()), 0)],
713 false,
714 );
715 assert_eq!(doc["results"][0]["message"], "boom");
716 assert_eq!(
717 doc["results"][0]["fix_command"],
718 "pnpm install --lockfile-only"
719 );
720 }
721
722 #[test]
723 fn a_successful_result_carries_no_message_or_fix() {
724 let doc = run_document(&[result(PruneStatus::Pruned, 1)], false);
725 assert!(doc["results"][0].get("message").is_none());
726 assert!(doc["results"][0].get("fix_command").is_none());
727 }
728
729 #[test]
730 fn venv_has_no_mechanical_lockfile_fix() {
731 assert!(lockfile_fix_command("venv").is_none());
734 assert!(lockfile_fix_command("nonsense").is_none());
735 }
736
737 #[test]
738 fn the_cache_report_totals_what_it_lists() {
739 use crate::commands::caches::{CacheReport, Clear};
740
741 let doc = caches_document(&[
742 CacheReport {
743 manager: "go",
744 kind: "module cache",
745 path: PathBuf::from("/home/dev/go/pkg/mod"),
746 bytes: 4_000,
747 clear_command: "go clean -modcache",
748 clear: Clear::Command("go", &["clean", "-modcache"]),
749 note: None,
750 },
751 CacheReport {
752 manager: "pnpm",
753 kind: "store",
754 path: PathBuf::from("/home/dev/.pnpm-store"),
755 bytes: 1_000,
756 clear_command: "pnpm store prune",
757 clear: Clear::Command("pnpm", &["store", "prune"]),
758 note: Some("hardlinked"),
759 },
760 ]);
761
762 assert_eq!(doc["command"], "caches");
763 assert_eq!(doc["summary"]["total_bytes"], 5_000);
764 assert_eq!(doc["summary"]["count"], 2);
765 assert!(doc["caches"][0].get("note").is_none());
768 assert_eq!(doc["caches"][1]["note"], "hardlinked");
769 assert_eq!(doc["caches"][0]["clear_command"], "go clean -modcache");
770 }
771
772 #[test]
773 fn an_empty_cache_report_is_still_a_document() {
774 let doc = caches_document(&[]);
777 assert_eq!(doc["summary"]["total_bytes"], 0);
778 assert_eq!(doc["caches"].as_array().unwrap().len(), 0);
779 }
780
781 #[test]
782 fn every_adapter_with_a_lockfile_has_a_fix_command() {
783 for adapter in crate::adapters::get_all_adapters() {
784 if matches!(adapter.name(), "venv" | "gradle" | "maven" | "swift") {
787 continue;
788 }
789 assert!(
790 lockfile_fix_command(adapter.name()).is_some(),
791 "{} has no fix command",
792 adapter.name()
793 );
794 }
795 }
796}