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