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::NoBloat => "no_bloat",
40 PruneStatus::Disabled => "disabled",
41 PruneStatus::SkippedIgnored => "ignored",
42 PruneStatus::DeleteError(_) => "delete_error",
43 PruneStatus::ConfigError(_) => "config_error",
44 }
45}
46
47fn status_message(status: &PruneStatus) -> Option<&str> {
49 match status {
50 PruneStatus::LockfileError(e)
51 | PruneStatus::DeleteError(e)
52 | PruneStatus::ConfigError(e) => Some(e.trim()),
53 _ => None,
54 }
55}
56
57pub fn lockfile_fix_command(adapter: &str) -> Option<&'static str> {
68 Some(match adapter {
69 "npm" => "npm install --package-lock-only --ignore-scripts",
70 "pnpm" => "pnpm install --lockfile-only",
71 "yarn" => "yarn install --mode update-lockfile",
72 "bun" => "bun install",
75 "uv" => "uv lock",
76 "cargo" => "cargo generate-lockfile",
77 "go" => "go mod tidy",
78 _ => return None,
81 })
82}
83
84fn result_value(result: &PruneResult) -> Value {
85 let mut obj = json!({
86 "repository": clean_path(&result.repo_path),
87 "adapter": result.adapter_name,
88 "directory": result.bloat_dir,
89 "status": status_tag(&result.status),
90 "bytes": result.size_freed,
91 });
92
93 if let Some(message) = status_message(&result.status) {
94 obj["message"] = json!(message);
95 }
96 if matches!(result.status, PruneStatus::LockfileError(_)) {
97 if let Some(fix) = lockfile_fix_command(&result.adapter_name) {
98 obj["fix_command"] = json!(fix);
99 }
100 }
101 obj
102}
103
104pub fn run_document(results: &[PruneResult], dry_run: bool) -> Value {
109 let bytes_freed: u64 = results
110 .iter()
111 .filter(|r| matches!(r.status, PruneStatus::Pruned))
112 .map(|r| r.size_freed)
113 .sum();
114 let directories_pruned = results
115 .iter()
116 .filter(|r| matches!(r.status, PruneStatus::Pruned))
117 .count();
118 let bytes_reclaimable: u64 = results
119 .iter()
120 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
121 .map(|r| r.size_freed)
122 .sum();
123 let errors = results
124 .iter()
125 .filter(|r| {
126 matches!(
127 r.status,
128 PruneStatus::LockfileError(_)
129 | PruneStatus::DeleteError(_)
130 | PruneStatus::ConfigError(_)
131 )
132 })
133 .count();
134
135 json!({
136 "schema": SCHEMA_VERSION,
137 "version": constants::VERSION,
138 "command": "run",
139 "dry_run": dry_run,
140 "results": results.iter().map(result_value).collect::<Vec<_>>(),
141 "summary": {
142 "bytes_freed": bytes_freed,
143 "bytes_reclaimable": bytes_reclaimable,
144 "directories_pruned": directories_pruned,
145 "errors": errors,
146 },
147 })
148}
149
150fn reason_tag(reason: &SkipReason) -> &'static str {
152 match reason {
153 SkipReason::Candidate => "candidate",
154 SkipReason::Active => "active",
155 SkipReason::Ignored => "ignored",
156 SkipReason::NoBloat => "no_bloat",
157 SkipReason::PathMissing => "path_missing",
158 SkipReason::ConfigError(_) => "config_error",
159 }
160}
161
162fn settings_value(settings: &Settings) -> Value {
163 json!({
164 "idle_days": settings.idle_days,
165 "check_interval_days": settings.check_interval_days,
166 "auto_setup": settings.auto_setup,
167 "auto_hooks": settings.auto_hooks,
168 "auto_daemon": settings.auto_daemon,
169 "require_confirmation": settings.require_confirmation,
170 "command_timeout_secs": settings.command_timeout_secs,
171 "min_size_mb": settings.min_size_mb,
172 "update_check": settings.update_check,
173 })
174}
175
176fn repo_value(entry: &RepoStatusEntry) -> Value {
177 let mut obj = json!({
178 "path": clean_path(&entry.path),
179 "state": reason_tag(&entry.reason),
180 "enabled": entry.entry.enabled,
181 "idle_days": entry.idle_days,
182 "last_activity": entry.last_activity.map(|t| t.to_rfc3339()),
183 "last_pruned_at": entry.entry.last_pruned_at.map(|t| t.to_rfc3339()),
184 "added_at": entry.entry.added_at.to_rfc3339(),
185 "adapters": entry.adapters,
186 "reclaimable_bytes": entry.reclaimable_bytes,
187 "directories": entry.bloat_dirs.iter().map(|b| json!({
188 "name": b.name,
189 "path": clean_path(&b.path),
190 "bytes": b.size_bytes,
191 })).collect::<Vec<_>>(),
192 });
193
194 if let SkipReason::ConfigError(e) = &entry.reason {
199 obj["error"] = json!(e);
200 }
201 obj
202}
203
204pub fn status_document(
210 registry: &Registry,
211 repos: &[RepoStatusEntry],
212 daemon: &str,
213 hooks: &str,
214) -> Value {
215 let reclaimable: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
216 let candidates = repos
217 .iter()
218 .filter(|r| matches!(r.reason, SkipReason::Candidate))
219 .count();
220
221 json!({
222 "schema": SCHEMA_VERSION,
223 "version": constants::VERSION,
224 "command": "status",
225 "config_path": Registry::registry_path().map(|p| clean_path(&p)).ok(),
226 "integrations": { "daemon": daemon, "git_hooks": hooks },
227 "settings": settings_value(®istry.settings),
228 "totals": {
229 "repositories": registry.repo_count(),
230 "candidates": candidates,
231 "reclaimable_bytes": reclaimable,
232 "historical_bytes_freed": registry.total_freed_bytes,
233 "prune_passes": registry.total_pruned_count,
234 },
235 "repositories": repos.iter().map(repo_value).collect::<Vec<_>>(),
236 })
237}
238
239pub fn caches_document(reports: &[crate::commands::caches::CacheReport]) -> Value {
246 let total: u64 = reports.iter().map(|r| r.bytes).sum();
247
248 let caches: Vec<Value> = reports
249 .iter()
250 .map(|r| {
251 let mut obj = json!({
252 "manager": r.manager,
253 "kind": r.kind,
254 "path": clean_path(&r.path),
255 "bytes": r.bytes,
256 "clear_command": r.clear_command,
257 });
258 if let Some(note) = r.note {
259 obj["note"] = json!(note);
260 }
261 obj
262 })
263 .collect();
264
265 json!({
266 "schema": SCHEMA_VERSION,
267 "version": constants::VERSION,
268 "command": "caches",
269 "caches": caches,
270 "summary": {
271 "total_bytes": total,
272 "count": reports.len(),
273 },
274 })
275}
276
277pub fn emit(document: &Value) -> anyhow::Result<()> {
282 println!("{}", serde_json::to_string_pretty(document)?);
283 Ok(())
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use std::path::PathBuf;
290
291 fn result(status: PruneStatus, bytes: u64) -> PruneResult {
292 PruneResult {
293 repo_path: PathBuf::from("/tmp/repo"),
294 adapter_name: "pnpm".to_string(),
295 bloat_dir: "node_modules".to_string(),
296 size_freed: bytes,
297 status,
298 }
299 }
300
301 #[test]
302 fn every_status_has_a_distinct_stable_tag() {
303 let all = [
304 PruneStatus::Pruned,
305 PruneStatus::SkippedActive,
306 PruneStatus::SkippedDryRun,
307 PruneStatus::LockfileError("x".into()),
308 PruneStatus::NoBloat,
309 PruneStatus::Disabled,
310 PruneStatus::SkippedIgnored,
311 PruneStatus::DeleteError("x".into()),
312 PruneStatus::ConfigError("x".into()),
313 ];
314 let mut tags: Vec<&str> = all.iter().map(status_tag).collect();
315 let count = tags.len();
316 tags.sort_unstable();
317 tags.dedup();
318 assert_eq!(tags.len(), count, "two statuses share a JSON tag");
319 }
320
321 #[test]
322 fn every_repository_state_has_a_distinct_stable_tag() {
323 let all = [
324 SkipReason::Candidate,
325 SkipReason::Active,
326 SkipReason::Ignored,
327 SkipReason::NoBloat,
328 SkipReason::PathMissing,
329 SkipReason::ConfigError("x".into()),
330 ];
331 let mut tags: Vec<&str> = all.iter().map(reason_tag).collect();
332 let count = tags.len();
333 tags.sort_unstable();
334 tags.dedup();
335 assert_eq!(tags.len(), count, "two repository states share a JSON tag");
336 }
337
338 #[test]
339 fn only_an_unreadable_config_carries_an_error_field() {
340 let entry = |reason| RepoStatusEntry {
341 path: PathBuf::from("/tmp/repo"),
342 entry: crate::config::RepoEntry::new(),
343 reason,
344 adapters: Vec::new(),
345 bloat_dirs: Vec::new(),
346 reclaimable_bytes: 0,
347 last_activity: None,
348 idle_days: 15,
349 };
350
351 let broken = repo_value(&entry(SkipReason::ConfigError("bad json".into())));
352 assert_eq!(broken["state"], "config_error");
353 assert_eq!(broken["error"], "bad json");
354
355 let healthy = repo_value(&entry(SkipReason::Candidate));
357 assert!(healthy.get("error").is_none());
358 }
359
360 #[test]
361 fn run_summary_counts_only_real_deletions() {
362 let doc = run_document(
363 &[
364 result(PruneStatus::Pruned, 100),
365 result(PruneStatus::Pruned, 50),
366 result(PruneStatus::SkippedActive, 0),
367 result(PruneStatus::LockfileError("nope".into()), 0),
368 ],
369 false,
370 );
371 assert_eq!(doc["summary"]["bytes_freed"], 150);
372 assert_eq!(doc["summary"]["directories_pruned"], 2);
373 assert_eq!(doc["summary"]["errors"], 1);
374 }
375
376 #[test]
377 fn dry_run_bytes_land_in_reclaimable_not_freed() {
378 let doc = run_document(&[result(PruneStatus::SkippedDryRun, 4096)], true);
381 assert_eq!(doc["summary"]["bytes_freed"], 0);
382 assert_eq!(doc["summary"]["bytes_reclaimable"], 4096);
383 assert_eq!(doc["dry_run"], true);
384 }
385
386 #[test]
387 fn lockfile_errors_carry_the_fix_command() {
388 let doc = run_document(
389 &[result(PruneStatus::LockfileError("boom".into()), 0)],
390 false,
391 );
392 assert_eq!(doc["results"][0]["message"], "boom");
393 assert_eq!(
394 doc["results"][0]["fix_command"],
395 "pnpm install --lockfile-only"
396 );
397 }
398
399 #[test]
400 fn a_successful_result_carries_no_message_or_fix() {
401 let doc = run_document(&[result(PruneStatus::Pruned, 1)], false);
402 assert!(doc["results"][0].get("message").is_none());
403 assert!(doc["results"][0].get("fix_command").is_none());
404 }
405
406 #[test]
407 fn venv_has_no_mechanical_lockfile_fix() {
408 assert!(lockfile_fix_command("venv").is_none());
411 assert!(lockfile_fix_command("nonsense").is_none());
412 }
413
414 #[test]
415 fn the_cache_report_totals_what_it_lists() {
416 use crate::commands::caches::CacheReport;
417
418 let doc = caches_document(&[
419 CacheReport {
420 manager: "go",
421 kind: "module cache",
422 path: PathBuf::from("/home/dev/go/pkg/mod"),
423 bytes: 4_000,
424 clear_command: "go clean -modcache",
425 note: None,
426 },
427 CacheReport {
428 manager: "pnpm",
429 kind: "store",
430 path: PathBuf::from("/home/dev/.pnpm-store"),
431 bytes: 1_000,
432 clear_command: "pnpm store prune",
433 note: Some("hardlinked"),
434 },
435 ]);
436
437 assert_eq!(doc["command"], "caches");
438 assert_eq!(doc["summary"]["total_bytes"], 5_000);
439 assert_eq!(doc["summary"]["count"], 2);
440 assert!(doc["caches"][0].get("note").is_none());
443 assert_eq!(doc["caches"][1]["note"], "hardlinked");
444 assert_eq!(doc["caches"][0]["clear_command"], "go clean -modcache");
445 }
446
447 #[test]
448 fn an_empty_cache_report_is_still_a_document() {
449 let doc = caches_document(&[]);
452 assert_eq!(doc["summary"]["total_bytes"], 0);
453 assert_eq!(doc["caches"].as_array().unwrap().len(), 0);
454 }
455
456 #[test]
457 fn every_adapter_with_a_lockfile_has_a_fix_command() {
458 for adapter in crate::adapters::get_all_adapters() {
459 if adapter.name() == "venv" {
460 continue;
461 }
462 assert!(
463 lockfile_fix_command(adapter.name()).is_some(),
464 "{} has no fix command",
465 adapter.name()
466 );
467 }
468 }
469}