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        PruneStatus::SkippedDeclaration(_) => "skipped_declaration",
48        PruneStatus::SkippedNestedRepo(_) => "skipped_nested_repo",
49    }
50}
51
52/// The detail carried by the failure variants, if any.
53fn status_message(status: &PruneStatus) -> Option<&str> {
54    match status {
55        PruneStatus::LockfileError(e)
56        | PruneStatus::ActivityCheckError(e)
57        | PruneStatus::DeleteError(e)
58        | PruneStatus::ConfigError(e)
59        | PruneStatus::SkippedSymlink(e)
60        | PruneStatus::SkippedDeclaration(e)
61        | PruneStatus::SkippedNestedRepo(e) => Some(e.trim()),
62        _ => None,
63    }
64}
65
66/// The command an agent should run to fix a failed lockfile check, or `None` when the
67/// failure is not of that kind.
68///
69/// This is the single reason an agent can act on a `lockfile_error` without a human:
70/// the fix is mechanical and the same one the human report prints.
71///
72/// Each of these is the *writing* form of that adapter's verification — the one
73/// [`crate::adapters::enforce_two_tier`] refuses to run on the user's behalf unless
74/// they set `allow_manifest_rewrite`. It resyncs the lockfile with the manifest, which
75/// is exactly what a failed read-only verification is complaining about.
76pub fn lockfile_fix_command(adapter: &str) -> Option<&'static str> {
77    Some(match adapter {
78        "npm" => "npm install --package-lock-only --ignore-scripts",
79        "pnpm" => "pnpm install --lockfile-only",
80        "yarn" => "yarn install --mode update-lockfile",
81        // bun has no resolve-only write mode; a plain install is what refreshes
82        // `bun.lock`, and unlike the others it also populates `node_modules`.
83        "bun" => "bun install",
84        // Same again for Deno: `deno install` is the only thing that rewrites
85        // `deno.lock`, and it materialises `node_modules` while it is there.
86        "deno" => "deno install",
87        "uv" => "uv lock",
88        "poetry" => "poetry lock",
89        "pdm" => "pdm lock",
90        "pipenv" => "pipenv lock",
91        "cargo" => "cargo generate-lockfile",
92        "go" => "go mod tidy",
93        "composer" => "composer update --no-install",
94        "bundler" => "bundle lock",
95        "cocoapods" => "pod install",
96        // Both Mix adapters refuse on a missing `mix.lock`, and one command writes it.
97        "mix" | "mix_build" => "mix deps.get",
98        // Writes the provider selections into `.terraform.lock.hcl` without touching
99        // the backend, which is the whole of what this adapter needs proven.
100        "terraform" => "terraform providers lock",
101        // Like bun, pub has no resolve-only write mode: `pub get` is what writes
102        // `pubspec.lock`, and it fills the machine-wide pub cache on the way past.
103        "dart" => "dart pub get",
104        // venv has no lockfile to regenerate — the fix is to write `requirements.txt`,
105        // which is authoring work, not a command we can hand over. gradle, maven, swift,
106        // vcpkg, cmake_build and dotnet_build verify the manifest, not lockfile sync —
107        // a missing manifest, or a `vcpkg.json` that declares no dependencies, has no
108        // mechanical fix either.
109        _ => return None,
110    })
111}
112
113fn result_value(result: &PruneResult) -> Value {
114    let mut obj = json!({
115        "repository": clean_path(&result.repo_path),
116        "adapter": result.adapter_name,
117        "directory": result.bloat_dir,
118        "status": status_tag(&result.status),
119        "bytes": result.size_freed,
120        "shared_bytes": result.shared_bytes,
121    });
122
123    if let Some(message) = status_message(&result.status) {
124        obj["message"] = json!(message);
125    }
126    if matches!(result.status, PruneStatus::LockfileError(_))
127        && let Some(fix) = lockfile_fix_command(&result.adapter_name)
128    {
129        obj["fix_command"] = json!(fix);
130    }
131    obj
132}
133
134/// The document emitted by `devp run --json`.
135///
136/// `summary.errors` counts results whose status is one of the four failure tags; a
137/// consumer that only wants to know "did anything go wrong" can read that alone.
138pub fn run_document(results: &[PruneResult], dry_run: bool) -> Value {
139    let bytes_freed: u64 = results
140        .iter()
141        .filter(|r| matches!(r.status, PruneStatus::Pruned))
142        .map(|r| r.size_freed)
143        .sum();
144    let directories_pruned = results
145        .iter()
146        .filter(|r| matches!(r.status, PruneStatus::Pruned))
147        .count();
148    let bytes_reclaimable: u64 = results
149        .iter()
150        .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
151        .map(|r| r.size_freed)
152        .sum();
153    let errors = results
154        .iter()
155        .filter(|r| {
156            matches!(
157                r.status,
158                PruneStatus::LockfileError(_)
159                    | PruneStatus::ActivityCheckError(_)
160                    | PruneStatus::DeleteError(_)
161                    | PruneStatus::ConfigError(_)
162            )
163        })
164        .count();
165
166    json!({
167        "schema": SCHEMA_VERSION,
168        "version": constants::VERSION,
169        "command": "run",
170        "dry_run": dry_run,
171        "results": results.iter().map(result_value).collect::<Vec<_>>(),
172        "summary": {
173            "bytes_freed": bytes_freed,
174            "bytes_reclaimable": bytes_reclaimable,
175            "directories_pruned": directories_pruned,
176            "errors": errors,
177        },
178    })
179}
180
181/// The stable machine name for why a repository is or is not a candidate.
182fn reason_tag(reason: &SkipReason) -> &'static str {
183    match reason {
184        SkipReason::Candidate => "candidate",
185        SkipReason::Active => "active",
186        SkipReason::Ignored => "ignored",
187        SkipReason::NoBloat => "no_bloat",
188        SkipReason::PathMissing => "path_missing",
189        SkipReason::ConfigError(_) => "config_error",
190    }
191}
192
193fn settings_value(settings: &Settings) -> Value {
194    json!({
195        "idle_days": settings.idle_days,
196        "check_interval_days": settings.check_interval_days,
197        "auto_setup": settings.auto_setup,
198        "auto_hooks": settings.auto_hooks,
199        "auto_daemon": settings.auto_daemon,
200        "require_confirmation": settings.require_confirmation,
201        "command_timeout_secs": settings.command_timeout_secs,
202        "min_size_mb": settings.min_size_mb,
203        "update_check": settings.update_check,
204    })
205}
206
207fn repo_value(registry: &Registry, entry: &RepoStatusEntry) -> Value {
208    let mut obj = json!({
209        "path": clean_path(&entry.path),
210        "state": reason_tag(&entry.reason),
211        "enabled": entry.entry.enabled,
212        "idle_days": entry.idle_days,
213        "last_activity": entry.last_activity.map(|t| t.to_rfc3339()),
214        "last_pruned_at": entry.entry.last_pruned_at.map(|t| t.to_rfc3339()),
215        "bytes_freed": entry.entry.total_freed_bytes,
216        "added_at": entry.entry.added_at.to_rfc3339(),
217        "adapters": entry.adapters,
218        "reclaimable_bytes": entry.reclaimable_bytes,
219        "directories": entry.bloat_dirs.iter().map(|b| json!({
220            "name": b.name,
221            "path": clean_path(&b.path),
222            "bytes": b.size_bytes,
223            "shared_bytes": b.shared_bytes,
224        })).collect::<Vec<_>>(),
225        // Null, not zero, when this machine has never timed a restore for any adapter
226        // this repository uses. Zero would read as "instant".
227        "restore_estimate_secs": registry
228            .estimate_restore(&entry.reclaimable_by_adapter)
229            .map(|(secs, _)| secs.round() as u64),
230    });
231
232    // Present only on `config_error`, and absent rather than null everywhere else — the
233    // same rule `result_value` follows for `message`, so one parser handles both
234    // documents. It carries the actual parse failure, so an agent can report what is
235    // wrong with the file instead of only the state word.
236    if let SkipReason::ConfigError(e) = &entry.reason {
237        obj["error"] = json!(e);
238    }
239    obj
240}
241
242/// The document emitted by `devp status --json`.
243///
244/// `daemon` and `hooks` are the same strings the dashboard shows; they describe the
245/// state of the machine's integrations, which is what an agent needs to decide whether
246/// to suggest `devp setup`.
247///
248/// `top` trims the `repositories` array only. `totals` is always computed over every
249/// registered repository, and `top` is echoed back so a consumer can tell a short list
250/// from a tidy machine.
251pub fn status_document(
252    registry: &Registry,
253    repos: &[RepoStatusEntry],
254    daemon: &str,
255    hooks: &str,
256    top: Option<usize>,
257) -> Value {
258    let reclaimable: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
259    let candidates = repos
260        .iter()
261        .filter(|r| matches!(r.reason, SkipReason::Candidate))
262        .count();
263    let listed = crate::engine::take_top(repos, top);
264
265    // Over every repository, like the rest of `totals`, and not over the trimmed list.
266    let mut by_adapter: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
267    for repo in repos {
268        for (adapter, bytes) in &repo.reclaimable_by_adapter {
269            *by_adapter.entry(adapter.clone()).or_default() += bytes;
270        }
271    }
272    let estimate = registry.estimate_restore(&by_adapter.into_iter().collect::<Vec<_>>());
273
274    let mut doc = json!({
275        "schema": SCHEMA_VERSION,
276        "version": constants::VERSION,
277        "command": "status",
278        "config_path": Registry::registry_path().map(|p| clean_path(&p)).ok(),
279        "integrations": { "daemon": daemon, "git_hooks": hooks },
280        "settings": settings_value(&registry.settings),
281        "totals": {
282            "repositories": registry.repo_count(),
283            "candidates": candidates,
284            "reclaimable_bytes": reclaimable,
285            "historical_bytes_freed": registry.total_freed_bytes,
286            "prune_passes": registry.total_pruned_count,
287            // Measured on this machine and nowhere else: `covered_bytes` is the part of
288            // `reclaimable_bytes` whose adapters have actually been timed here, so a
289            // consumer can tell a whole answer from a partial one instead of quoting
290            // `seconds` as if it covered everything.
291            "restore_estimate": estimate.map(|(secs, covered)| json!({
292                "seconds": secs.round() as u64,
293                "covered_bytes": covered,
294                "samples": registry.restore_rates.values().map(|r| r.samples as u64).sum::<u64>(),
295            })),
296        },
297        "repositories": listed.iter().map(|r| repo_value(registry, r)).collect::<Vec<_>>(),
298    });
299
300    // Absent rather than null when the whole list is present, the same rule `message`
301    // and `note` follow elsewhere in this contract.
302    if let Some(n) = top {
303        doc["top"] = json!(n);
304    }
305    doc
306}
307
308/// The document emitted by `devp stats --json`.
309///
310/// Three different vintages of number live here, and the field names say which is which.
311/// `lifetime` has been accumulating since 1.0.0. `recent_passes` and the `bytes_freed`
312/// inside `repositories` are only recorded from 1.1.0 onward, so on an upgraded machine
313/// they start near zero while `lifetime` does not — `history_starts_at` names the version
314/// that changed, so a consumer can say so rather than reporting a regression.
315/// `lifetime.cache_bytes_freed` is the third vintage: 1.9.0 onward, and zero on every
316/// machine that has not emptied a cache since upgrading.
317///
318/// `by_manager` and `by_trigger` are the fourth: they are summed from the prune log, so
319/// they cover only the passes it holds — `detail_starts_at`, not `history_starts_at`. Each carries its own `passes_not_counted`,
320/// and the two numbers differ on purpose — a pass can have a directory list without a
321/// trigger, so the manager breakdown reaches back further than the trigger split does.
322pub fn stats_document(registry: &Registry, passes: &[crate::history::Pass]) -> Value {
323    let (managers, managers_uncounted) = crate::commands::stats::rank_managers(passes);
324    let (triggers, triggers_uncounted) = crate::commands::stats::split_by_trigger(passes);
325
326    let mut repos: Vec<(&std::path::PathBuf, &crate::config::RepoEntry)> =
327        registry.repositories.iter().collect();
328    repos.sort_by(|a, b| {
329        b.1.total_freed_bytes
330            .cmp(&a.1.total_freed_bytes)
331            .then_with(|| a.0.cmp(b.0))
332    });
333
334    json!({
335        "schema": SCHEMA_VERSION,
336        "version": constants::VERSION,
337        "command": "stats",
338        "history_starts_at": constants::HISTORY_STARTS_AT,
339        // The other vintage, and the one `by_manager` and `by_trigger` are bounded by.
340        // Same key and same meaning as in the history document.
341        "detail_starts_at": constants::PRUNE_LOG_STARTS_AT,
342        "lifetime": {
343            "bytes_freed": registry.total_freed_bytes,
344            // Its own key, never added to `bytes_freed`. Both are bytes this tool gave
345            // back, but a consumer asking "how much did pruning save me" and one asking
346            // "how much will I re-download" want different halves of the sum.
347            "cache_bytes_freed": registry.total_cache_freed_bytes,
348            "container_bytes_freed": registry.total_container_freed_bytes,
349            // Same name and same number as `totals.prune_passes` in the status document.
350            // One per pass that deleted something, wherever it was started from.
351            "prune_passes": registry.total_pruned_count,
352            "repositories": registry.repo_count(),
353        },
354        "last_prune": registry.last_prune.as_ref().map(|p| json!({
355            "at": p.at.to_rfc3339(),
356            "bytes_freed": p.dirs.iter().map(|d| d.size_freed).sum::<u64>(),
357            "directories": p.dirs.len(),
358        })),
359        "recent_passes": registry.prune_history.iter().rev().map(|p| json!({
360            "at": p.at.to_rfc3339(),
361            "bytes_freed": p.bytes_freed,
362            "directories": p.dirs_removed,
363            "repositories": p.repos_touched,
364        })).collect::<Vec<_>>(),
365        "repositories": repos.iter().map(|(path, entry)| json!({
366            "path": clean_path(path),
367            "bytes_freed": entry.total_freed_bytes,
368            "last_pruned_at": entry.last_pruned_at.map(|t| t.to_rfc3339()),
369        })).collect::<Vec<_>>(),
370        // Every manager, not the ten the text report ranks: a consumer that wanted a
371        // top-ten can take one, and one that wanted the tail cannot get it back.
372        "by_manager": {
373            "passes_not_counted": managers_uncounted,
374            "managers": managers.iter().map(|m| json!({
375                "manager": m.manager,
376                "bytes_freed": m.bytes,
377                "directories": m.dirs,
378            })).collect::<Vec<_>>(),
379        },
380        "by_trigger": {
381            "passes_not_counted": triggers_uncounted,
382            // Always all three, including the zeros. A missing `scheduled` key and one
383            // reading zero mean the same thing here, and only one of them says it.
384            "triggers": triggers.iter().map(|t| json!({
385                "trigger": t.trigger.label(),
386                "bytes_freed": t.bytes,
387                "passes": t.passes,
388            })).collect::<Vec<_>>(),
389        },
390    })
391}
392
393/// The full prune log, for `devp history --json` and `devp history --export`.
394///
395/// The whole log, not a page of it: this is the document the `--export` file holds and
396/// the one an assistant is pointed at, and both of those want every pass. The compact
397/// text report is where the trimming lives.
398///
399/// `detail: false` on a pass is the honest form of a gap. Passes older than
400/// [`constants::PRUNE_LOG_STARTS_AT`] have their totals and no directory list, and a
401/// consumer must be able to tell "this pass deleted nothing" from "nobody wrote down
402/// what this pass deleted" — an empty `directories` array on its own says the first.
403///
404/// `only` narrows the document to one pass without renumbering it: `--pass 3 --json`
405/// carries `"pass": 3`, the same number the text report and `--pass` itself use.
406pub fn history_document(passes: &[crate::history::Pass], only: Option<usize>) -> Value {
407    use crate::history::Pass;
408    json!({
409        "schema": SCHEMA_VERSION,
410        "version": constants::VERSION,
411        "command": "history",
412        "detail_starts_at": constants::PRUNE_LOG_STARTS_AT,
413        "passes": passes.iter().enumerate()
414            .filter(|(index, _)| only.is_none_or(|n| n == index + 1))
415            .map(|(index, pass)| {
416            let mut entry = json!({
417                // 1 is the newest, matching `devp history --pass N` exactly.
418                "pass": index + 1,
419                "at": pass.at().to_rfc3339(),
420                "bytes_freed": pass.bytes_freed(),
421                "directories": pass.dirs_removed(),
422                "repositories": pass.repos_touched(),
423                "detail": pass.dirs().is_some(),
424            });
425            let map = entry.as_object_mut().expect("json! built an object");
426            if let Pass::Detailed(record) = pass {
427                map.insert("trigger".into(), json!(record.trigger.label()));
428                map.insert("argv".into(), json!(record.argv));
429                map.insert("command_line".into(), json!(record.command_line()));
430                map.insert("dev_prune_version".into(), json!(record.version));
431            }
432            if let Some(dirs) = pass.dirs() {
433                map.insert("removed".into(), json!(dirs.iter().map(|d| json!({
434                    "repo_path": clean_path(&d.repo_path),
435                    "directory": d.bloat_dir,
436                    "adapter": d.adapter,
437                    "bytes_freed": d.size_freed,
438                    "runtime": d.runtime,
439                })).collect::<Vec<_>>()));
440            }
441            entry
442        }).collect::<Vec<_>>(),
443    })
444}
445
446/// One entry per container engine that is installed, for either document that carries
447/// them.
448///
449/// An engine that is not installed is absent rather than present with `available:
450/// false`: a consumer looping over this array is asking "what is on this machine", and a
451/// row for every engine that is not would make every machine look like it had three.
452///
453/// `available: false` is the other case — installed, and its daemon did not answer — and
454/// it carries `reason` instead of sizes. A consumer must not read a missing `total_bytes`
455/// as zero; that is the difference between "Docker is holding nothing" and "dev-prune
456/// could not find out".
457fn container_engines(reports: &[crate::commands::containers::EngineReport]) -> Vec<Value> {
458    use crate::commands::containers::EngineState;
459    reports
460        .iter()
461        .map(|report| match &report.state {
462            EngineState::Unavailable(reason) => json!({
463                "engine": report.name,
464                "available": false,
465                "reason": reason,
466            }),
467            EngineState::Ready(rows) => json!({
468                "engine": report.name,
469                "available": true,
470                "rows": rows.iter().map(|row| {
471                    let mut obj = json!({ "kind": row.kind });
472                    // Every one of these is absent rather than null when the engine did
473                    // not say. `docker system df` reports no count for build cache on
474                    // some versions, and a `"total": 0` there would be a number nobody
475                    // produced.
476                    if let Some(n) = row.total {
477                        obj["total"] = json!(n);
478                    }
479                    if let Some(n) = row.active {
480                        obj["active"] = json!(n);
481                    }
482                    if let Some(n) = row.bytes {
483                        obj["bytes"] = json!(n);
484                    }
485                    if let Some(n) = row.reclaimable {
486                        obj["reclaimable_bytes"] = json!(n);
487                    }
488                    obj
489                }).collect::<Vec<_>>(),
490                "total_bytes": report.total_bytes().unwrap_or(0),
491                "reclaimable_bytes": report.reclaimable_bytes().unwrap_or(0),
492            }),
493        })
494        .collect()
495}
496
497/// The document emitted by `devp caches docker --json` and its siblings.
498///
499/// Deliberately has no `clear_command` anywhere, unlike [`caches_document`]. The prune
500/// commands are in the human report because a person reads them and decides; putting them
501/// in a machine-readable document would be handing an agent an argv for `docker system
502/// prune --volumes`, and no field in this contract should be one command substitution
503/// away from deleting a database. An agent that wants to reclaim container disk should
504/// say so to its human.
505///
506/// `kubernetes_contexts` carries names and no sizes, for the same reason the table does:
507/// a local cluster's disk already belongs to one of the engines above.
508pub fn containers_document(
509    reports: &[crate::commands::containers::EngineReport],
510    kubernetes_contexts: &[String],
511) -> Value {
512    let total: u64 = reports.iter().filter_map(|r| r.total_bytes()).sum();
513    let reclaimable: u64 = reports.iter().filter_map(|r| r.reclaimable_bytes()).sum();
514
515    json!({
516        "schema": SCHEMA_VERSION,
517        "version": constants::VERSION,
518        "command": "caches containers",
519        "engines": container_engines(reports),
520        "kubernetes_contexts": kubernetes_contexts,
521        "summary": {
522            "total_bytes": total,
523            "reclaimable_bytes": reclaimable,
524            "engines": reports.len(),
525        },
526    })
527}
528
529/// The document emitted by `devp caches --json`.
530///
531/// `clear_command` is the one field an agent can act on, and it is the only place in this
532/// contract that carries a command dev-prune will not run itself: these caches are shared
533/// by every project on the machine, so clearing one is a human's decision. `note` is
534/// present only where there is a cost beyond time.
535/// `registered_repositories` is the denominator behind every `dependents` field, and is
536/// present only when there was a registry to count — a consumer that finds it absent knows
537/// the counts are missing because nothing could be counted, not because nothing uses these
538/// caches.
539pub fn caches_document(
540    reports: &[crate::commands::caches::CacheReport],
541    registered_repositories: Option<usize>,
542    containers: &[crate::commands::containers::EngineReport],
543    volume: Option<&std::path::Path>,
544) -> Value {
545    let total: u64 = reports.iter().map(|r| r.bytes).sum();
546
547    let caches: Vec<Value> = reports
548        .iter()
549        .map(|r| {
550            let mut obj = json!({
551                "manager": r.manager,
552                "kind": r.kind,
553                "path": clean_path(&r.path),
554                "bytes": r.bytes,
555                "clear_command": &r.clear_command,
556            });
557            // The drive or filesystem the cache sits on, so a consumer can group by it
558            // without re-deriving a mount table from `path`. Absent where dev-prune
559            // cannot tell, which on Windows means a path with no drive letter.
560            if let Some(root) = crate::commands::caches::volume_root(&r.path) {
561                obj["volume"] = json!(clean_path(&root));
562            }
563            if let Some(note) = r.note {
564                obj["note"] = json!(note);
565            }
566            // Only when a cap is actually set. A `"cap_gb": null` on every row of every
567            // report would read as a feature that is switched on and doing nothing.
568            if let Some(gb) = r.cap_gb {
569                obj["cap_gb"] = json!(gb);
570                obj["over_cap"] = json!(r.over_cap);
571            }
572            // Absent where dev-prune cannot attribute a cache to any adapter, for the same
573            // reason: a `"dependents": 0` on a `pip` row would be read as "safe to clear"
574            // by exactly the consumer this contract exists for.
575            if let Some(n) = r.dependents {
576                obj["dependents"] = json!(n);
577            }
578            obj
579        })
580        .collect();
581
582    let mut summary = json!({
583        "total_bytes": total,
584        "count": reports.len(),
585    });
586    if let Some(n) = registered_repositories {
587        summary["registered_repositories"] = json!(n);
588    }
589    // Present only under `--volume`, and its presence is the signal that every figure in
590    // this document describes one drive rather than the machine.
591    if let Some(root) = volume {
592        summary["volume"] = json!(clean_path(root));
593    }
594
595    // Outside `summary.total_bytes` on purpose, and outside `caches` too. Container disk
596    // is not a package manager cache, dev-prune will never clear it, and a consumer
597    // summing one figure for "what devp caches could free" must not pick this up.
598    let mut doc = json!({
599        "schema": SCHEMA_VERSION,
600        "version": constants::VERSION,
601        "command": "caches",
602        "caches": caches,
603        "summary": summary,
604    });
605    // Absent under `--volume`, for the same reason `registered_repositories` is absent
606    // when there was no registry: an engine reports its disk from inside a VM image with
607    // no path on this filesystem, so it belongs to no drive and was never measured here.
608    // A `"containers": []` would say there are none installed, which is a different
609    // claim and usually a false one.
610    if volume.is_none() {
611        doc["containers"] = json!(container_engines(containers));
612    }
613    doc
614}
615/// The caches `clear` reported but deliberately did not empty, and the reason for each.
616///
617/// A consumer that only reads `caches` would otherwise see a Maven repository silently
618/// absent from a `clear all` and conclude there was none on the machine.
619fn kept_caches(kept: &[crate::commands::caches::CacheReport]) -> Vec<Value> {
620    use crate::commands::caches::Clear;
621    kept.iter()
622        .filter_map(|r| {
623            let Clear::Manual { why } = r.clear else {
624                return None;
625            };
626            Some(json!({
627                "manager": r.manager,
628                "kind": r.kind,
629                "path": clean_path(&r.path),
630                "bytes": r.bytes,
631                "clear_command": &r.clear_command,
632                "reason": why,
633            }))
634        })
635        .collect()
636}
637
638/// `caches clear --dry-run --json`: what would be emptied, and nothing touched.
639pub fn caches_clear_plan_document(
640    reports: &[crate::commands::caches::CacheReport],
641    kept: &[crate::commands::caches::CacheReport],
642) -> Value {
643    let total: u64 = reports.iter().map(|r| r.bytes).sum();
644
645    let caches: Vec<Value> = reports
646        .iter()
647        .map(|r| {
648            let mut obj = json!({
649                "manager": r.manager,
650                "kind": r.kind,
651                "path": clean_path(&r.path),
652                "bytes": r.bytes,
653                "clear_command": &r.clear_command,
654            });
655            if let Some(n) = r.dependents {
656                obj["dependents"] = json!(n);
657            }
658            obj
659        })
660        .collect();
661
662    json!({
663        "schema": SCHEMA_VERSION,
664        "version": constants::VERSION,
665        "command": "caches clear",
666        "dry_run": true,
667        "caches": caches,
668        "kept": kept_caches(kept),
669        "summary": {
670            "total_bytes": total,
671            "count": reports.len(),
672        },
673    })
674}
675
676/// `caches clear --json`: what actually went.
677///
678/// `freed_bytes` is measured, not assumed — a `prune` keeps what is still referenced,
679/// and a clear that failed half-way still freed part of it.
680pub fn caches_clear_document(
681    outcomes: &[crate::commands::caches::ClearOutcome],
682    kept: &[crate::commands::caches::CacheReport],
683) -> Value {
684    let freed: u64 = outcomes.iter().map(|o| o.freed()).sum();
685    let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
686
687    let caches: Vec<Value> = outcomes
688        .iter()
689        .map(|o| {
690            let mut obj = json!({
691                "manager": o.manager,
692                "kind": o.kind,
693                "path": clean_path(&o.path),
694                "bytes_before": o.before,
695                "bytes_after": o.after,
696                "freed_bytes": o.freed(),
697                "cleared": o.problem.is_none(),
698            });
699            if let Some(problem) = &o.problem {
700                obj["error"] = json!(problem);
701            }
702            obj
703        })
704        .collect();
705
706    json!({
707        "schema": SCHEMA_VERSION,
708        "version": constants::VERSION,
709        "command": "caches clear",
710        "dry_run": false,
711        "caches": caches,
712        "kept": kept_caches(kept),
713        "summary": {
714            "freed_bytes": freed,
715            "count": outcomes.len(),
716            "failed": failed,
717        },
718    })
719}
720/// `devp caches clear <engine> --json`: what was run, and what the disk gave back.
721///
722/// `freed_bytes` is the engine's own `system df` total before minus the same total after,
723/// never the sum of what each prune command reported. Container layers are shared, so
724/// those add up to more than the disk ever had — a consumer adding the step figures would
725/// get a number that cannot be true.
726///
727/// `volumes_untouched` is stated rather than implied. It is the one promise this command
728/// makes about what it did *not* do, and a consumer should be able to check it without
729/// reading the argv table in the binary.
730pub fn containers_clear_document(
731    outcome: &crate::commands::containers::ClearOutcome,
732    dry_run: bool,
733) -> Value {
734    let steps: Vec<Value> = outcome
735        .steps
736        .iter()
737        .map(|s| {
738            let mut obj = json!({
739                "command": s.command,
740                "reclaims": s.what,
741                "ran": !dry_run && s.problem.is_none(),
742            });
743            if let Some(problem) = &s.problem {
744                obj["error"] = json!(problem);
745            }
746            obj
747        })
748        .collect();
749
750    json!({
751        "schema": SCHEMA_VERSION,
752        "version": constants::VERSION,
753        "command": "caches clear",
754        "dry_run": dry_run,
755        "engine": outcome.engine,
756        "steps": steps,
757        "summary": {
758            "bytes_before": outcome.before,
759            "bytes_after": outcome.after,
760            "freed_bytes": if dry_run { 0 } else { outcome.freed() },
761            "failed": outcome.steps.iter().filter(|s| s.problem.is_some()).count(),
762            "volumes_untouched": true,
763        },
764    })
765}
766
767/// `devp trust --json`: what the tool guarantees, and what this machine has switched on.
768///
769/// Guarantees and machine state stay in separate arrays because they are different kinds
770/// of claim — one is structural and one is a reading — and flattening them would let a
771/// consumer treat a setting as a promise.
772pub fn trust_document(report: &crate::commands::trust::TrustReport) -> Value {
773    let rows = |rows: &[crate::commands::trust::TrustRow]| -> Vec<Value> {
774        rows.iter()
775            .map(|r| {
776                json!({
777                    "key": r.key,
778                    "subject": r.subject,
779                    "state": r.state,
780                    "verdict": r.verdict_key(),
781                })
782            })
783            .collect()
784    };
785
786    let widened = report.widened();
787
788    // `sha256` is a field rather than part of a sentence because the whole point of it
789    // is to be compared against a published `.sha256` by something other than a human.
790    // Null when the file could not be read, never an empty string: absent and empty are
791    // different answers and a consumer must be able to tell them apart.
792    let binaries: Vec<Value> = report
793        .binaries
794        .iter()
795        .map(|b| {
796            json!({
797                "role": b.role,
798                "name": b.name,
799                "path": b.path,
800                "channel": b.channel,
801                "sha256": b.sha256,
802                // Null rather than absent for a build that predates the stamp, for the
803                // same reason `sha256` is: "this copy does not say" and "this consumer
804                // is reading an older schema" are different answers.
805                "version": b.version,
806                "marker": b.marker,
807                "running": b.running,
808                "scan_report": b.scan_url(),
809                "note": b.note,
810            })
811        })
812        .collect();
813
814    json!({
815        "schema": SCHEMA_VERSION,
816        "version": constants::VERSION,
817        "command": "trust",
818        "guarantees": rows(&report.guarantees),
819        "machine": rows(&report.machine),
820        "binaries": binaries,
821        "summary": {
822            "widened": widened,
823            "widened_count": widened.len(),
824        },
825    })
826}
827
828/// The document emitted by `devp status --drift --json`.
829///
830/// A separate document from plain `status` because it answers a different question:
831/// not "what could a prune reclaim" but "what would a prune refuse, and why". An empty
832/// `drift` array means nothing was *detected*, across the adapters that can compare an
833/// environment against its lockfile from files alone.
834pub fn drift_document(findings: &[crate::commands::status::ProjectDrift]) -> Value {
835    let unrecorded_total: usize = findings.iter().map(|f| f.report.unrecorded.len()).sum();
836
837    json!({
838        "schema": SCHEMA_VERSION,
839        "version": constants::VERSION,
840        "command": "status --drift",
841        "drift": findings.iter().map(|f| json!({
842            "repository": clean_path(&f.repository),
843            "project": f.project,
844            "adapter": f.adapter,
845            "directory": f.report.directory,
846            "unrecorded": f.report.unrecorded,
847            "record_command": f.report.record_command,
848        })).collect::<Vec<_>>(),
849        "summary": {
850            "projects_with_drift": findings.len(),
851            "unrecorded_packages": unrecorded_total,
852        },
853    })
854}
855
856/// Print a document to stdout as pretty JSON with a trailing newline.
857///
858/// Pretty rather than compact because a human reads this output far more often than a
859/// parser does, and `jq` does not care either way.
860///
861/// When stdout is a terminal, the same document also lands on the clipboard: a pipe or
862/// a redirect means a program is consuming the output, but a terminal means a *person*
863/// asked for JSON, and the next thing they usually do is paste it somewhere. The
864/// notice goes to stderr and the copy is skipped entirely when piped, so the stdout
865/// contract — one document, byte-identical either way — holds.
866pub fn emit(document: &Value) -> anyhow::Result<()> {
867    use std::io::IsTerminal;
868    let text = serde_json::to_string_pretty(document)?;
869    println!("{text}");
870    if std::io::stdout().is_terminal() && copy_to_clipboard(&text) {
871        use colored::Colorize;
872        eprintln!("{}", "(also copied to your clipboard)".dimmed());
873    }
874    Ok(())
875}
876
877/// Best-effort: put `text` on the system clipboard. Returns whether it worked.
878///
879/// Spawns the platform's own clipboard tool rather than linking a clipboard crate — a
880/// native dependency is a heavy price for a nicety. `clip` on Windows, `pbcopy` on
881/// macOS, then `wl-copy`/`xclip`/`xsel` in that order on Linux; a headless box has
882/// none of them, and quietly not copying is the right behaviour there.
883fn copy_to_clipboard(text: &str) -> bool {
884    // `clip.exe` reads its input in the console codepage unless a BOM says otherwise;
885    // UTF-16LE with a BOM is the one encoding it always honours, and repository paths
886    // are not guaranteed to be ASCII.
887    let bytes: Vec<u8> = if cfg!(windows) {
888        let mut utf16 = vec![0xFF, 0xFE];
889        for unit in text.encode_utf16() {
890            utf16.extend_from_slice(&unit.to_le_bytes());
891        }
892        utf16
893    } else {
894        text.as_bytes().to_vec()
895    };
896
897    // On Windows the tool is named by full path: `CreateProcess` resolves a bare
898    // program name through the *current directory* before PATH, and dev-prune is
899    // routinely run from inside checkouts it has no reason to trust — a repository
900    // carrying its own `clip.exe` must not become the thing that executes. Unix PATH
901    // search never consults the current directory, so the bare names there are fine.
902    let windows_clip = std::env::var("SystemRoot")
903        .map(|root| format!("{root}\\System32\\clip.exe"))
904        .unwrap_or_else(|_| String::from("C:\\Windows\\System32\\clip.exe"));
905    let tools: Vec<Vec<&str>> = if cfg!(windows) {
906        vec![vec![windows_clip.as_str()]]
907    } else if cfg!(target_os = "macos") {
908        vec![vec!["pbcopy"]]
909    } else {
910        vec![
911            vec!["wl-copy"],
912            vec!["xclip", "-selection", "clipboard"],
913            vec!["xsel", "--clipboard", "--input"],
914        ]
915    };
916    tools.iter().any(|tool| pipe_into(tool, &bytes))
917}
918
919/// Run `command`, feed `bytes` to its stdin, and report whether it exited cleanly.
920fn pipe_into(command: &[&str], bytes: &[u8]) -> bool {
921    use std::io::Write;
922    use std::process::Stdio;
923    let Ok(mut child) = crate::spawn::command(command[0])
924        .args(&command[1..])
925        .stdin(Stdio::piped())
926        .stdout(Stdio::null())
927        .stderr(Stdio::null())
928        .spawn()
929    else {
930        return false;
931    };
932    let wrote = child
933        .stdin
934        .take()
935        .is_some_and(|mut stdin| stdin.write_all(bytes).is_ok());
936    let exited_cleanly = child.wait().map(|status| status.success()).unwrap_or(false);
937    wrote && exited_cleanly
938}
939
940#[cfg(test)]
941mod tests {
942    use super::*;
943    use std::path::PathBuf;
944
945    fn result(status: PruneStatus, bytes: u64) -> PruneResult {
946        PruneResult {
947            repo_path: PathBuf::from("/tmp/repo"),
948            adapter_name: "pnpm".to_string(),
949            bloat_dir: "node_modules".to_string(),
950            size_freed: bytes,
951            shared_bytes: 0,
952            runtime: None,
953            status,
954        }
955    }
956
957    #[test]
958    fn every_status_has_a_distinct_stable_tag() {
959        let all = [
960            PruneStatus::Pruned,
961            PruneStatus::SkippedActive,
962            PruneStatus::SkippedDryRun,
963            PruneStatus::LockfileError("x".into()),
964            PruneStatus::ActivityCheckError("x".into()),
965            PruneStatus::PathMissing,
966            PruneStatus::NoBloat,
967            PruneStatus::Disabled,
968            PruneStatus::SkippedIgnored,
969            PruneStatus::DeleteError("x".into()),
970            PruneStatus::ConfigError("x".into()),
971            PruneStatus::SkippedSymlink("x".into()),
972            PruneStatus::SkippedDeclaration("x".into()),
973            PruneStatus::SkippedNestedRepo("x".into()),
974        ];
975        let mut tags: Vec<&str> = all.iter().map(status_tag).collect();
976        let count = tags.len();
977        tags.sort_unstable();
978        tags.dedup();
979        assert_eq!(tags.len(), count, "two statuses share a JSON tag");
980    }
981
982    #[test]
983    fn every_repository_state_has_a_distinct_stable_tag() {
984        let all = [
985            SkipReason::Candidate,
986            SkipReason::Active,
987            SkipReason::Ignored,
988            SkipReason::NoBloat,
989            SkipReason::PathMissing,
990            SkipReason::ConfigError("x".into()),
991        ];
992        let mut tags: Vec<&str> = all.iter().map(reason_tag).collect();
993        let count = tags.len();
994        tags.sort_unstable();
995        tags.dedup();
996        assert_eq!(tags.len(), count, "two repository states share a JSON tag");
997    }
998
999    #[test]
1000    fn only_an_unreadable_config_carries_an_error_field() {
1001        let entry = |reason| RepoStatusEntry {
1002            path: PathBuf::from("/tmp/repo"),
1003            entry: crate::config::RepoEntry::new(),
1004            reason,
1005            adapters: Vec::new(),
1006            bloat_dirs: Vec::new(),
1007            reclaimable_bytes: 0,
1008            reclaimable_by_adapter: Vec::new(),
1009            last_activity: None,
1010            idle_days: 15,
1011        };
1012
1013        let registry = Registry::default();
1014        let broken = repo_value(
1015            &registry,
1016            &entry(SkipReason::ConfigError("bad json".into())),
1017        );
1018        assert_eq!(broken["state"], "config_error");
1019        assert_eq!(broken["error"], "bad json");
1020
1021        // Absent, not null — the same shape rule `message` follows in the run document.
1022        let healthy = repo_value(&registry, &entry(SkipReason::Candidate));
1023        assert!(healthy.get("error").is_none());
1024    }
1025
1026    #[test]
1027    fn run_summary_counts_only_real_deletions() {
1028        let doc = run_document(
1029            &[
1030                result(PruneStatus::Pruned, 100),
1031                result(PruneStatus::Pruned, 50),
1032                result(PruneStatus::SkippedActive, 0),
1033                result(PruneStatus::LockfileError("nope".into()), 0),
1034            ],
1035            false,
1036        );
1037        assert_eq!(doc["summary"]["bytes_freed"], 150);
1038        assert_eq!(doc["summary"]["directories_pruned"], 2);
1039        assert_eq!(doc["summary"]["errors"], 1);
1040    }
1041
1042    #[test]
1043    fn dry_run_bytes_land_in_reclaimable_not_freed() {
1044        // A dry run must never claim to have freed anything — a CI step that adds up
1045        // `bytes_freed` across runs would otherwise report space that still exists.
1046        let doc = run_document(&[result(PruneStatus::SkippedDryRun, 4096)], true);
1047        assert_eq!(doc["summary"]["bytes_freed"], 0);
1048        assert_eq!(doc["summary"]["bytes_reclaimable"], 4096);
1049        assert_eq!(doc["dry_run"], true);
1050    }
1051
1052    #[test]
1053    fn lockfile_errors_carry_the_fix_command() {
1054        let doc = run_document(
1055            &[result(PruneStatus::LockfileError("boom".into()), 0)],
1056            false,
1057        );
1058        assert_eq!(doc["results"][0]["message"], "boom");
1059        assert_eq!(
1060            doc["results"][0]["fix_command"],
1061            "pnpm install --lockfile-only"
1062        );
1063    }
1064
1065    #[test]
1066    fn a_successful_result_carries_no_message_or_fix() {
1067        let doc = run_document(&[result(PruneStatus::Pruned, 1)], false);
1068        assert!(doc["results"][0].get("message").is_none());
1069        assert!(doc["results"][0].get("fix_command").is_none());
1070    }
1071
1072    #[test]
1073    fn venv_has_no_mechanical_lockfile_fix() {
1074        // There is no command that writes a requirements.txt, so offering one would be
1075        // a lie an agent would then run.
1076        assert!(lockfile_fix_command("venv").is_none());
1077        assert!(lockfile_fix_command("nonsense").is_none());
1078    }
1079
1080    #[test]
1081    fn the_cache_report_totals_what_it_lists() {
1082        use crate::commands::caches::{CacheReport, Clear};
1083
1084        let doc = caches_document(
1085            &[
1086                CacheReport {
1087                    manager: "go",
1088                    kind: "module cache",
1089                    path: PathBuf::from("/home/dev/go/pkg/mod"),
1090                    bytes: 4_000,
1091                    clear_command: "go clean -modcache".to_string(),
1092                    clear: Clear::Command("go", &["clean", "-modcache"]),
1093                    note: None,
1094                    cap_gb: None,
1095                    over_cap: false,
1096                    dependents: None,
1097                    extra_args: Vec::new(),
1098                },
1099                CacheReport {
1100                    manager: "pnpm",
1101                    kind: "store",
1102                    path: PathBuf::from("/home/dev/.pnpm-store"),
1103                    bytes: 1_000,
1104                    clear_command: "pnpm store prune".to_string(),
1105                    clear: Clear::Command("pnpm", &["store", "prune"]),
1106                    note: Some("hardlinked"),
1107                    cap_gb: None,
1108                    over_cap: false,
1109                    dependents: None,
1110                    extra_args: Vec::new(),
1111                },
1112            ],
1113            Some(3),
1114            &[],
1115            None,
1116        );
1117
1118        assert_eq!(doc["command"], "caches");
1119        assert_eq!(doc["summary"]["total_bytes"], 5_000);
1120        assert_eq!(doc["summary"]["count"], 2);
1121        // Absent rather than null where there is nothing to say, matching every other
1122        // optional field in this contract.
1123        assert!(doc["caches"][0].get("note").is_none());
1124        assert_eq!(doc["caches"][1]["note"], "hardlinked");
1125        assert_eq!(doc["caches"][0]["clear_command"], "go clean -modcache");
1126    }
1127
1128    #[test]
1129    fn an_empty_cache_report_is_still_a_document() {
1130        // A machine with no package manager installed must produce a parseable zero, not
1131        // an absent `summary` a consumer would have to special-case.
1132        let doc = caches_document(&[], None, &[], None);
1133        assert_eq!(doc["summary"]["total_bytes"], 0);
1134        assert_eq!(doc["caches"].as_array().unwrap().len(), 0);
1135        assert_eq!(doc["containers"].as_array().unwrap().len(), 0);
1136    }
1137
1138    #[test]
1139    fn cache_clears_are_reported_beside_the_prune_total_not_inside_it() {
1140        let mut registry = crate::config::Registry {
1141            total_freed_bytes: 12_000_000_000,
1142            ..Default::default()
1143        };
1144        registry.record_cache_clear(6_000_000_000);
1145
1146        let doc = stats_document(&registry, &[]);
1147
1148        assert_eq!(doc["lifetime"]["bytes_freed"], 12_000_000_000u64);
1149        assert_eq!(doc["lifetime"]["cache_bytes_freed"], 6_000_000_000u64);
1150    }
1151
1152    #[test]
1153    fn the_breakdowns_report_what_they_could_not_count() {
1154        use crate::config::PrunedDir;
1155        use crate::history::{Pass, PassRecord, Trigger};
1156
1157        let registry = crate::config::Registry::default();
1158        let logged = Pass::Detailed(PassRecord {
1159            at: chrono::Utc::now(),
1160            trigger: Trigger::Scheduled,
1161            argv: vec!["run".to_string()],
1162            version: "1.17.0".to_string(),
1163            dirs: vec![PrunedDir {
1164                repo_path: std::path::PathBuf::from("/tmp/api"),
1165                bloat_dir: "node_modules".to_string(),
1166                adapter: "npm".to_string(),
1167                size_freed: 900,
1168                runtime: None,
1169            }],
1170        });
1171        // A pre-log pass, as `history::merged` reconstructs one: a total, and nothing to
1172        // attribute it to.
1173        let recovered = Pass::Summary {
1174            at: chrono::Utc::now() - chrono::Duration::days(1),
1175            bytes_freed: 100,
1176            dirs_removed: 1,
1177            repos_touched: 1,
1178            dirs: None,
1179        };
1180
1181        let doc = stats_document(&registry, &[logged, recovered]);
1182
1183        assert_eq!(doc["by_manager"]["managers"][0]["manager"], "npm");
1184        assert_eq!(doc["by_manager"]["managers"][0]["bytes_freed"], 900);
1185        assert_eq!(doc["by_manager"]["passes_not_counted"], 1);
1186
1187        // All three triggers, and the one pass that has none accounted for separately.
1188        let triggers = doc["by_trigger"]["triggers"].as_array().unwrap();
1189        assert_eq!(triggers.len(), 3);
1190        let scheduled = triggers
1191            .iter()
1192            .find(|t| t["trigger"] == "scheduled")
1193            .unwrap();
1194        assert_eq!(scheduled["passes"], 1);
1195        assert_eq!(scheduled["bytes_freed"], 900);
1196        assert_eq!(doc["by_trigger"]["passes_not_counted"], 1);
1197    }
1198
1199    #[test]
1200    fn container_disk_stays_out_of_the_cache_total() {
1201        use crate::commands::containers::{EngineReport, EngineState, Row};
1202
1203        let docker = EngineReport {
1204            name: "docker",
1205            state: EngineState::Ready(vec![Row {
1206                kind: "Images".to_string(),
1207                total: Some(9),
1208                active: Some(2),
1209                bytes: Some(40_000_000_000),
1210                reclaimable: Some(38_000_000_000),
1211            }]),
1212        };
1213        let doc = caches_document(&[], None, std::slice::from_ref(&docker), None);
1214
1215        // The whole point of the separate key. A consumer summing `summary.total_bytes`
1216        // is asking what `devp caches clear` could free, and 40 GB of images is not that
1217        // — dev-prune will never delete them.
1218        assert_eq!(doc["summary"]["total_bytes"], 0);
1219        assert_eq!(doc["containers"][0]["engine"], "docker");
1220        assert_eq!(doc["containers"][0]["total_bytes"], 40_000_000_000u64);
1221        assert_eq!(doc["containers"][0]["rows"][0]["kind"], "Images");
1222    }
1223
1224    #[test]
1225    fn an_engine_that_did_not_answer_carries_no_zero() {
1226        use crate::commands::containers::{EngineReport, EngineState};
1227
1228        let doc = containers_document(
1229            &[EngineReport {
1230                name: "docker",
1231                state: EngineState::Unavailable("daemon is not running".to_string()),
1232            }],
1233            &[],
1234        );
1235
1236        assert_eq!(doc["command"], "caches containers");
1237        assert_eq!(doc["engines"][0]["available"], false);
1238        assert_eq!(doc["engines"][0]["reason"], "daemon is not running");
1239        // Absent, not zero: "dev-prune could not find out" and "Docker is holding
1240        // nothing" are different answers and a consumer must be able to tell them apart.
1241        assert!(doc["engines"][0].get("total_bytes").is_none());
1242        assert_eq!(doc["summary"]["total_bytes"], 0);
1243    }
1244
1245    #[test]
1246    fn no_prune_command_reaches_the_json_contract() {
1247        use crate::commands::containers::{EngineReport, EngineState, Row};
1248
1249        let doc = containers_document(
1250            &[EngineReport {
1251                name: "docker",
1252                state: EngineState::Ready(vec![Row {
1253                    kind: "Build Cache".to_string(),
1254                    total: Some(41),
1255                    active: Some(0),
1256                    bytes: Some(6_750_000_000),
1257                    reclaimable: Some(6_750_000_000),
1258                }]),
1259            }],
1260            &["kind-dev".to_string()],
1261        );
1262
1263        // Deliberate: no field here should be one command substitution away from
1264        // `docker system prune --volumes`. The prune commands live in the human report.
1265        let text = serde_json::to_string(&doc).unwrap();
1266        assert!(!text.contains("prune"), "{text}");
1267        assert_eq!(doc["kubernetes_contexts"][0], "kind-dev");
1268        assert_eq!(doc["summary"]["reclaimable_bytes"], 6_750_000_000u64);
1269    }
1270
1271    #[test]
1272    fn every_adapter_with_a_lockfile_has_a_fix_command() {
1273        for adapter in crate::adapters::get_all_adapters() {
1274            // venv, gradle, maven, swift, vcpkg, cmake_build and dotnet_build verify
1275            // without a lockfile-sync step — see `lockfile_fix_command` for why each
1276            // has nothing mechanical to hand over.
1277            if matches!(
1278                adapter.name(),
1279                "venv" | "gradle" | "maven" | "swift" | "vcpkg" | "cmake_build" | "dotnet_build"
1280            ) {
1281                continue;
1282            }
1283            assert!(
1284                lockfile_fix_command(adapter.name()).is_some(),
1285                "{} has no fix command",
1286                adapter.name()
1287            );
1288        }
1289    }
1290}