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