Skip to main content

dev_prune/
json.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Machine-readable output for `--json`.
5//
6// This module is the whole contract. Every field an AI agent, CI step or script can rely
7// on is built here, so there is exactly one place to look when asking "what does
8// dev-prune emit?" and exactly one place to change when the answer moves.
9//
10// ## Stability
11//
12// `schema` is an integer that increases when a consumer would have to change to keep
13// working: a field removed, renamed, or given a different meaning. *Adding* a field does
14// not bump it, so parse permissively and ignore what you do not recognise.
15//
16// Paths are emitted through `output::clean_path`, which is what the human output shows,
17// so the two never disagree about what a repository is called.
18
19use 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
26/// Current output schema version. See the module docs before changing it.
27pub const SCHEMA_VERSION: u32 = 1;
28
29/// The stable machine name for a prune outcome.
30///
31/// Deliberately not the `Display` string: the human text is free to be reworded, these
32/// are not. Keep them lowercase snake_case and never reuse a retired one.
33fn 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
50/// The detail carried by the failure variants, if any.
51fn 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
62/// The command an agent should run to fix a failed lockfile check, or `None` when the
63/// failure is not of that kind.
64///
65/// This is the single reason an agent can act on a `lockfile_error` without a human:
66/// the fix is mechanical and the same one the human report prints.
67///
68/// Each of these is the *writing* form of that adapter's verification — the one
69/// [`crate::adapters::enforce_two_tier`] refuses to run on the user's behalf unless
70/// they set `allow_manifest_rewrite`. It resyncs the lockfile with the manifest, which
71/// is exactly what a failed read-only verification is complaining about.
72pub 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 has no resolve-only write mode; a plain install is what refreshes
78        // `bun.lock`, and unlike the others it also populates `node_modules`.
79        "bun" => "bun install",
80        "uv" => "uv lock",
81        "cargo" => "cargo generate-lockfile",
82        "go" => "go mod tidy",
83        // venv has no lockfile to regenerate — the fix is to write `requirements.txt`,
84        // which is authoring work, not a command we can hand over.
85        _ => 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
110/// The document emitted by `devp run --json`.
111///
112/// `summary.errors` counts results whose status is one of the four failure tags; a
113/// consumer that only wants to know "did anything go wrong" can read that alone.
114pub 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
157/// The stable machine name for why a repository is or is not a candidate.
158fn 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    // Present only on `config_error`, and absent rather than null everywhere else — the
203    // same rule `result_value` follows for `message`, so one parser handles both
204    // documents. It carries the actual parse failure, so an agent can report what is
205    // wrong with the file instead of only the state word.
206    if let SkipReason::ConfigError(e) = &entry.reason {
207        obj["error"] = json!(e);
208    }
209    obj
210}
211
212/// The document emitted by `devp status --json`.
213///
214/// `daemon` and `hooks` are the same strings the dashboard shows; they describe the
215/// state of the machine's integrations, which is what an agent needs to decide whether
216/// to suggest `devp setup`.
217///
218/// `top` trims the `repositories` array only. `totals` is always computed over every
219/// registered repository, and `top` is echoed back so a consumer can tell a short list
220/// from a tidy machine.
221pub 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(&registry.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    // Absent rather than null when the whole list is present, the same rule `message`
253    // and `note` follow elsewhere in this contract.
254    if let Some(n) = top {
255        doc["top"] = json!(n);
256    }
257    doc
258}
259
260/// The document emitted by `devp stats --json`.
261///
262/// Three different vintages of number live here, and the field names say which is which.
263/// `lifetime` has been accumulating since 1.0.0. `recent_passes` and the `bytes_freed`
264/// inside `repositories` are only recorded from 1.1.0 onward, so on an upgraded machine
265/// they start near zero while `lifetime` does not — `history_starts_at` names the version
266/// that changed, so a consumer can say so rather than reporting a regression.
267pub 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            // Same name and same number as `totals.prune_passes` in the status document.
284            // One per pass that deleted something, wherever it was started from.
285            "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
307/// The document emitted by `devp caches --json`.
308///
309/// `clear_command` is the one field an agent can act on, and it is the only place in this
310/// contract that carries a command dev-prune will not run itself: these caches are shared
311/// by every project on the machine, so clearing one is a human's decision. `note` is
312/// present only where there is a cost beyond time.
313pub 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
345/// The document emitted by `devp status --drift --json`.
346///
347/// A separate document from plain `status` because it answers a different question:
348/// not "what could a prune reclaim" but "what would a prune refuse, and why". An empty
349/// `drift` array means nothing was *detected*, across the adapters that can compare an
350/// environment against its lockfile from files alone.
351pub 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
373/// Print a document to stdout as pretty JSON with a trailing newline.
374///
375/// Pretty rather than compact because a human reads this output far more often than a
376/// parser does, and `jq` does not care either way.
377pub 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        // Absent, not null — the same shape rule `message` follows in the run document.
456        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        // A dry run must never claim to have freed anything — a CI step that adds up
479        // `bytes_freed` across runs would otherwise report space that still exists.
480        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        // There is no command that writes a requirements.txt, so offering one would be
509        // a lie an agent would then run.
510        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        // Absent rather than null where there is nothing to say, matching every other
541        // optional field in this contract.
542        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        // A machine with no package manager installed must produce a parseable zero, not
550        // an absent `summary` a consumer would have to special-case.
551        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}