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    }
48}
49
50/// The detail carried by the failure variants, if any.
51fn status_message(status: &PruneStatus) -> Option<&str> {
52    match status {
53        PruneStatus::LockfileError(e)
54        | PruneStatus::ActivityCheckError(e)
55        | PruneStatus::DeleteError(e)
56        | PruneStatus::ConfigError(e)
57        | PruneStatus::SkippedSymlink(e) => Some(e.trim()),
58        _ => None,
59    }
60}
61
62/// The command an agent should run to fix a failed lockfile check, or `None` when the
63/// failure is not of that kind.
64///
65/// This is the single reason an agent can act on a `lockfile_error` without a human:
66/// the fix is mechanical and the same one the human report prints.
67///
68/// Each of these is the *writing* form of that adapter's verification — the one
69/// [`crate::adapters::enforce_two_tier`] refuses to run on the user's behalf unless
70/// they set `allow_manifest_rewrite`. It resyncs the lockfile with the manifest, which
71/// is exactly what a failed read-only verification is complaining about.
72pub fn lockfile_fix_command(adapter: &str) -> Option<&'static str> {
73    Some(match adapter {
74        "npm" => "npm install --package-lock-only --ignore-scripts",
75        "pnpm" => "pnpm install --lockfile-only",
76        "yarn" => "yarn install --mode update-lockfile",
77        // bun has no resolve-only write mode; a plain install is what refreshes
78        // `bun.lock`, and unlike the others it also populates `node_modules`.
79        "bun" => "bun install",
80        "uv" => "uv lock",
81        "poetry" => "poetry lock",
82        "pdm" => "pdm lock",
83        "pipenv" => "pipenv lock",
84        "cargo" => "cargo generate-lockfile",
85        "go" => "go mod tidy",
86        "composer" => "composer update --no-install",
87        "bundler" => "bundle lock",
88        "cocoapods" => "pod install",
89        "mix" => "mix deps.get",
90        // Writes the provider selections into `.terraform.lock.hcl` without touching
91        // the backend, which is the whole of what this adapter needs proven.
92        "terraform" => "terraform providers lock",
93        // Like bun, pub has no resolve-only write mode: `pub get` is what writes
94        // `pubspec.lock`, and it fills the machine-wide pub cache on the way past.
95        "dart" => "dart pub get",
96        // venv has no lockfile to regenerate — the fix is to write `requirements.txt`,
97        // which is authoring work, not a command we can hand over. gradle, maven and
98        // swift verify manifest presence, not lockfile sync — a missing manifest has no
99        // mechanical fix either.
100        _ => return None,
101    })
102}
103
104fn result_value(result: &PruneResult) -> Value {
105    let mut obj = json!({
106        "repository": clean_path(&result.repo_path),
107        "adapter": result.adapter_name,
108        "directory": result.bloat_dir,
109        "status": status_tag(&result.status),
110        "bytes": result.size_freed,
111        "shared_bytes": result.shared_bytes,
112    });
113
114    if let Some(message) = status_message(&result.status) {
115        obj["message"] = json!(message);
116    }
117    if matches!(result.status, PruneStatus::LockfileError(_))
118        && let Some(fix) = lockfile_fix_command(&result.adapter_name)
119    {
120        obj["fix_command"] = json!(fix);
121    }
122    obj
123}
124
125/// The document emitted by `devp run --json`.
126///
127/// `summary.errors` counts results whose status is one of the four failure tags; a
128/// consumer that only wants to know "did anything go wrong" can read that alone.
129pub fn run_document(results: &[PruneResult], dry_run: bool) -> Value {
130    let bytes_freed: u64 = results
131        .iter()
132        .filter(|r| matches!(r.status, PruneStatus::Pruned))
133        .map(|r| r.size_freed)
134        .sum();
135    let directories_pruned = results
136        .iter()
137        .filter(|r| matches!(r.status, PruneStatus::Pruned))
138        .count();
139    let bytes_reclaimable: u64 = results
140        .iter()
141        .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
142        .map(|r| r.size_freed)
143        .sum();
144    let errors = results
145        .iter()
146        .filter(|r| {
147            matches!(
148                r.status,
149                PruneStatus::LockfileError(_)
150                    | PruneStatus::ActivityCheckError(_)
151                    | PruneStatus::DeleteError(_)
152                    | PruneStatus::ConfigError(_)
153            )
154        })
155        .count();
156
157    json!({
158        "schema": SCHEMA_VERSION,
159        "version": constants::VERSION,
160        "command": "run",
161        "dry_run": dry_run,
162        "results": results.iter().map(result_value).collect::<Vec<_>>(),
163        "summary": {
164            "bytes_freed": bytes_freed,
165            "bytes_reclaimable": bytes_reclaimable,
166            "directories_pruned": directories_pruned,
167            "errors": errors,
168        },
169    })
170}
171
172/// The stable machine name for why a repository is or is not a candidate.
173fn reason_tag(reason: &SkipReason) -> &'static str {
174    match reason {
175        SkipReason::Candidate => "candidate",
176        SkipReason::Active => "active",
177        SkipReason::Ignored => "ignored",
178        SkipReason::NoBloat => "no_bloat",
179        SkipReason::PathMissing => "path_missing",
180        SkipReason::ConfigError(_) => "config_error",
181    }
182}
183
184fn settings_value(settings: &Settings) -> Value {
185    json!({
186        "idle_days": settings.idle_days,
187        "check_interval_days": settings.check_interval_days,
188        "auto_setup": settings.auto_setup,
189        "auto_hooks": settings.auto_hooks,
190        "auto_daemon": settings.auto_daemon,
191        "require_confirmation": settings.require_confirmation,
192        "command_timeout_secs": settings.command_timeout_secs,
193        "min_size_mb": settings.min_size_mb,
194        "update_check": settings.update_check,
195    })
196}
197
198fn repo_value(registry: &Registry, entry: &RepoStatusEntry) -> Value {
199    let mut obj = json!({
200        "path": clean_path(&entry.path),
201        "state": reason_tag(&entry.reason),
202        "enabled": entry.entry.enabled,
203        "idle_days": entry.idle_days,
204        "last_activity": entry.last_activity.map(|t| t.to_rfc3339()),
205        "last_pruned_at": entry.entry.last_pruned_at.map(|t| t.to_rfc3339()),
206        "bytes_freed": entry.entry.total_freed_bytes,
207        "added_at": entry.entry.added_at.to_rfc3339(),
208        "adapters": entry.adapters,
209        "reclaimable_bytes": entry.reclaimable_bytes,
210        "directories": entry.bloat_dirs.iter().map(|b| json!({
211            "name": b.name,
212            "path": clean_path(&b.path),
213            "bytes": b.size_bytes,
214            "shared_bytes": b.shared_bytes,
215        })).collect::<Vec<_>>(),
216        // Null, not zero, when this machine has never timed a restore for any adapter
217        // this repository uses. Zero would read as "instant".
218        "restore_estimate_secs": registry
219            .estimate_restore(&entry.reclaimable_by_adapter)
220            .map(|(secs, _)| secs.round() as u64),
221    });
222
223    // Present only on `config_error`, and absent rather than null everywhere else — the
224    // same rule `result_value` follows for `message`, so one parser handles both
225    // documents. It carries the actual parse failure, so an agent can report what is
226    // wrong with the file instead of only the state word.
227    if let SkipReason::ConfigError(e) = &entry.reason {
228        obj["error"] = json!(e);
229    }
230    obj
231}
232
233/// The document emitted by `devp status --json`.
234///
235/// `daemon` and `hooks` are the same strings the dashboard shows; they describe the
236/// state of the machine's integrations, which is what an agent needs to decide whether
237/// to suggest `devp setup`.
238///
239/// `top` trims the `repositories` array only. `totals` is always computed over every
240/// registered repository, and `top` is echoed back so a consumer can tell a short list
241/// from a tidy machine.
242pub fn status_document(
243    registry: &Registry,
244    repos: &[RepoStatusEntry],
245    daemon: &str,
246    hooks: &str,
247    top: Option<usize>,
248) -> Value {
249    let reclaimable: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
250    let candidates = repos
251        .iter()
252        .filter(|r| matches!(r.reason, SkipReason::Candidate))
253        .count();
254    let listed = crate::engine::take_top(repos, top);
255
256    // Over every repository, like the rest of `totals`, and not over the trimmed list.
257    let mut by_adapter: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
258    for repo in repos {
259        for (adapter, bytes) in &repo.reclaimable_by_adapter {
260            *by_adapter.entry(adapter.clone()).or_default() += bytes;
261        }
262    }
263    let estimate = registry.estimate_restore(&by_adapter.into_iter().collect::<Vec<_>>());
264
265    let mut doc = json!({
266        "schema": SCHEMA_VERSION,
267        "version": constants::VERSION,
268        "command": "status",
269        "config_path": Registry::registry_path().map(|p| clean_path(&p)).ok(),
270        "integrations": { "daemon": daemon, "git_hooks": hooks },
271        "settings": settings_value(&registry.settings),
272        "totals": {
273            "repositories": registry.repo_count(),
274            "candidates": candidates,
275            "reclaimable_bytes": reclaimable,
276            "historical_bytes_freed": registry.total_freed_bytes,
277            "prune_passes": registry.total_pruned_count,
278            // Measured on this machine and nowhere else: `covered_bytes` is the part of
279            // `reclaimable_bytes` whose adapters have actually been timed here, so a
280            // consumer can tell a whole answer from a partial one instead of quoting
281            // `seconds` as if it covered everything.
282            "restore_estimate": estimate.map(|(secs, covered)| json!({
283                "seconds": secs.round() as u64,
284                "covered_bytes": covered,
285                "samples": registry.restore_rates.values().map(|r| r.samples as u64).sum::<u64>(),
286            })),
287        },
288        "repositories": listed.iter().map(|r| repo_value(registry, r)).collect::<Vec<_>>(),
289    });
290
291    // Absent rather than null when the whole list is present, the same rule `message`
292    // and `note` follow elsewhere in this contract.
293    if let Some(n) = top {
294        doc["top"] = json!(n);
295    }
296    doc
297}
298
299/// The document emitted by `devp stats --json`.
300///
301/// Three different vintages of number live here, and the field names say which is which.
302/// `lifetime` has been accumulating since 1.0.0. `recent_passes` and the `bytes_freed`
303/// inside `repositories` are only recorded from 1.1.0 onward, so on an upgraded machine
304/// they start near zero while `lifetime` does not — `history_starts_at` names the version
305/// that changed, so a consumer can say so rather than reporting a regression.
306pub fn stats_document(registry: &Registry) -> Value {
307    let mut repos: Vec<(&std::path::PathBuf, &crate::config::RepoEntry)> =
308        registry.repositories.iter().collect();
309    repos.sort_by(|a, b| {
310        b.1.total_freed_bytes
311            .cmp(&a.1.total_freed_bytes)
312            .then_with(|| a.0.cmp(b.0))
313    });
314
315    json!({
316        "schema": SCHEMA_VERSION,
317        "version": constants::VERSION,
318        "command": "stats",
319        "history_starts_at": constants::HISTORY_STARTS_AT,
320        "lifetime": {
321            "bytes_freed": registry.total_freed_bytes,
322            // Same name and same number as `totals.prune_passes` in the status document.
323            // One per pass that deleted something, wherever it was started from.
324            "prune_passes": registry.total_pruned_count,
325            "repositories": registry.repo_count(),
326        },
327        "last_prune": registry.last_prune.as_ref().map(|p| json!({
328            "at": p.at.to_rfc3339(),
329            "bytes_freed": p.dirs.iter().map(|d| d.size_freed).sum::<u64>(),
330            "directories": p.dirs.len(),
331        })),
332        "recent_passes": registry.prune_history.iter().rev().map(|p| json!({
333            "at": p.at.to_rfc3339(),
334            "bytes_freed": p.bytes_freed,
335            "directories": p.dirs_removed,
336            "repositories": p.repos_touched,
337        })).collect::<Vec<_>>(),
338        "repositories": repos.iter().map(|(path, entry)| json!({
339            "path": clean_path(path),
340            "bytes_freed": entry.total_freed_bytes,
341            "last_pruned_at": entry.last_pruned_at.map(|t| t.to_rfc3339()),
342        })).collect::<Vec<_>>(),
343    })
344}
345
346/// The document emitted by `devp caches --json`.
347///
348/// `clear_command` is the one field an agent can act on, and it is the only place in this
349/// contract that carries a command dev-prune will not run itself: these caches are shared
350/// by every project on the machine, so clearing one is a human's decision. `note` is
351/// present only where there is a cost beyond time.
352pub fn caches_document(reports: &[crate::commands::caches::CacheReport]) -> Value {
353    let total: u64 = reports.iter().map(|r| r.bytes).sum();
354
355    let caches: Vec<Value> = reports
356        .iter()
357        .map(|r| {
358            let mut obj = json!({
359                "manager": r.manager,
360                "kind": r.kind,
361                "path": clean_path(&r.path),
362                "bytes": r.bytes,
363                "clear_command": r.clear_command,
364            });
365            if let Some(note) = r.note {
366                obj["note"] = json!(note);
367            }
368            obj
369        })
370        .collect();
371
372    json!({
373        "schema": SCHEMA_VERSION,
374        "version": constants::VERSION,
375        "command": "caches",
376        "caches": caches,
377        "summary": {
378            "total_bytes": total,
379            "count": reports.len(),
380        },
381    })
382}
383/// `caches clear --dry-run --json`: what would be emptied, and nothing touched.
384pub fn caches_clear_plan_document(reports: &[crate::commands::caches::CacheReport]) -> Value {
385    let total: u64 = reports.iter().map(|r| r.bytes).sum();
386
387    let caches: Vec<Value> = reports
388        .iter()
389        .map(|r| {
390            json!({
391                "manager": r.manager,
392                "kind": r.kind,
393                "path": clean_path(&r.path),
394                "bytes": r.bytes,
395                "clear_command": r.clear_command,
396            })
397        })
398        .collect();
399
400    json!({
401        "schema": SCHEMA_VERSION,
402        "version": constants::VERSION,
403        "command": "caches clear",
404        "dry_run": true,
405        "caches": caches,
406        "summary": {
407            "total_bytes": total,
408            "count": reports.len(),
409        },
410    })
411}
412
413/// `caches clear --json`: what actually went.
414///
415/// `freed_bytes` is measured, not assumed — a `prune` keeps what is still referenced,
416/// and a clear that failed half-way still freed part of it.
417pub fn caches_clear_document(outcomes: &[crate::commands::caches::ClearOutcome]) -> Value {
418    let freed: u64 = outcomes.iter().map(|o| o.freed()).sum();
419    let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
420
421    let caches: Vec<Value> = outcomes
422        .iter()
423        .map(|o| {
424            let mut obj = json!({
425                "manager": o.manager,
426                "kind": o.kind,
427                "path": clean_path(&o.path),
428                "bytes_before": o.before,
429                "bytes_after": o.after,
430                "freed_bytes": o.freed(),
431                "cleared": o.problem.is_none(),
432            });
433            if let Some(problem) = &o.problem {
434                obj["error"] = json!(problem);
435            }
436            obj
437        })
438        .collect();
439
440    json!({
441        "schema": SCHEMA_VERSION,
442        "version": constants::VERSION,
443        "command": "caches clear",
444        "dry_run": false,
445        "caches": caches,
446        "summary": {
447            "freed_bytes": freed,
448            "count": outcomes.len(),
449            "failed": failed,
450        },
451    })
452}
453/// `devp trust --json`: what the tool guarantees, and what this machine has switched on.
454///
455/// Guarantees and machine state stay in separate arrays because they are different kinds
456/// of claim — one is structural and one is a reading — and flattening them would let a
457/// consumer treat a setting as a promise.
458pub fn trust_document(report: &crate::commands::trust::TrustReport) -> Value {
459    let rows = |rows: &[crate::commands::trust::TrustRow]| -> Vec<Value> {
460        rows.iter()
461            .map(|r| {
462                json!({
463                    "key": r.key,
464                    "subject": r.subject,
465                    "state": r.state,
466                    "verdict": r.verdict_key(),
467                })
468            })
469            .collect()
470    };
471
472    let widened = report.widened();
473
474    json!({
475        "schema": SCHEMA_VERSION,
476        "version": constants::VERSION,
477        "command": "trust",
478        "guarantees": rows(&report.guarantees),
479        "machine": rows(&report.machine),
480        "summary": {
481            "widened": widened,
482            "widened_count": widened.len(),
483        },
484    })
485}
486
487/// The document emitted by `devp status --drift --json`.
488///
489/// A separate document from plain `status` because it answers a different question:
490/// not "what could a prune reclaim" but "what would a prune refuse, and why". An empty
491/// `drift` array means nothing was *detected*, across the adapters that can compare an
492/// environment against its lockfile from files alone.
493pub fn drift_document(findings: &[crate::commands::status::ProjectDrift]) -> Value {
494    let unrecorded_total: usize = findings.iter().map(|f| f.report.unrecorded.len()).sum();
495
496    json!({
497        "schema": SCHEMA_VERSION,
498        "version": constants::VERSION,
499        "command": "status --drift",
500        "drift": findings.iter().map(|f| json!({
501            "repository": clean_path(&f.repository),
502            "project": f.project,
503            "adapter": f.adapter,
504            "directory": f.report.directory,
505            "unrecorded": f.report.unrecorded,
506            "record_command": f.report.record_command,
507        })).collect::<Vec<_>>(),
508        "summary": {
509            "projects_with_drift": findings.len(),
510            "unrecorded_packages": unrecorded_total,
511        },
512    })
513}
514
515/// Print a document to stdout as pretty JSON with a trailing newline.
516///
517/// Pretty rather than compact because a human reads this output far more often than a
518/// parser does, and `jq` does not care either way.
519///
520/// When stdout is a terminal, the same document also lands on the clipboard: a pipe or
521/// a redirect means a program is consuming the output, but a terminal means a *person*
522/// asked for JSON, and the next thing they usually do is paste it somewhere. The
523/// notice goes to stderr and the copy is skipped entirely when piped, so the stdout
524/// contract — one document, byte-identical either way — holds.
525pub fn emit(document: &Value) -> anyhow::Result<()> {
526    use std::io::IsTerminal;
527    let text = serde_json::to_string_pretty(document)?;
528    println!("{text}");
529    if std::io::stdout().is_terminal() && copy_to_clipboard(&text) {
530        use colored::Colorize;
531        eprintln!("{}", "(also copied to your clipboard)".dimmed());
532    }
533    Ok(())
534}
535
536/// Best-effort: put `text` on the system clipboard. Returns whether it worked.
537///
538/// Spawns the platform's own clipboard tool rather than linking a clipboard crate — a
539/// native dependency is a heavy price for a nicety. `clip` on Windows, `pbcopy` on
540/// macOS, then `wl-copy`/`xclip`/`xsel` in that order on Linux; a headless box has
541/// none of them, and quietly not copying is the right behaviour there.
542fn copy_to_clipboard(text: &str) -> bool {
543    // `clip.exe` reads its input in the console codepage unless a BOM says otherwise;
544    // UTF-16LE with a BOM is the one encoding it always honours, and repository paths
545    // are not guaranteed to be ASCII.
546    let bytes: Vec<u8> = if cfg!(windows) {
547        let mut utf16 = vec![0xFF, 0xFE];
548        for unit in text.encode_utf16() {
549            utf16.extend_from_slice(&unit.to_le_bytes());
550        }
551        utf16
552    } else {
553        text.as_bytes().to_vec()
554    };
555
556    // On Windows the tool is named by full path: `CreateProcess` resolves a bare
557    // program name through the *current directory* before PATH, and dev-prune is
558    // routinely run from inside checkouts it has no reason to trust — a repository
559    // carrying its own `clip.exe` must not become the thing that executes. Unix PATH
560    // search never consults the current directory, so the bare names there are fine.
561    let windows_clip = std::env::var("SystemRoot")
562        .map(|root| format!("{root}\\System32\\clip.exe"))
563        .unwrap_or_else(|_| String::from("C:\\Windows\\System32\\clip.exe"));
564    let tools: Vec<Vec<&str>> = if cfg!(windows) {
565        vec![vec![windows_clip.as_str()]]
566    } else if cfg!(target_os = "macos") {
567        vec![vec!["pbcopy"]]
568    } else {
569        vec![
570            vec!["wl-copy"],
571            vec!["xclip", "-selection", "clipboard"],
572            vec!["xsel", "--clipboard", "--input"],
573        ]
574    };
575    tools.iter().any(|tool| pipe_into(tool, &bytes))
576}
577
578/// Run `command`, feed `bytes` to its stdin, and report whether it exited cleanly.
579fn pipe_into(command: &[&str], bytes: &[u8]) -> bool {
580    use std::io::Write;
581    use std::process::Stdio;
582    let Ok(mut child) = crate::spawn::command(command[0])
583        .args(&command[1..])
584        .stdin(Stdio::piped())
585        .stdout(Stdio::null())
586        .stderr(Stdio::null())
587        .spawn()
588    else {
589        return false;
590    };
591    let wrote = child
592        .stdin
593        .take()
594        .is_some_and(|mut stdin| stdin.write_all(bytes).is_ok());
595    let exited_cleanly = child.wait().map(|status| status.success()).unwrap_or(false);
596    wrote && exited_cleanly
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602    use std::path::PathBuf;
603
604    fn result(status: PruneStatus, bytes: u64) -> PruneResult {
605        PruneResult {
606            repo_path: PathBuf::from("/tmp/repo"),
607            adapter_name: "pnpm".to_string(),
608            bloat_dir: "node_modules".to_string(),
609            size_freed: bytes,
610            shared_bytes: 0,
611            runtime: None,
612            status,
613        }
614    }
615
616    #[test]
617    fn every_status_has_a_distinct_stable_tag() {
618        let all = [
619            PruneStatus::Pruned,
620            PruneStatus::SkippedActive,
621            PruneStatus::SkippedDryRun,
622            PruneStatus::LockfileError("x".into()),
623            PruneStatus::ActivityCheckError("x".into()),
624            PruneStatus::PathMissing,
625            PruneStatus::NoBloat,
626            PruneStatus::Disabled,
627            PruneStatus::SkippedIgnored,
628            PruneStatus::DeleteError("x".into()),
629            PruneStatus::ConfigError("x".into()),
630            PruneStatus::SkippedSymlink("x".into()),
631        ];
632        let mut tags: Vec<&str> = all.iter().map(status_tag).collect();
633        let count = tags.len();
634        tags.sort_unstable();
635        tags.dedup();
636        assert_eq!(tags.len(), count, "two statuses share a JSON tag");
637    }
638
639    #[test]
640    fn every_repository_state_has_a_distinct_stable_tag() {
641        let all = [
642            SkipReason::Candidate,
643            SkipReason::Active,
644            SkipReason::Ignored,
645            SkipReason::NoBloat,
646            SkipReason::PathMissing,
647            SkipReason::ConfigError("x".into()),
648        ];
649        let mut tags: Vec<&str> = all.iter().map(reason_tag).collect();
650        let count = tags.len();
651        tags.sort_unstable();
652        tags.dedup();
653        assert_eq!(tags.len(), count, "two repository states share a JSON tag");
654    }
655
656    #[test]
657    fn only_an_unreadable_config_carries_an_error_field() {
658        let entry = |reason| RepoStatusEntry {
659            path: PathBuf::from("/tmp/repo"),
660            entry: crate::config::RepoEntry::new(),
661            reason,
662            adapters: Vec::new(),
663            bloat_dirs: Vec::new(),
664            reclaimable_bytes: 0,
665            reclaimable_by_adapter: Vec::new(),
666            last_activity: None,
667            idle_days: 15,
668        };
669
670        let registry = Registry::default();
671        let broken = repo_value(
672            &registry,
673            &entry(SkipReason::ConfigError("bad json".into())),
674        );
675        assert_eq!(broken["state"], "config_error");
676        assert_eq!(broken["error"], "bad json");
677
678        // Absent, not null — the same shape rule `message` follows in the run document.
679        let healthy = repo_value(&registry, &entry(SkipReason::Candidate));
680        assert!(healthy.get("error").is_none());
681    }
682
683    #[test]
684    fn run_summary_counts_only_real_deletions() {
685        let doc = run_document(
686            &[
687                result(PruneStatus::Pruned, 100),
688                result(PruneStatus::Pruned, 50),
689                result(PruneStatus::SkippedActive, 0),
690                result(PruneStatus::LockfileError("nope".into()), 0),
691            ],
692            false,
693        );
694        assert_eq!(doc["summary"]["bytes_freed"], 150);
695        assert_eq!(doc["summary"]["directories_pruned"], 2);
696        assert_eq!(doc["summary"]["errors"], 1);
697    }
698
699    #[test]
700    fn dry_run_bytes_land_in_reclaimable_not_freed() {
701        // A dry run must never claim to have freed anything — a CI step that adds up
702        // `bytes_freed` across runs would otherwise report space that still exists.
703        let doc = run_document(&[result(PruneStatus::SkippedDryRun, 4096)], true);
704        assert_eq!(doc["summary"]["bytes_freed"], 0);
705        assert_eq!(doc["summary"]["bytes_reclaimable"], 4096);
706        assert_eq!(doc["dry_run"], true);
707    }
708
709    #[test]
710    fn lockfile_errors_carry_the_fix_command() {
711        let doc = run_document(
712            &[result(PruneStatus::LockfileError("boom".into()), 0)],
713            false,
714        );
715        assert_eq!(doc["results"][0]["message"], "boom");
716        assert_eq!(
717            doc["results"][0]["fix_command"],
718            "pnpm install --lockfile-only"
719        );
720    }
721
722    #[test]
723    fn a_successful_result_carries_no_message_or_fix() {
724        let doc = run_document(&[result(PruneStatus::Pruned, 1)], false);
725        assert!(doc["results"][0].get("message").is_none());
726        assert!(doc["results"][0].get("fix_command").is_none());
727    }
728
729    #[test]
730    fn venv_has_no_mechanical_lockfile_fix() {
731        // There is no command that writes a requirements.txt, so offering one would be
732        // a lie an agent would then run.
733        assert!(lockfile_fix_command("venv").is_none());
734        assert!(lockfile_fix_command("nonsense").is_none());
735    }
736
737    #[test]
738    fn the_cache_report_totals_what_it_lists() {
739        use crate::commands::caches::{CacheReport, Clear};
740
741        let doc = caches_document(&[
742            CacheReport {
743                manager: "go",
744                kind: "module cache",
745                path: PathBuf::from("/home/dev/go/pkg/mod"),
746                bytes: 4_000,
747                clear_command: "go clean -modcache",
748                clear: Clear::Command("go", &["clean", "-modcache"]),
749                note: None,
750            },
751            CacheReport {
752                manager: "pnpm",
753                kind: "store",
754                path: PathBuf::from("/home/dev/.pnpm-store"),
755                bytes: 1_000,
756                clear_command: "pnpm store prune",
757                clear: Clear::Command("pnpm", &["store", "prune"]),
758                note: Some("hardlinked"),
759            },
760        ]);
761
762        assert_eq!(doc["command"], "caches");
763        assert_eq!(doc["summary"]["total_bytes"], 5_000);
764        assert_eq!(doc["summary"]["count"], 2);
765        // Absent rather than null where there is nothing to say, matching every other
766        // optional field in this contract.
767        assert!(doc["caches"][0].get("note").is_none());
768        assert_eq!(doc["caches"][1]["note"], "hardlinked");
769        assert_eq!(doc["caches"][0]["clear_command"], "go clean -modcache");
770    }
771
772    #[test]
773    fn an_empty_cache_report_is_still_a_document() {
774        // A machine with no package manager installed must produce a parseable zero, not
775        // an absent `summary` a consumer would have to special-case.
776        let doc = caches_document(&[]);
777        assert_eq!(doc["summary"]["total_bytes"], 0);
778        assert_eq!(doc["caches"].as_array().unwrap().len(), 0);
779    }
780
781    #[test]
782    fn every_adapter_with_a_lockfile_has_a_fix_command() {
783        for adapter in crate::adapters::get_all_adapters() {
784            // venv, gradle, maven and swift verify without a lockfile-sync step — see
785            // `lockfile_fix_command` for why each has nothing mechanical to hand over.
786            if matches!(adapter.name(), "venv" | "gradle" | "maven" | "swift") {
787                continue;
788            }
789            assert!(
790                lockfile_fix_command(adapter.name()).is_some(),
791                "{} has no fix command",
792                adapter.name()
793            );
794        }
795    }
796}