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        "cargo" => "cargo generate-lockfile",
83        "go" => "go mod tidy",
84        // venv has no lockfile to regenerate — the fix is to write `requirements.txt`,
85        // which is authoring work, not a command we can hand over. gradle and maven
86        // verify manifest presence, not lockfile sync — a missing manifest has no
87        // mechanical fix either.
88        _ => return None,
89    })
90}
91
92fn result_value(result: &PruneResult) -> Value {
93    let mut obj = json!({
94        "repository": clean_path(&result.repo_path),
95        "adapter": result.adapter_name,
96        "directory": result.bloat_dir,
97        "status": status_tag(&result.status),
98        "bytes": result.size_freed,
99        "shared_bytes": result.shared_bytes,
100    });
101
102    if let Some(message) = status_message(&result.status) {
103        obj["message"] = json!(message);
104    }
105    if matches!(result.status, PruneStatus::LockfileError(_))
106        && let Some(fix) = lockfile_fix_command(&result.adapter_name)
107    {
108        obj["fix_command"] = json!(fix);
109    }
110    obj
111}
112
113/// The document emitted by `devp run --json`.
114///
115/// `summary.errors` counts results whose status is one of the four failure tags; a
116/// consumer that only wants to know "did anything go wrong" can read that alone.
117pub fn run_document(results: &[PruneResult], dry_run: bool) -> Value {
118    let bytes_freed: u64 = results
119        .iter()
120        .filter(|r| matches!(r.status, PruneStatus::Pruned))
121        .map(|r| r.size_freed)
122        .sum();
123    let directories_pruned = results
124        .iter()
125        .filter(|r| matches!(r.status, PruneStatus::Pruned))
126        .count();
127    let bytes_reclaimable: u64 = results
128        .iter()
129        .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
130        .map(|r| r.size_freed)
131        .sum();
132    let errors = results
133        .iter()
134        .filter(|r| {
135            matches!(
136                r.status,
137                PruneStatus::LockfileError(_)
138                    | PruneStatus::ActivityCheckError(_)
139                    | PruneStatus::DeleteError(_)
140                    | PruneStatus::ConfigError(_)
141            )
142        })
143        .count();
144
145    json!({
146        "schema": SCHEMA_VERSION,
147        "version": constants::VERSION,
148        "command": "run",
149        "dry_run": dry_run,
150        "results": results.iter().map(result_value).collect::<Vec<_>>(),
151        "summary": {
152            "bytes_freed": bytes_freed,
153            "bytes_reclaimable": bytes_reclaimable,
154            "directories_pruned": directories_pruned,
155            "errors": errors,
156        },
157    })
158}
159
160/// The stable machine name for why a repository is or is not a candidate.
161fn reason_tag(reason: &SkipReason) -> &'static str {
162    match reason {
163        SkipReason::Candidate => "candidate",
164        SkipReason::Active => "active",
165        SkipReason::Ignored => "ignored",
166        SkipReason::NoBloat => "no_bloat",
167        SkipReason::PathMissing => "path_missing",
168        SkipReason::ConfigError(_) => "config_error",
169    }
170}
171
172fn settings_value(settings: &Settings) -> Value {
173    json!({
174        "idle_days": settings.idle_days,
175        "check_interval_days": settings.check_interval_days,
176        "auto_setup": settings.auto_setup,
177        "auto_hooks": settings.auto_hooks,
178        "auto_daemon": settings.auto_daemon,
179        "require_confirmation": settings.require_confirmation,
180        "command_timeout_secs": settings.command_timeout_secs,
181        "min_size_mb": settings.min_size_mb,
182        "update_check": settings.update_check,
183    })
184}
185
186fn repo_value(entry: &RepoStatusEntry) -> Value {
187    let mut obj = json!({
188        "path": clean_path(&entry.path),
189        "state": reason_tag(&entry.reason),
190        "enabled": entry.entry.enabled,
191        "idle_days": entry.idle_days,
192        "last_activity": entry.last_activity.map(|t| t.to_rfc3339()),
193        "last_pruned_at": entry.entry.last_pruned_at.map(|t| t.to_rfc3339()),
194        "bytes_freed": entry.entry.total_freed_bytes,
195        "added_at": entry.entry.added_at.to_rfc3339(),
196        "adapters": entry.adapters,
197        "reclaimable_bytes": entry.reclaimable_bytes,
198        "directories": entry.bloat_dirs.iter().map(|b| json!({
199            "name": b.name,
200            "path": clean_path(&b.path),
201            "bytes": b.size_bytes,
202            "shared_bytes": b.shared_bytes,
203        })).collect::<Vec<_>>(),
204    });
205
206    // Present only on `config_error`, and absent rather than null everywhere else — the
207    // same rule `result_value` follows for `message`, so one parser handles both
208    // documents. It carries the actual parse failure, so an agent can report what is
209    // wrong with the file instead of only the state word.
210    if let SkipReason::ConfigError(e) = &entry.reason {
211        obj["error"] = json!(e);
212    }
213    obj
214}
215
216/// The document emitted by `devp status --json`.
217///
218/// `daemon` and `hooks` are the same strings the dashboard shows; they describe the
219/// state of the machine's integrations, which is what an agent needs to decide whether
220/// to suggest `devp setup`.
221///
222/// `top` trims the `repositories` array only. `totals` is always computed over every
223/// registered repository, and `top` is echoed back so a consumer can tell a short list
224/// from a tidy machine.
225pub fn status_document(
226    registry: &Registry,
227    repos: &[RepoStatusEntry],
228    daemon: &str,
229    hooks: &str,
230    top: Option<usize>,
231) -> Value {
232    let reclaimable: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
233    let candidates = repos
234        .iter()
235        .filter(|r| matches!(r.reason, SkipReason::Candidate))
236        .count();
237    let listed = crate::engine::take_top(repos, top);
238
239    let mut doc = json!({
240        "schema": SCHEMA_VERSION,
241        "version": constants::VERSION,
242        "command": "status",
243        "config_path": Registry::registry_path().map(|p| clean_path(&p)).ok(),
244        "integrations": { "daemon": daemon, "git_hooks": hooks },
245        "settings": settings_value(&registry.settings),
246        "totals": {
247            "repositories": registry.repo_count(),
248            "candidates": candidates,
249            "reclaimable_bytes": reclaimable,
250            "historical_bytes_freed": registry.total_freed_bytes,
251            "prune_passes": registry.total_pruned_count,
252        },
253        "repositories": listed.iter().map(repo_value).collect::<Vec<_>>(),
254    });
255
256    // Absent rather than null when the whole list is present, the same rule `message`
257    // and `note` follow elsewhere in this contract.
258    if let Some(n) = top {
259        doc["top"] = json!(n);
260    }
261    doc
262}
263
264/// The document emitted by `devp stats --json`.
265///
266/// Three different vintages of number live here, and the field names say which is which.
267/// `lifetime` has been accumulating since 1.0.0. `recent_passes` and the `bytes_freed`
268/// inside `repositories` are only recorded from 1.1.0 onward, so on an upgraded machine
269/// they start near zero while `lifetime` does not — `history_starts_at` names the version
270/// that changed, so a consumer can say so rather than reporting a regression.
271pub fn stats_document(registry: &Registry) -> Value {
272    let mut repos: Vec<(&std::path::PathBuf, &crate::config::RepoEntry)> =
273        registry.repositories.iter().collect();
274    repos.sort_by(|a, b| {
275        b.1.total_freed_bytes
276            .cmp(&a.1.total_freed_bytes)
277            .then_with(|| a.0.cmp(b.0))
278    });
279
280    json!({
281        "schema": SCHEMA_VERSION,
282        "version": constants::VERSION,
283        "command": "stats",
284        "history_starts_at": constants::HISTORY_STARTS_AT,
285        "lifetime": {
286            "bytes_freed": registry.total_freed_bytes,
287            // Same name and same number as `totals.prune_passes` in the status document.
288            // One per pass that deleted something, wherever it was started from.
289            "prune_passes": registry.total_pruned_count,
290            "repositories": registry.repo_count(),
291        },
292        "last_prune": registry.last_prune.as_ref().map(|p| json!({
293            "at": p.at.to_rfc3339(),
294            "bytes_freed": p.dirs.iter().map(|d| d.size_freed).sum::<u64>(),
295            "directories": p.dirs.len(),
296        })),
297        "recent_passes": registry.prune_history.iter().rev().map(|p| json!({
298            "at": p.at.to_rfc3339(),
299            "bytes_freed": p.bytes_freed,
300            "directories": p.dirs_removed,
301            "repositories": p.repos_touched,
302        })).collect::<Vec<_>>(),
303        "repositories": repos.iter().map(|(path, entry)| json!({
304            "path": clean_path(path),
305            "bytes_freed": entry.total_freed_bytes,
306            "last_pruned_at": entry.last_pruned_at.map(|t| t.to_rfc3339()),
307        })).collect::<Vec<_>>(),
308    })
309}
310
311/// The document emitted by `devp caches --json`.
312///
313/// `clear_command` is the one field an agent can act on, and it is the only place in this
314/// contract that carries a command dev-prune will not run itself: these caches are shared
315/// by every project on the machine, so clearing one is a human's decision. `note` is
316/// present only where there is a cost beyond time.
317pub fn caches_document(reports: &[crate::commands::caches::CacheReport]) -> Value {
318    let total: u64 = reports.iter().map(|r| r.bytes).sum();
319
320    let caches: Vec<Value> = reports
321        .iter()
322        .map(|r| {
323            let mut obj = json!({
324                "manager": r.manager,
325                "kind": r.kind,
326                "path": clean_path(&r.path),
327                "bytes": r.bytes,
328                "clear_command": r.clear_command,
329            });
330            if let Some(note) = r.note {
331                obj["note"] = json!(note);
332            }
333            obj
334        })
335        .collect();
336
337    json!({
338        "schema": SCHEMA_VERSION,
339        "version": constants::VERSION,
340        "command": "caches",
341        "caches": caches,
342        "summary": {
343            "total_bytes": total,
344            "count": reports.len(),
345        },
346    })
347}
348
349/// The document emitted by `devp status --drift --json`.
350///
351/// A separate document from plain `status` because it answers a different question:
352/// not "what could a prune reclaim" but "what would a prune refuse, and why". An empty
353/// `drift` array means nothing was *detected*, across the adapters that can compare an
354/// environment against its lockfile from files alone.
355pub fn drift_document(findings: &[crate::commands::status::ProjectDrift]) -> Value {
356    let unrecorded_total: usize = findings.iter().map(|f| f.report.unrecorded.len()).sum();
357
358    json!({
359        "schema": SCHEMA_VERSION,
360        "version": constants::VERSION,
361        "command": "status --drift",
362        "drift": findings.iter().map(|f| json!({
363            "repository": clean_path(&f.repository),
364            "project": f.project,
365            "adapter": f.adapter,
366            "directory": f.report.directory,
367            "unrecorded": f.report.unrecorded,
368            "record_command": f.report.record_command,
369        })).collect::<Vec<_>>(),
370        "summary": {
371            "projects_with_drift": findings.len(),
372            "unrecorded_packages": unrecorded_total,
373        },
374    })
375}
376
377/// Print a document to stdout as pretty JSON with a trailing newline.
378///
379/// Pretty rather than compact because a human reads this output far more often than a
380/// parser does, and `jq` does not care either way.
381///
382/// When stdout is a terminal, the same document also lands on the clipboard: a pipe or
383/// a redirect means a program is consuming the output, but a terminal means a *person*
384/// asked for JSON, and the next thing they usually do is paste it somewhere. The
385/// notice goes to stderr and the copy is skipped entirely when piped, so the stdout
386/// contract — one document, byte-identical either way — holds.
387pub fn emit(document: &Value) -> anyhow::Result<()> {
388    use std::io::IsTerminal;
389    let text = serde_json::to_string_pretty(document)?;
390    println!("{text}");
391    if std::io::stdout().is_terminal() && copy_to_clipboard(&text) {
392        use colored::Colorize;
393        eprintln!("{}", "(also copied to your clipboard)".dimmed());
394    }
395    Ok(())
396}
397
398/// Best-effort: put `text` on the system clipboard. Returns whether it worked.
399///
400/// Spawns the platform's own clipboard tool rather than linking a clipboard crate — a
401/// native dependency is a heavy price for a nicety. `clip` on Windows, `pbcopy` on
402/// macOS, then `wl-copy`/`xclip`/`xsel` in that order on Linux; a headless box has
403/// none of them, and quietly not copying is the right behaviour there.
404fn copy_to_clipboard(text: &str) -> bool {
405    // `clip.exe` reads its input in the console codepage unless a BOM says otherwise;
406    // UTF-16LE with a BOM is the one encoding it always honours, and repository paths
407    // are not guaranteed to be ASCII.
408    let bytes: Vec<u8> = if cfg!(windows) {
409        let mut utf16 = vec![0xFF, 0xFE];
410        for unit in text.encode_utf16() {
411            utf16.extend_from_slice(&unit.to_le_bytes());
412        }
413        utf16
414    } else {
415        text.as_bytes().to_vec()
416    };
417
418    // On Windows the tool is named by full path: `CreateProcess` resolves a bare
419    // program name through the *current directory* before PATH, and dev-prune is
420    // routinely run from inside checkouts it has no reason to trust — a repository
421    // carrying its own `clip.exe` must not become the thing that executes. Unix PATH
422    // search never consults the current directory, so the bare names there are fine.
423    let windows_clip = std::env::var("SystemRoot")
424        .map(|root| format!("{root}\\System32\\clip.exe"))
425        .unwrap_or_else(|_| String::from("C:\\Windows\\System32\\clip.exe"));
426    let tools: Vec<Vec<&str>> = if cfg!(windows) {
427        vec![vec![windows_clip.as_str()]]
428    } else if cfg!(target_os = "macos") {
429        vec![vec!["pbcopy"]]
430    } else {
431        vec![
432            vec!["wl-copy"],
433            vec!["xclip", "-selection", "clipboard"],
434            vec!["xsel", "--clipboard", "--input"],
435        ]
436    };
437    tools.iter().any(|tool| pipe_into(tool, &bytes))
438}
439
440/// Run `command`, feed `bytes` to its stdin, and report whether it exited cleanly.
441fn pipe_into(command: &[&str], bytes: &[u8]) -> bool {
442    use std::io::Write;
443    use std::process::Stdio;
444    let Ok(mut child) = crate::spawn::command(command[0])
445        .args(&command[1..])
446        .stdin(Stdio::piped())
447        .stdout(Stdio::null())
448        .stderr(Stdio::null())
449        .spawn()
450    else {
451        return false;
452    };
453    let wrote = child
454        .stdin
455        .take()
456        .is_some_and(|mut stdin| stdin.write_all(bytes).is_ok());
457    let exited_cleanly = child.wait().map(|status| status.success()).unwrap_or(false);
458    wrote && exited_cleanly
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use std::path::PathBuf;
465
466    fn result(status: PruneStatus, bytes: u64) -> PruneResult {
467        PruneResult {
468            repo_path: PathBuf::from("/tmp/repo"),
469            adapter_name: "pnpm".to_string(),
470            bloat_dir: "node_modules".to_string(),
471            size_freed: bytes,
472            shared_bytes: 0,
473            status,
474        }
475    }
476
477    #[test]
478    fn every_status_has_a_distinct_stable_tag() {
479        let all = [
480            PruneStatus::Pruned,
481            PruneStatus::SkippedActive,
482            PruneStatus::SkippedDryRun,
483            PruneStatus::LockfileError("x".into()),
484            PruneStatus::ActivityCheckError("x".into()),
485            PruneStatus::PathMissing,
486            PruneStatus::NoBloat,
487            PruneStatus::Disabled,
488            PruneStatus::SkippedIgnored,
489            PruneStatus::DeleteError("x".into()),
490            PruneStatus::ConfigError("x".into()),
491            PruneStatus::SkippedSymlink("x".into()),
492        ];
493        let mut tags: Vec<&str> = all.iter().map(status_tag).collect();
494        let count = tags.len();
495        tags.sort_unstable();
496        tags.dedup();
497        assert_eq!(tags.len(), count, "two statuses share a JSON tag");
498    }
499
500    #[test]
501    fn every_repository_state_has_a_distinct_stable_tag() {
502        let all = [
503            SkipReason::Candidate,
504            SkipReason::Active,
505            SkipReason::Ignored,
506            SkipReason::NoBloat,
507            SkipReason::PathMissing,
508            SkipReason::ConfigError("x".into()),
509        ];
510        let mut tags: Vec<&str> = all.iter().map(reason_tag).collect();
511        let count = tags.len();
512        tags.sort_unstable();
513        tags.dedup();
514        assert_eq!(tags.len(), count, "two repository states share a JSON tag");
515    }
516
517    #[test]
518    fn only_an_unreadable_config_carries_an_error_field() {
519        let entry = |reason| RepoStatusEntry {
520            path: PathBuf::from("/tmp/repo"),
521            entry: crate::config::RepoEntry::new(),
522            reason,
523            adapters: Vec::new(),
524            bloat_dirs: Vec::new(),
525            reclaimable_bytes: 0,
526            last_activity: None,
527            idle_days: 15,
528        };
529
530        let broken = repo_value(&entry(SkipReason::ConfigError("bad json".into())));
531        assert_eq!(broken["state"], "config_error");
532        assert_eq!(broken["error"], "bad json");
533
534        // Absent, not null — the same shape rule `message` follows in the run document.
535        let healthy = repo_value(&entry(SkipReason::Candidate));
536        assert!(healthy.get("error").is_none());
537    }
538
539    #[test]
540    fn run_summary_counts_only_real_deletions() {
541        let doc = run_document(
542            &[
543                result(PruneStatus::Pruned, 100),
544                result(PruneStatus::Pruned, 50),
545                result(PruneStatus::SkippedActive, 0),
546                result(PruneStatus::LockfileError("nope".into()), 0),
547            ],
548            false,
549        );
550        assert_eq!(doc["summary"]["bytes_freed"], 150);
551        assert_eq!(doc["summary"]["directories_pruned"], 2);
552        assert_eq!(doc["summary"]["errors"], 1);
553    }
554
555    #[test]
556    fn dry_run_bytes_land_in_reclaimable_not_freed() {
557        // A dry run must never claim to have freed anything — a CI step that adds up
558        // `bytes_freed` across runs would otherwise report space that still exists.
559        let doc = run_document(&[result(PruneStatus::SkippedDryRun, 4096)], true);
560        assert_eq!(doc["summary"]["bytes_freed"], 0);
561        assert_eq!(doc["summary"]["bytes_reclaimable"], 4096);
562        assert_eq!(doc["dry_run"], true);
563    }
564
565    #[test]
566    fn lockfile_errors_carry_the_fix_command() {
567        let doc = run_document(
568            &[result(PruneStatus::LockfileError("boom".into()), 0)],
569            false,
570        );
571        assert_eq!(doc["results"][0]["message"], "boom");
572        assert_eq!(
573            doc["results"][0]["fix_command"],
574            "pnpm install --lockfile-only"
575        );
576    }
577
578    #[test]
579    fn a_successful_result_carries_no_message_or_fix() {
580        let doc = run_document(&[result(PruneStatus::Pruned, 1)], false);
581        assert!(doc["results"][0].get("message").is_none());
582        assert!(doc["results"][0].get("fix_command").is_none());
583    }
584
585    #[test]
586    fn venv_has_no_mechanical_lockfile_fix() {
587        // There is no command that writes a requirements.txt, so offering one would be
588        // a lie an agent would then run.
589        assert!(lockfile_fix_command("venv").is_none());
590        assert!(lockfile_fix_command("nonsense").is_none());
591    }
592
593    #[test]
594    fn the_cache_report_totals_what_it_lists() {
595        use crate::commands::caches::CacheReport;
596
597        let doc = caches_document(&[
598            CacheReport {
599                manager: "go",
600                kind: "module cache",
601                path: PathBuf::from("/home/dev/go/pkg/mod"),
602                bytes: 4_000,
603                clear_command: "go clean -modcache",
604                note: None,
605            },
606            CacheReport {
607                manager: "pnpm",
608                kind: "store",
609                path: PathBuf::from("/home/dev/.pnpm-store"),
610                bytes: 1_000,
611                clear_command: "pnpm store prune",
612                note: Some("hardlinked"),
613            },
614        ]);
615
616        assert_eq!(doc["command"], "caches");
617        assert_eq!(doc["summary"]["total_bytes"], 5_000);
618        assert_eq!(doc["summary"]["count"], 2);
619        // Absent rather than null where there is nothing to say, matching every other
620        // optional field in this contract.
621        assert!(doc["caches"][0].get("note").is_none());
622        assert_eq!(doc["caches"][1]["note"], "hardlinked");
623        assert_eq!(doc["caches"][0]["clear_command"], "go clean -modcache");
624    }
625
626    #[test]
627    fn an_empty_cache_report_is_still_a_document() {
628        // A machine with no package manager installed must produce a parseable zero, not
629        // an absent `summary` a consumer would have to special-case.
630        let doc = caches_document(&[]);
631        assert_eq!(doc["summary"]["total_bytes"], 0);
632        assert_eq!(doc["caches"].as_array().unwrap().len(), 0);
633    }
634
635    #[test]
636    fn every_adapter_with_a_lockfile_has_a_fix_command() {
637        for adapter in crate::adapters::get_all_adapters() {
638            // venv, gradle and maven verify without a lockfile-sync step — see
639            // `lockfile_fix_command` for why each has nothing mechanical to hand over.
640            if matches!(adapter.name(), "venv" | "gradle" | "maven") {
641                continue;
642            }
643            assert!(
644                lockfile_fix_command(adapter.name()).is_some(),
645                "{} has no fix command",
646                adapter.name()
647            );
648        }
649    }
650}