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