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