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::NoBloat => "no_bloat",
40        PruneStatus::Disabled => "disabled",
41        PruneStatus::SkippedIgnored => "ignored",
42        PruneStatus::DeleteError(_) => "delete_error",
43        PruneStatus::ConfigError(_) => "config_error",
44    }
45}
46
47/// The detail carried by the failure variants, if any.
48fn 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
57/// The command an agent should run to fix a failed lockfile check, or `None` when the
58/// failure is not of that kind.
59///
60/// This is the single reason an agent can act on a `lockfile_error` without a human:
61/// the fix is mechanical and the same one the human report prints.
62///
63/// Each of these is the *writing* form of that adapter's verification — the one
64/// [`crate::adapters::enforce_two_tier`] refuses to run on the user's behalf unless
65/// they set `allow_manifest_rewrite`. It resyncs the lockfile with the manifest, which
66/// is exactly what a failed read-only verification is complaining about.
67pub 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 has no resolve-only write mode; a plain install is what refreshes
73        // `bun.lock`, and unlike the others it also populates `node_modules`.
74        "bun" => "bun install",
75        "uv" => "uv lock",
76        "cargo" => "cargo generate-lockfile",
77        "go" => "go mod tidy",
78        // venv has no lockfile to regenerate — the fix is to write `requirements.txt`,
79        // which is authoring work, not a command we can hand over.
80        _ => 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
104/// The document emitted by `devp run --json`.
105///
106/// `summary.errors` counts results whose status is one of the three failure tags; a
107/// consumer that only wants to know "did anything go wrong" can read that alone.
108pub 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
150/// The stable machine name for why a repository is or is not a candidate.
151fn 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    // Present only on `config_error`, and absent rather than null everywhere else — the
195    // same rule `result_value` follows for `message`, so one parser handles both
196    // documents. It carries the actual parse failure, so an agent can report what is
197    // wrong with the file instead of only the state word.
198    if let SkipReason::ConfigError(e) = &entry.reason {
199        obj["error"] = json!(e);
200    }
201    obj
202}
203
204/// The document emitted by `devp status --json`.
205///
206/// `daemon` and `hooks` are the same strings the dashboard shows; they describe the
207/// state of the machine's integrations, which is what an agent needs to decide whether
208/// to suggest `devp setup`.
209pub 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(&registry.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
239/// The document emitted by `devp caches --json`.
240///
241/// `clear_command` is the one field an agent can act on, and it is the only place in this
242/// contract that carries a command dev-prune will not run itself: these caches are shared
243/// by every project on the machine, so clearing one is a human's decision. `note` is
244/// present only where there is a cost beyond time.
245pub 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
277/// Print a document to stdout as pretty JSON with a trailing newline.
278///
279/// Pretty rather than compact because a human reads this output far more often than a
280/// parser does, and `jq` does not care either way.
281pub 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        // Absent, not null — the same shape rule `message` follows in the run document.
356        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        // A dry run must never claim to have freed anything — a CI step that adds up
379        // `bytes_freed` across runs would otherwise report space that still exists.
380        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        // There is no command that writes a requirements.txt, so offering one would be
409        // a lie an agent would then run.
410        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        // Absent rather than null where there is nothing to say, matching every other
441        // optional field in this contract.
442        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        // A machine with no package manager installed must produce a parseable zero, not
450        // an absent `summary` a consumer would have to special-case.
451        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}