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