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