dev-prune 1.22.0

Universal, lockfile-safe workspace pruner and background dependency cleaner
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
# How to Add a New Package Manager Adapter to `dev-prune`

`dev-prune` (`devp`) is designed to be highly modular and contribution-friendly. Adding support for a new package manager or ecosystem (e.g. Composer, Bundler, CocoaPods, Mix, Swift SPM, etc.) takes only a few minutes.

There are three things you can add, and all three are one table entry:

| What | Where | What it gives you |
|---|---|---|
| **An adapter** — a per-repository ecosystem | `src/adapters/` | `devp run` deletes that ecosystem's directories and can prove the lockfile rebuilds them. [Steps 1–7 below.]#step-1-create-a-new-module-file |
| **A cache probe** — a machine-wide store | `src/commands/caches.rs` | A row in `devp caches` and a working `devp caches clear <name>`. [How to add one.]#-adding-a-cache-probe |
| **A container engine** | `src/commands/containers.rs` | A report in `devp caches containers` and a working `devp caches clear <engine>`. [How to add one.]#-adding-a-container-engine |
| **A rebuild check** — for declared directories | beside the adapter, in `src/adapters/` | A declared `rebuild` command using your tool is verified against the manifest it would read, instead of only against `PATH`. [How to add one.]#-adding-a-rebuild-check |

They are independent. An ecosystem can have an adapter and no probe (its cache is
per-project, or it has none), a probe and no adapter (`pip`, `playwright`,
`huggingface` — nothing on disk to prune per-repository), or both.

---

<p align="center">
  <img src="../assets/github-readme-banner.png" alt="dev-prune — gigabytes back, nothing you can't rebuild" width="800" />
</p>

---

## 🏛️ Architecture Overview

All adapters live in [`src/adapters/`](../src/adapters/) and implement the [`PackageManager`](../src/adapters/mod.rs) Rust trait:

```rust
pub trait PackageManager: Send + Sync {
    /// Unique human-readable name of the adapter (e.g. "npm", "uv", "cargo", "go").
    fn name(&self) -> &'static str;

    /// Check if this adapter applies to a given project path.
    fn detect(&self, project_path: &Path) -> bool;

    /// List existing bloat directories managed by this adapter.
    fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir>;

    /// Lockfile safety check: MUST prove the lockfile can rebuild the tree BEFORE any
    /// bloat directory is deleted. If this returns an Err, pruning for this adapter is
    /// ABORTED. `policy` carries the user's `allow_manifest_rewrite` and
    /// `command_timeout_secs`; hand it straight to `enforce_two_tier`.
    fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()>;

    /// Restore dependencies from lockfile (used by `devp restore`). `timeout` is the
    /// user's `command_timeout_secs`, threaded explicitly.
    fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()>;

    /// `restore`, told the name the pruned directory had and the runtime tag recorded
    /// when it was deleted (`None` when nothing was recorded). Has a default impl that
    /// ignores both and calls `restore`; override it only if your manager can rebuild
    /// under more than one directory name (venv does — `venv`, `env`, ...).
    fn restore_named(
        &self,
        project_path: &Path,
        dir_name: &str,
        runtime: Option<&str>,
        timeout: std::time::Duration,
    ) -> Result<()> { let _ = (dir_name, runtime); self.restore(project_path, timeout) }

    /// The language runtime `dir_name` was built against, recorded at prune time and
    /// handed back through `restore_named`. Defaults to `None`; only the Python
    /// managers answer it, because only their directories embed one interpreter.
    fn runtime_tag(&self, project_path: &Path, dir_name: &str) -> Option<String> {
        let _ = (project_path, dir_name);
        None
    }

    /// Installed-but-unrecorded packages, surfaced by `devp status --drift` before a
    /// prune is attempted. Defaults to empty — "nothing detected", not "proven clean".
    fn drift(&self, project_path: &Path) -> Vec<DriftReport> {
        let _ = project_path;
        Vec::new()
    }

    /// Lockfiles that identify this manager. Only needed if your adapter can
    /// share a bloat directory with another one. Defaults to an empty slice.
    fn lockfiles(&self) -> &'static [&'static str] { &[] }

    /// Whether this adapter must be switched on explicitly before it detects
    /// anything. Defaults to `false`. Return `true` when what you delete is a
    /// build tree that has to be *recompiled* back rather than re-downloaded —
    /// cargo, gradle, maven and swift do — and add a matching `enable_<name>` setting in
    /// `src/commands/config.rs`. While the setting is off, the adapter is
    /// invisible: not detected, not listed, and `--only <name>` prunes nothing.
    /// Opt-in adapters are also idle-gated by `build_idle_days`, applied as
    /// `max(build_idle_days, idle_days)`.
    fn opt_in(&self) -> bool { false }
}
```

Several adapters detecting in the same directory is normal and fully supported — a
directory with `package-lock.json`, `uv.lock` and `Cargo.toml` runs three adapters,
each owning a different bloat directory. Only adapters that would fight over the
**same** directory need resolving; see [Step 6](#step-6-if-your-adapter-shares-a-bloat-directory).

---

## 🛠️ Step-by-Step Implementation Tutorial

> The worked example below is Gradle — which has since shipped as a real (opt-in)
> adapter, built by following exactly these steps. The finished version is
> [`src/adapters/gradle.rs`]../src/adapters/gradle.rs; compare against it as you go.

### Step 1: Create a new module file
Create a new Rust file in `src/adapters/` named after your package manager, e.g. `src/adapters/gradle.rs`.

### Step 2: Implement the `PackageManager` trait

Here is a complete, production-ready example for a Gradle adapter:

```rust
// Gradle adapter for dev-prune.

use std::path::Path;
use anyhow::Result;
use super::{
    PackageManager, BloatDir, EnforcePolicy, dir_size, enforce_two_tier,
    run_command_with_timeout,
};

/// Adapter for Gradle projects.
pub struct Gradle;

impl PackageManager for Gradle {
    fn name(&self) -> &'static str {
        "gradle"
    }

    fn detect(&self, project_path: &Path) -> bool {
        project_path.join("build.gradle").exists()
            || project_path.join("build.gradle.kts").exists()
    }

    fn bloat_dirs(&self, project_path: &Path) -> Vec<BloatDir> {
        let mut dirs = Vec::new();
        let build_dir = project_path.join("build");
        let gradle_dir = project_path.join(".gradle");

        if build_dir.is_dir() {
            dirs.push(BloatDir {
                name: "build".to_string(),
                path: build_dir.clone(),
                size_bytes: dir_size(&build_dir),
                shared_bytes: 0,
            });
        }
        if gradle_dir.is_dir() {
            dirs.push(BloatDir {
                name: ".gradle".to_string(),
                path: gradle_dir.clone(),
                size_bytes: dir_size(&gradle_dir),
                shared_bytes: 0,
            });
        }
        dirs
    }

    /// Two commands, and the order matters: the read-only one first, the writing one
    /// second. `gradle dependencies` resolves against the existing lock state and fails
    /// if it has drifted; `--write-locks` *rewrites* `gradle.lockfile`, so it is only
    /// reached when there is no lockfile to preserve or the user opted in.
    fn enforce_lockfile(&self, project_path: &Path, policy: EnforcePolicy) -> Result<()> {
        let lockfile = project_path.join("gradle.lockfile");
        enforce_two_tier(
            &lockfile,
            "gradle",
            &["dependencies"],
            &["dependencies", "--write-locks"],
            project_path,
            policy,
        )
    }

    fn restore(&self, project_path: &Path, timeout: std::time::Duration) -> Result<()> {
        run_command_with_timeout("gradle", &["build", "-x", "test"], project_path, timeout)
    }
}
```

---

### Step 3: Available Adapter Helper Functions

`src/adapters/mod.rs` provides shared utility functions for adapter authors:

- **`enforce_two_tier(lockfile, program, verify_args, write_args, cwd, policy)`**: the one
  helper every shipped adapter uses. It picks between your two command forms:
  - `policy.allow_rewrite` is set → run `write_args`; the user asked for repairs.
  - Otherwise, binary installed and `lockfile` exists → run `verify_args`. **Read-only.**
  - Binary installed, no lockfile → run `write_args`; there is nothing to preserve.
  - Binary missing but `lockfile` exists → proceed; the lockfile is itself the proof.
  - Binary missing and no lockfile → error, and deletion is aborted.

  Everything runs under `policy.timeout`, which is the user's `command_timeout_secs`.
- **`lock_verify_or_generate(...)` / `lock_sync_or_verify_with_timeout(...)`**: the two
  shapes `enforce_two_tier` dispatches to. Call them directly only if your ecosystem
  genuinely does not fit — bun does, because it has no writing form to opt into.
- **`run_command_with_timeout(program, args, cwd, timeout)`**: Executes an external
  command bounded by `timeout` — pass the `timeout` your `restore` was handed, or
  `policy.timeout` inside `enforce_lockfile`.
- **`capture_command_with_timeout(program, args, cwd, timeout)`**: Same, but returns the
  command's stdout — for commands that answer a question instead of doing work.
- **`try_run_command(program, args, cwd)`**: Executes command returning boolean `true`/`false`.
- **`binary_available(program)`**: Whether `program` is on `PATH`.
- **`dir_size(path)`**: Calculates directory size in bytes recursively.
- **`dir_size_with_hardlinks(path)`**: Splits that size into `freed_bytes` (what deleting
  the tree gives back) and `shared_bytes` (hardlinked from outside — a store keeps them).
  Use it if your package manager links files out of a global store instead of copying,
  the way pnpm and bun do, and carry both halves into the `BloatDir`. A manager that
  copies sets `shared_bytes: 0` and uses plain `dir_size`.

> **Rule for `enforce_lockfile`: resolve, never install, never write.**
> The command runs as a precondition for *deleting* the tree, so it must not download
> dependencies or execute their lifecycle scripts, and — this is the part contributors
> get wrong — `verify_args` must not rewrite the lockfile either. A prune pass can be
> started by the OS scheduler; a check that "helpfully" resyncs a drifted lockfile leaves
> a modified tracked file behind with nobody watching. Prefer a frozen, locked or dry-run
> mode (`--frozen-lockfile`, `--immutable`, `--locked`, `--dry-run`). If your ecosystem
> has no such mode, treat an existing lockfile as sufficient proof of recoverability
> rather than running a full install — that is what the Yarn Classic path does.
>
> Pair it with a `write_args` that *does* repair the lockfile. That is what runs when
> there is no lockfile at all, what `allow_manifest_rewrite` opts into, and what
> [`json::lockfile_fix_command`]../src/json.rs should print for your adapter so the
> user can run it themselves.

---

### Step 4: Write comprehensive unit tests

Every adapter must contain unit tests using `tempfile::TempDir` verifying:
1. Detection logic (positive and negative cases)
2. Bloat directory scanning
3. Adapter name uniqueness

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn test_gradle_name() {
        assert_eq!(Gradle.name(), "gradle");
    }

    #[test]
    fn test_gradle_detect_positive() {
        let tmp = TempDir::new().unwrap();
        fs::write(tmp.path().join("build.gradle"), "// gradle").unwrap();
        assert!(Gradle.detect(tmp.path()));
    }

    #[test]
    fn test_gradle_detect_negative() {
        let tmp = TempDir::new().unwrap();
        assert!(!Gradle.detect(tmp.path()));
    }

    #[test]
    fn test_gradle_bloat_dirs_present() {
        let tmp = TempDir::new().unwrap();
        let build = tmp.path().join("build");
        fs::create_dir(&build).unwrap();
        fs::write(build.join("output.jar"), "sample binary data").unwrap();

        let dirs = Gradle.bloat_dirs(tmp.path());
        assert_eq!(dirs.len(), 1);
        assert_eq!(dirs[0].name, "build");
    }
}
```

---

### Step 5: Register your adapter in `src/adapters/mod.rs`

1. Add `pub mod gradle;` to top of `src/adapters/mod.rs`
2. Add `Box::new(gradle::Gradle)` inside `get_all_adapters()`:

```rust
pub fn get_all_adapters() -> Vec<Box<dyn PackageManager>> {
    vec![
        Box::new(npm::Npm),
        Box::new(pnpm::Pnpm),
        Box::new(yarn::Yarn),
        Box::new(bun::Bun),
        Box::new(uv::Uv),
        Box::new(poetry::Poetry),
        Box::new(venv::Venv),
        Box::new(cargo_adapter::Cargo),
        Box::new(go::Go),
        Box::new(gradle::Gradle),
        Box::new(maven::Maven),
    ]
}
```

---

### Step 5b: Put your adapter in a language group

`ADAPTER_GROUPS` in the same file is what `devp config wizard` draws its checklist
from, grouped by language so a heading can switch a whole ecosystem on, off, or onto
its own idle window in one keypress. Add your adapter to the group it belongs to, or
add a new group:

```rust
pub const ADAPTER_GROUPS: &[(&str, &[&str])] = &[
    ("JVM", &["gradle", "maven"]),
    // ...
];
```

`every_adapter_is_grouped_exactly_once` fails if you forget — an ungrouped adapter is
one a user cannot find in the only screen that lists them.

---

### Step 6: If your adapter shares a bloat directory

If your ecosystem's bloat directory is already owned by another adapter (as npm, pnpm,
yarn and bun all own `node_modules`), the two cannot both run: whichever loses would
rewrite a lockfile the project does not use. Implement `lockfiles()` so the tie can be
broken, and extend `resolve_conflicts()` in `src/adapters/mod.rs` with a rule for your
family. The existing rules are the model:

- **JavaScript** picks a winner from the `packageManager` field in `package.json`, then
  from the bookkeeping files the installer left inside `node_modules`, then from the
  newest lockfile mtime.
- **Python** gives uv and poetry priority over the plain-venv adapter, because they
  have a real lockfile and can reproduce the environment exactly; between uv and
  poetry, whichever one's lockfile is actually on disk owns the environment.

The principle in both: prefer whatever evidence describes the tree **actually on disk**
over evidence about what the project once used.

---

### Step 7: Verify with `cargo test`

Run tests to ensure everything builds cleanly and tests pass:

```bash
cargo test --all
cargo clippy -- -D warnings
cargo fmt -- --check
```

---

## 🔎 Adding a Rebuild Check

`project.devprune.json` lets a repository declare extra directories with the command
that rebuilds them. Before honouring one, `src/declared/` checks that the command's
tool is on `PATH`, and then, for tools it has a check for, that the *target* the
command names actually exists in the manifest the tool would read: `npm run build`
against the `scripts` table of `package.json`, `make generate` against the makefile's
targets, and so on. That second half is a rebuild check.

A check is one implementation of the `RebuildCheck` trait (in `src/declared/mod.rs`),
written beside your adapter so all of one tool's knowledge stays in one file, plus one
line in the `REBUILD_CHECKS` registry:

```rust
pub(crate) struct GradleTasks;

impl RebuildCheck for GradleTasks {
    fn tools(&self) -> &'static [&'static str] {
        &["gradle", "gradlew"]
    }

    fn gap(&self, repo_path: &Path, tool: &str, args: &[&str]) -> Option<Gap> {
        // Read the manifest. Never run anything.
    }
}
```

The contract is asymmetric on purpose, and it is the part to get right:

- Return `Some(Gap)` **only on positive proof**: the manifest is present, readable,
  and definitively does not provide what the command names.
- Everything unanswerable returns `None`, which **allows** the prune. A flag your
  check does not model, a manifest that is absent or unparseable, a shape you cannot
  read: all of those are "cannot tell", never "refuse". A false refusal blocks a safe
  prune on a committed file the user cannot easily debug, which is a worse bug than
  the one you are closing.
- Never execute the rebuild command or any part of it. Every check is a read.

Two registry tests in `src/declared/mod.rs` enforce the mechanical half of that
contract on every registered check automatically (no tool claimed twice; empty and
unmodelled arguments never refuse). Add your own tests beside them for the refusals
your check does make, pinning the exact wording.

Rebuild checks are optional and independent of the adapter itself: a tool with no
check simply stops at the `PATH` test, which is safe. `make` has a check and no
adapter at all; it lives in `src/declared/make.rs` because there is no adapter file to
sit beside.

---

## 💾 Adding a Cache Probe

A cache probe is the machine-wide half: the shared store a package manager downloads
into once and reinstalls from forever. It is what `devp caches` sizes and what
`devp caches clear <name>` empties.

Adding one is **one `PROBES` entry and one `fallbacks()` arm**. Everything else in the
subsystem derives from that table — `known_managers()`, `is_cache_manager()`, the
`<MANAGER>` list in the usage error, the `--over-cap` accounting and the `used_by`
column all read `PROBES` rather than a list of their own. There is no second place to
register a name, and no way to add one to half of them.

### Step 1: The `PROBES` entry

In `src/commands/caches.rs`:

```rust
Probe {
    manager: "poetry",
    kind: "artifact cache",
    // The manager's own answer to "where is it?". It must print a path and exit,
    // and it must not create the directory — a probe that installs something is
    // a probe that lies about the disk it was measuring.
    query: Some(("poetry", &["config", "cache-dir"])),
    // Appended to that answer, for the managers that will only name a directory
    // one level above their cache. Poetry's cache-dir holds `virtualenvs/` beside
    // `artifacts/`; only the second is a cache.
    query_suffix: Some("artifacts"),
    clear_command: POETRY_ARTIFACTS_CLEAR,
    clear: Clear::Directory,
    note: Some(
        "only `artifacts/`, the wheels and sdists Poetry downloaded. `virtualenvs/` \
         sits in the same cache directory and holds the environments themselves — \
         deleting that would not be clearing a cache.",
    ),
}
```

Three fields carry the judgement:

- **`query`** is the manager asked rather than assumed. `None` is correct when the
  ecosystem has no such command, and the conventional locations are all there is.
- **`query_suffix`** is where most of the care goes. `gem env gemdir` prints the gem
  home, which holds installed gems and binstubs as well as the `.gem` archives;
  `poetry config cache-dir` prints a parent of `virtualenvs/`. Sizing or clearing
  either answer whole would take working environments with it. Name the one
  subdirectory that is genuinely a download cache.
- **`clear`** is what dev-prune is willing to run. `Clear::Command(program, args)` when
  the manager has a real, non-interactive clean subcommand; `Clear::Directory` to
  delete the probed path; `Clear::Manual { why }` when neither is honest — Maven is the
  only one, because its local repository is not a cache and nothing should treat it as
  one.

`clear_command` is the string the report prints on its `runs:` line. On the
`Clear::Directory` rows it is a `const` with a `#[cfg]` per platform, because the
command a user would type is PowerShell on Windows and `rm -rf` elsewhere.

### Step 2: The `fallbacks()` arm

Also in `src/commands/caches.rs`, in `fallbacks()`:

```rust
("poetry", _) => vec![
    std::env::var_os("POETRY_CACHE_DIR").map(|p| PathBuf::from(p).join("artifacts")),
    under(&cache, "pypoetry/Cache/artifacts"),
    under(&cache, "pypoetry/artifacts"),
],
```

The environment variable goes first where the ecosystem has one, because a machine
that sets it has moved the cache and the conventional path is then wrong rather than
merely absent. This list is what runs when the manager is not installed — which is the
interesting case, because a cache outliving its manager is exactly the disk nobody can
account for. Use
the `under()` helper rather than joining paths yourself: it splits on `/` so a Windows
path never comes back spelled `C:\Users\dev/pypoetry`.

### Step 3: If the name is also an adapter name

Nothing to do — `dependents()` seeds the `used_by` count from
`adapters::is_adapter_name(m)`, so a probe whose name matches an adapter gets the
"used by 3 of 9 registered repositories" line automatically, and one that does not
stays silent rather than guessing. `pip` does not claim every venv on the disk;
`playwright` does not claim every `node_modules`.

### Step 4: If re-downloading is not a promise

Give it a `note`. Every other row in the report is safe on the strength of one
sentence — delete it, the manager fetches it again — and `huggingface` is the row where
that is not true: a gated or now-private repository will not hand the weights back, and
the re-download is measured in tens of gigabytes. If your cache has a case like that,
say so in the `note` rather than leaving the row looking like the ones that do not.

---

## 📦 Adding a Container Engine

Docker, Podman, nerdctl, finch and Apple's `container` are five entries in one
`ENGINES` table in `src/commands/containers.rs`. A sixth is one more:

```rust
Engine {
    name: "finch",
    binary: "finch",
    prompts: true,
    df_args: &["system", "df", "--format", "{{json .}}"],
    // Printed, never run. Every command worth knowing about, including the one
    // that deletes data.
    prune: &[
        (
            "finch system prune",
            "stopped containers, networks, dangling images",
        ),
        (
            "finch system prune --volumes",
            "adds unused volumes — the one that deletes data",
        ),
    ],
    // Run, on request, after printing. Nothing here touches a volume.
    reclaim: &[ReclaimStep {
        what: "images, stopped containers and the build cache",
        args: &["system", "prune", "-a", "-f"],
    }],
    // None: finch forwards to nerdctl, whose dangling filter is "not supported
    // yet", so it cannot list only its unused volumes and `--include-volumes`
    // is a usage error for it.
    volume_candidates: None,
}
```

Every runtime enumeration derives from this table: which binaries are looked for, the
engine names `devp caches clear` accepts, the list in its usage error, and what
`devp caches containers` runs when given no engine. There is no separate list to keep
in step.

Three fields are the ones to get right:

- **`prune` and `reclaim` are deliberately separate tables.** `prune` is every command
  worth *knowing about*, including the volume-deleting variant; it is printed and never
  run. `reclaim` is only what `devp caches clear <engine>` will execute. There is no
  argv in any `reclaim` list that touches a volume — so "no reclaim step can touch a
  volume" is a property of the table rather than a flag someone could pass or a check
  someone could forget.
- **`volume_candidates`** is `Some` only when the engine can list *just* its unused
  volumes by name, which today means a `volume ls -q --filter dangling=true` the engine
  actually honours: docker and podman do. The surface is what `--include-volumes`
  reads. The list is printed with row numbers, the user types the numbers, and each
  pick runs one unforced `volume rm <name>`. No argv on the surface may contain
  `prune`, `--volumes` or a force flag, and a unit test fails the build if one appears.
  An engine whose filter is unreliable sets `None`, which makes `--include-volumes` a
  usage error pointing at `<engine> volume ls`. When in doubt, `None`: a filter that
  silently lists in-use volumes would put the wrong names on a deletion menu.
- **`prompts`** says whether the engine stops to ask on its own. Docker, Podman,
  nerdctl and finch all do, and all take `-f` to mean the question has been asked
  already. Apple's `container` does not: its prune subcommands neither ask nor define
  a `-f`, so they run the moment they are typed. Setting `prompts: false` is what makes
  `devp caches containers container` say so in as many words before it lists them.
  dev-prune's own confirmation is unconditional either way.

If the engine's `system df` does not answer in the shape the other four use, teach
`parse_rows` about it rather than reshaping the engine entry — Apple's answers with a
single object rather than a row per resource, and that is where it is handled.

---

## ✅ Whichever you added

The gate is the same four commands, and clippy is `-D warnings` including test code:

```bash
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all --all-features
npm --prefix site run build
```

A new adapter changes a count that is written out **in words** across the README, the
site, `llms.txt`, the AI skill and two marketplace manifests. Grep for the number you
are replacing before you open the pull request; CI does not check prose.