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<()> {
378 println!("{}", serde_json::to_string_pretty(document)?);
379 Ok(())
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use std::path::PathBuf;
386
387 fn result(status: PruneStatus, bytes: u64) -> PruneResult {
388 PruneResult {
389 repo_path: PathBuf::from("/tmp/repo"),
390 adapter_name: "pnpm".to_string(),
391 bloat_dir: "node_modules".to_string(),
392 size_freed: bytes,
393 shared_bytes: 0,
394 status,
395 }
396 }
397
398 #[test]
399 fn every_status_has_a_distinct_stable_tag() {
400 let all = [
401 PruneStatus::Pruned,
402 PruneStatus::SkippedActive,
403 PruneStatus::SkippedDryRun,
404 PruneStatus::LockfileError("x".into()),
405 PruneStatus::ActivityCheckError("x".into()),
406 PruneStatus::PathMissing,
407 PruneStatus::NoBloat,
408 PruneStatus::Disabled,
409 PruneStatus::SkippedIgnored,
410 PruneStatus::DeleteError("x".into()),
411 PruneStatus::ConfigError("x".into()),
412 PruneStatus::SkippedSymlink("x".into()),
413 ];
414 let mut tags: Vec<&str> = all.iter().map(status_tag).collect();
415 let count = tags.len();
416 tags.sort_unstable();
417 tags.dedup();
418 assert_eq!(tags.len(), count, "two statuses share a JSON tag");
419 }
420
421 #[test]
422 fn every_repository_state_has_a_distinct_stable_tag() {
423 let all = [
424 SkipReason::Candidate,
425 SkipReason::Active,
426 SkipReason::Ignored,
427 SkipReason::NoBloat,
428 SkipReason::PathMissing,
429 SkipReason::ConfigError("x".into()),
430 ];
431 let mut tags: Vec<&str> = all.iter().map(reason_tag).collect();
432 let count = tags.len();
433 tags.sort_unstable();
434 tags.dedup();
435 assert_eq!(tags.len(), count, "two repository states share a JSON tag");
436 }
437
438 #[test]
439 fn only_an_unreadable_config_carries_an_error_field() {
440 let entry = |reason| RepoStatusEntry {
441 path: PathBuf::from("/tmp/repo"),
442 entry: crate::config::RepoEntry::new(),
443 reason,
444 adapters: Vec::new(),
445 bloat_dirs: Vec::new(),
446 reclaimable_bytes: 0,
447 last_activity: None,
448 idle_days: 15,
449 };
450
451 let broken = repo_value(&entry(SkipReason::ConfigError("bad json".into())));
452 assert_eq!(broken["state"], "config_error");
453 assert_eq!(broken["error"], "bad json");
454
455 let healthy = repo_value(&entry(SkipReason::Candidate));
457 assert!(healthy.get("error").is_none());
458 }
459
460 #[test]
461 fn run_summary_counts_only_real_deletions() {
462 let doc = run_document(
463 &[
464 result(PruneStatus::Pruned, 100),
465 result(PruneStatus::Pruned, 50),
466 result(PruneStatus::SkippedActive, 0),
467 result(PruneStatus::LockfileError("nope".into()), 0),
468 ],
469 false,
470 );
471 assert_eq!(doc["summary"]["bytes_freed"], 150);
472 assert_eq!(doc["summary"]["directories_pruned"], 2);
473 assert_eq!(doc["summary"]["errors"], 1);
474 }
475
476 #[test]
477 fn dry_run_bytes_land_in_reclaimable_not_freed() {
478 let doc = run_document(&[result(PruneStatus::SkippedDryRun, 4096)], true);
481 assert_eq!(doc["summary"]["bytes_freed"], 0);
482 assert_eq!(doc["summary"]["bytes_reclaimable"], 4096);
483 assert_eq!(doc["dry_run"], true);
484 }
485
486 #[test]
487 fn lockfile_errors_carry_the_fix_command() {
488 let doc = run_document(
489 &[result(PruneStatus::LockfileError("boom".into()), 0)],
490 false,
491 );
492 assert_eq!(doc["results"][0]["message"], "boom");
493 assert_eq!(
494 doc["results"][0]["fix_command"],
495 "pnpm install --lockfile-only"
496 );
497 }
498
499 #[test]
500 fn a_successful_result_carries_no_message_or_fix() {
501 let doc = run_document(&[result(PruneStatus::Pruned, 1)], false);
502 assert!(doc["results"][0].get("message").is_none());
503 assert!(doc["results"][0].get("fix_command").is_none());
504 }
505
506 #[test]
507 fn venv_has_no_mechanical_lockfile_fix() {
508 assert!(lockfile_fix_command("venv").is_none());
511 assert!(lockfile_fix_command("nonsense").is_none());
512 }
513
514 #[test]
515 fn the_cache_report_totals_what_it_lists() {
516 use crate::commands::caches::CacheReport;
517
518 let doc = caches_document(&[
519 CacheReport {
520 manager: "go",
521 kind: "module cache",
522 path: PathBuf::from("/home/dev/go/pkg/mod"),
523 bytes: 4_000,
524 clear_command: "go clean -modcache",
525 note: None,
526 },
527 CacheReport {
528 manager: "pnpm",
529 kind: "store",
530 path: PathBuf::from("/home/dev/.pnpm-store"),
531 bytes: 1_000,
532 clear_command: "pnpm store prune",
533 note: Some("hardlinked"),
534 },
535 ]);
536
537 assert_eq!(doc["command"], "caches");
538 assert_eq!(doc["summary"]["total_bytes"], 5_000);
539 assert_eq!(doc["summary"]["count"], 2);
540 assert!(doc["caches"][0].get("note").is_none());
543 assert_eq!(doc["caches"][1]["note"], "hardlinked");
544 assert_eq!(doc["caches"][0]["clear_command"], "go clean -modcache");
545 }
546
547 #[test]
548 fn an_empty_cache_report_is_still_a_document() {
549 let doc = caches_document(&[]);
552 assert_eq!(doc["summary"]["total_bytes"], 0);
553 assert_eq!(doc["caches"].as_array().unwrap().len(), 0);
554 }
555
556 #[test]
557 fn every_adapter_with_a_lockfile_has_a_fix_command() {
558 for adapter in crate::adapters::get_all_adapters() {
559 if adapter.name() == "venv" {
560 continue;
561 }
562 assert!(
563 lockfile_fix_command(adapter.name()).is_some(),
564 "{} has no fix command",
565 adapter.name()
566 );
567 }
568 }
569}