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 "cargo" => "cargo generate-lockfile",
83 "go" => "go mod tidy",
84 _ => return None,
89 })
90}
91
92fn result_value(result: &PruneResult) -> Value {
93 let mut obj = json!({
94 "repository": clean_path(&result.repo_path),
95 "adapter": result.adapter_name,
96 "directory": result.bloat_dir,
97 "status": status_tag(&result.status),
98 "bytes": result.size_freed,
99 "shared_bytes": result.shared_bytes,
100 });
101
102 if let Some(message) = status_message(&result.status) {
103 obj["message"] = json!(message);
104 }
105 if matches!(result.status, PruneStatus::LockfileError(_))
106 && let Some(fix) = lockfile_fix_command(&result.adapter_name)
107 {
108 obj["fix_command"] = json!(fix);
109 }
110 obj
111}
112
113pub fn run_document(results: &[PruneResult], dry_run: bool) -> Value {
118 let bytes_freed: u64 = results
119 .iter()
120 .filter(|r| matches!(r.status, PruneStatus::Pruned))
121 .map(|r| r.size_freed)
122 .sum();
123 let directories_pruned = results
124 .iter()
125 .filter(|r| matches!(r.status, PruneStatus::Pruned))
126 .count();
127 let bytes_reclaimable: u64 = results
128 .iter()
129 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
130 .map(|r| r.size_freed)
131 .sum();
132 let errors = results
133 .iter()
134 .filter(|r| {
135 matches!(
136 r.status,
137 PruneStatus::LockfileError(_)
138 | PruneStatus::ActivityCheckError(_)
139 | PruneStatus::DeleteError(_)
140 | PruneStatus::ConfigError(_)
141 )
142 })
143 .count();
144
145 json!({
146 "schema": SCHEMA_VERSION,
147 "version": constants::VERSION,
148 "command": "run",
149 "dry_run": dry_run,
150 "results": results.iter().map(result_value).collect::<Vec<_>>(),
151 "summary": {
152 "bytes_freed": bytes_freed,
153 "bytes_reclaimable": bytes_reclaimable,
154 "directories_pruned": directories_pruned,
155 "errors": errors,
156 },
157 })
158}
159
160fn reason_tag(reason: &SkipReason) -> &'static str {
162 match reason {
163 SkipReason::Candidate => "candidate",
164 SkipReason::Active => "active",
165 SkipReason::Ignored => "ignored",
166 SkipReason::NoBloat => "no_bloat",
167 SkipReason::PathMissing => "path_missing",
168 SkipReason::ConfigError(_) => "config_error",
169 }
170}
171
172fn settings_value(settings: &Settings) -> Value {
173 json!({
174 "idle_days": settings.idle_days,
175 "check_interval_days": settings.check_interval_days,
176 "auto_setup": settings.auto_setup,
177 "auto_hooks": settings.auto_hooks,
178 "auto_daemon": settings.auto_daemon,
179 "require_confirmation": settings.require_confirmation,
180 "command_timeout_secs": settings.command_timeout_secs,
181 "min_size_mb": settings.min_size_mb,
182 "update_check": settings.update_check,
183 })
184}
185
186fn repo_value(entry: &RepoStatusEntry) -> Value {
187 let mut obj = json!({
188 "path": clean_path(&entry.path),
189 "state": reason_tag(&entry.reason),
190 "enabled": entry.entry.enabled,
191 "idle_days": entry.idle_days,
192 "last_activity": entry.last_activity.map(|t| t.to_rfc3339()),
193 "last_pruned_at": entry.entry.last_pruned_at.map(|t| t.to_rfc3339()),
194 "bytes_freed": entry.entry.total_freed_bytes,
195 "added_at": entry.entry.added_at.to_rfc3339(),
196 "adapters": entry.adapters,
197 "reclaimable_bytes": entry.reclaimable_bytes,
198 "directories": entry.bloat_dirs.iter().map(|b| json!({
199 "name": b.name,
200 "path": clean_path(&b.path),
201 "bytes": b.size_bytes,
202 "shared_bytes": b.shared_bytes,
203 })).collect::<Vec<_>>(),
204 });
205
206 if let SkipReason::ConfigError(e) = &entry.reason {
211 obj["error"] = json!(e);
212 }
213 obj
214}
215
216pub fn status_document(
226 registry: &Registry,
227 repos: &[RepoStatusEntry],
228 daemon: &str,
229 hooks: &str,
230 top: Option<usize>,
231) -> Value {
232 let reclaimable: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
233 let candidates = repos
234 .iter()
235 .filter(|r| matches!(r.reason, SkipReason::Candidate))
236 .count();
237 let listed = crate::engine::take_top(repos, top);
238
239 let mut doc = json!({
240 "schema": SCHEMA_VERSION,
241 "version": constants::VERSION,
242 "command": "status",
243 "config_path": Registry::registry_path().map(|p| clean_path(&p)).ok(),
244 "integrations": { "daemon": daemon, "git_hooks": hooks },
245 "settings": settings_value(®istry.settings),
246 "totals": {
247 "repositories": registry.repo_count(),
248 "candidates": candidates,
249 "reclaimable_bytes": reclaimable,
250 "historical_bytes_freed": registry.total_freed_bytes,
251 "prune_passes": registry.total_pruned_count,
252 },
253 "repositories": listed.iter().map(repo_value).collect::<Vec<_>>(),
254 });
255
256 if let Some(n) = top {
259 doc["top"] = json!(n);
260 }
261 doc
262}
263
264pub fn stats_document(registry: &Registry) -> Value {
272 let mut repos: Vec<(&std::path::PathBuf, &crate::config::RepoEntry)> =
273 registry.repositories.iter().collect();
274 repos.sort_by(|a, b| {
275 b.1.total_freed_bytes
276 .cmp(&a.1.total_freed_bytes)
277 .then_with(|| a.0.cmp(b.0))
278 });
279
280 json!({
281 "schema": SCHEMA_VERSION,
282 "version": constants::VERSION,
283 "command": "stats",
284 "history_starts_at": constants::HISTORY_STARTS_AT,
285 "lifetime": {
286 "bytes_freed": registry.total_freed_bytes,
287 "prune_passes": registry.total_pruned_count,
290 "repositories": registry.repo_count(),
291 },
292 "last_prune": registry.last_prune.as_ref().map(|p| json!({
293 "at": p.at.to_rfc3339(),
294 "bytes_freed": p.dirs.iter().map(|d| d.size_freed).sum::<u64>(),
295 "directories": p.dirs.len(),
296 })),
297 "recent_passes": registry.prune_history.iter().rev().map(|p| json!({
298 "at": p.at.to_rfc3339(),
299 "bytes_freed": p.bytes_freed,
300 "directories": p.dirs_removed,
301 "repositories": p.repos_touched,
302 })).collect::<Vec<_>>(),
303 "repositories": repos.iter().map(|(path, entry)| json!({
304 "path": clean_path(path),
305 "bytes_freed": entry.total_freed_bytes,
306 "last_pruned_at": entry.last_pruned_at.map(|t| t.to_rfc3339()),
307 })).collect::<Vec<_>>(),
308 })
309}
310
311pub fn caches_document(reports: &[crate::commands::caches::CacheReport]) -> Value {
318 let total: u64 = reports.iter().map(|r| r.bytes).sum();
319
320 let caches: Vec<Value> = reports
321 .iter()
322 .map(|r| {
323 let mut obj = json!({
324 "manager": r.manager,
325 "kind": r.kind,
326 "path": clean_path(&r.path),
327 "bytes": r.bytes,
328 "clear_command": r.clear_command,
329 });
330 if let Some(note) = r.note {
331 obj["note"] = json!(note);
332 }
333 obj
334 })
335 .collect();
336
337 json!({
338 "schema": SCHEMA_VERSION,
339 "version": constants::VERSION,
340 "command": "caches",
341 "caches": caches,
342 "summary": {
343 "total_bytes": total,
344 "count": reports.len(),
345 },
346 })
347}
348
349pub fn drift_document(findings: &[crate::commands::status::ProjectDrift]) -> Value {
356 let unrecorded_total: usize = findings.iter().map(|f| f.report.unrecorded.len()).sum();
357
358 json!({
359 "schema": SCHEMA_VERSION,
360 "version": constants::VERSION,
361 "command": "status --drift",
362 "drift": findings.iter().map(|f| json!({
363 "repository": clean_path(&f.repository),
364 "project": f.project,
365 "adapter": f.adapter,
366 "directory": f.report.directory,
367 "unrecorded": f.report.unrecorded,
368 "record_command": f.report.record_command,
369 })).collect::<Vec<_>>(),
370 "summary": {
371 "projects_with_drift": findings.len(),
372 "unrecorded_packages": unrecorded_total,
373 },
374 })
375}
376
377pub fn emit(document: &Value) -> anyhow::Result<()> {
388 use std::io::IsTerminal;
389 let text = serde_json::to_string_pretty(document)?;
390 println!("{text}");
391 if std::io::stdout().is_terminal() && copy_to_clipboard(&text) {
392 use colored::Colorize;
393 eprintln!("{}", "(also copied to your clipboard)".dimmed());
394 }
395 Ok(())
396}
397
398fn copy_to_clipboard(text: &str) -> bool {
405 let bytes: Vec<u8> = if cfg!(windows) {
409 let mut utf16 = vec![0xFF, 0xFE];
410 for unit in text.encode_utf16() {
411 utf16.extend_from_slice(&unit.to_le_bytes());
412 }
413 utf16
414 } else {
415 text.as_bytes().to_vec()
416 };
417
418 let windows_clip = std::env::var("SystemRoot")
424 .map(|root| format!("{root}\\System32\\clip.exe"))
425 .unwrap_or_else(|_| String::from("C:\\Windows\\System32\\clip.exe"));
426 let tools: Vec<Vec<&str>> = if cfg!(windows) {
427 vec![vec![windows_clip.as_str()]]
428 } else if cfg!(target_os = "macos") {
429 vec![vec!["pbcopy"]]
430 } else {
431 vec![
432 vec!["wl-copy"],
433 vec!["xclip", "-selection", "clipboard"],
434 vec!["xsel", "--clipboard", "--input"],
435 ]
436 };
437 tools.iter().any(|tool| pipe_into(tool, &bytes))
438}
439
440fn pipe_into(command: &[&str], bytes: &[u8]) -> bool {
442 use std::io::Write;
443 use std::process::Stdio;
444 let Ok(mut child) = crate::spawn::command(command[0])
445 .args(&command[1..])
446 .stdin(Stdio::piped())
447 .stdout(Stdio::null())
448 .stderr(Stdio::null())
449 .spawn()
450 else {
451 return false;
452 };
453 let wrote = child
454 .stdin
455 .take()
456 .is_some_and(|mut stdin| stdin.write_all(bytes).is_ok());
457 let exited_cleanly = child.wait().map(|status| status.success()).unwrap_or(false);
458 wrote && exited_cleanly
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use std::path::PathBuf;
465
466 fn result(status: PruneStatus, bytes: u64) -> PruneResult {
467 PruneResult {
468 repo_path: PathBuf::from("/tmp/repo"),
469 adapter_name: "pnpm".to_string(),
470 bloat_dir: "node_modules".to_string(),
471 size_freed: bytes,
472 shared_bytes: 0,
473 status,
474 }
475 }
476
477 #[test]
478 fn every_status_has_a_distinct_stable_tag() {
479 let all = [
480 PruneStatus::Pruned,
481 PruneStatus::SkippedActive,
482 PruneStatus::SkippedDryRun,
483 PruneStatus::LockfileError("x".into()),
484 PruneStatus::ActivityCheckError("x".into()),
485 PruneStatus::PathMissing,
486 PruneStatus::NoBloat,
487 PruneStatus::Disabled,
488 PruneStatus::SkippedIgnored,
489 PruneStatus::DeleteError("x".into()),
490 PruneStatus::ConfigError("x".into()),
491 PruneStatus::SkippedSymlink("x".into()),
492 ];
493 let mut tags: Vec<&str> = all.iter().map(status_tag).collect();
494 let count = tags.len();
495 tags.sort_unstable();
496 tags.dedup();
497 assert_eq!(tags.len(), count, "two statuses share a JSON tag");
498 }
499
500 #[test]
501 fn every_repository_state_has_a_distinct_stable_tag() {
502 let all = [
503 SkipReason::Candidate,
504 SkipReason::Active,
505 SkipReason::Ignored,
506 SkipReason::NoBloat,
507 SkipReason::PathMissing,
508 SkipReason::ConfigError("x".into()),
509 ];
510 let mut tags: Vec<&str> = all.iter().map(reason_tag).collect();
511 let count = tags.len();
512 tags.sort_unstable();
513 tags.dedup();
514 assert_eq!(tags.len(), count, "two repository states share a JSON tag");
515 }
516
517 #[test]
518 fn only_an_unreadable_config_carries_an_error_field() {
519 let entry = |reason| RepoStatusEntry {
520 path: PathBuf::from("/tmp/repo"),
521 entry: crate::config::RepoEntry::new(),
522 reason,
523 adapters: Vec::new(),
524 bloat_dirs: Vec::new(),
525 reclaimable_bytes: 0,
526 last_activity: None,
527 idle_days: 15,
528 };
529
530 let broken = repo_value(&entry(SkipReason::ConfigError("bad json".into())));
531 assert_eq!(broken["state"], "config_error");
532 assert_eq!(broken["error"], "bad json");
533
534 let healthy = repo_value(&entry(SkipReason::Candidate));
536 assert!(healthy.get("error").is_none());
537 }
538
539 #[test]
540 fn run_summary_counts_only_real_deletions() {
541 let doc = run_document(
542 &[
543 result(PruneStatus::Pruned, 100),
544 result(PruneStatus::Pruned, 50),
545 result(PruneStatus::SkippedActive, 0),
546 result(PruneStatus::LockfileError("nope".into()), 0),
547 ],
548 false,
549 );
550 assert_eq!(doc["summary"]["bytes_freed"], 150);
551 assert_eq!(doc["summary"]["directories_pruned"], 2);
552 assert_eq!(doc["summary"]["errors"], 1);
553 }
554
555 #[test]
556 fn dry_run_bytes_land_in_reclaimable_not_freed() {
557 let doc = run_document(&[result(PruneStatus::SkippedDryRun, 4096)], true);
560 assert_eq!(doc["summary"]["bytes_freed"], 0);
561 assert_eq!(doc["summary"]["bytes_reclaimable"], 4096);
562 assert_eq!(doc["dry_run"], true);
563 }
564
565 #[test]
566 fn lockfile_errors_carry_the_fix_command() {
567 let doc = run_document(
568 &[result(PruneStatus::LockfileError("boom".into()), 0)],
569 false,
570 );
571 assert_eq!(doc["results"][0]["message"], "boom");
572 assert_eq!(
573 doc["results"][0]["fix_command"],
574 "pnpm install --lockfile-only"
575 );
576 }
577
578 #[test]
579 fn a_successful_result_carries_no_message_or_fix() {
580 let doc = run_document(&[result(PruneStatus::Pruned, 1)], false);
581 assert!(doc["results"][0].get("message").is_none());
582 assert!(doc["results"][0].get("fix_command").is_none());
583 }
584
585 #[test]
586 fn venv_has_no_mechanical_lockfile_fix() {
587 assert!(lockfile_fix_command("venv").is_none());
590 assert!(lockfile_fix_command("nonsense").is_none());
591 }
592
593 #[test]
594 fn the_cache_report_totals_what_it_lists() {
595 use crate::commands::caches::CacheReport;
596
597 let doc = caches_document(&[
598 CacheReport {
599 manager: "go",
600 kind: "module cache",
601 path: PathBuf::from("/home/dev/go/pkg/mod"),
602 bytes: 4_000,
603 clear_command: "go clean -modcache",
604 note: None,
605 },
606 CacheReport {
607 manager: "pnpm",
608 kind: "store",
609 path: PathBuf::from("/home/dev/.pnpm-store"),
610 bytes: 1_000,
611 clear_command: "pnpm store prune",
612 note: Some("hardlinked"),
613 },
614 ]);
615
616 assert_eq!(doc["command"], "caches");
617 assert_eq!(doc["summary"]["total_bytes"], 5_000);
618 assert_eq!(doc["summary"]["count"], 2);
619 assert!(doc["caches"][0].get("note").is_none());
622 assert_eq!(doc["caches"][1]["note"], "hardlinked");
623 assert_eq!(doc["caches"][0]["clear_command"], "go clean -modcache");
624 }
625
626 #[test]
627 fn an_empty_cache_report_is_still_a_document() {
628 let doc = caches_document(&[]);
631 assert_eq!(doc["summary"]["total_bytes"], 0);
632 assert_eq!(doc["caches"].as_array().unwrap().len(), 0);
633 }
634
635 #[test]
636 fn every_adapter_with_a_lockfile_has_a_fix_command() {
637 for adapter in crate::adapters::get_all_adapters() {
638 if matches!(adapter.name(), "venv" | "gradle" | "maven") {
641 continue;
642 }
643 assert!(
644 lockfile_fix_command(adapter.name()).is_some(),
645 "{} has no fix command",
646 adapter.name()
647 );
648 }
649 }
650}