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