Skip to main content

dev_prune/commands/
containers.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune caches docker`, `caches podman` and `caches containers`.
5//
6// A container engine is usually the largest thing on a developer's disk and the last one
7// anybody looks at. `devp caches` already answers "how big is the npm cache"; this
8// answers the same question about images, stopped containers, dangling volumes and the
9// build cache, which between them routinely hold more than every package manager cache
10// on the machine combined.
11//
12// **Nothing on a schedule will ever delete any of it**, and until 1.17.0 nothing here
13// deleted it at all. The reasoning behind that has not changed: a container image has no
14// lockfile — the registry tag it came from can be retagged or deleted, the Dockerfile
15// that built it may not be on this disk — and a named volume is the one thing in the
16// whole system that is not reproducible at any price.
17//
18// What changed is who runs the command. The report used to end by printing four commands
19// and asking the reader to go type one in another window, which meant the reclaim was
20// theirs to have remembered, and dev-prune could neither count it nor say afterwards what
21// it had cost. `caches clear <engine>` now runs the narrow ones itself — build cache,
22// unused images, stopped containers — in the foreground, after printing them, after
23// asking, and never from the daemon. The volume-deleting variants stay printed and
24// unrun, which is the only part of this that was ever about proof rather than about
25// consent.
26//
27// The numbers come from the engine's own `system df`, not from a directory walk. On
28// Docker Desktop and Podman the store lives inside a VM disk image that the host cannot
29// see, and `~/.docker` is a config directory rather than the data — a size taken from
30// the filesystem would be wrong by orders of magnitude, and wrong in the reassuring
31// direction. Asking the engine is also the only way to learn what is *reclaimable*,
32// which is the figure that decides anything: 40 GB of images with 38 GB dangling is a
33// different situation from 40 GB with 2 GB dangling.
34//
35// Kubernetes is reported as names and no bytes. kind, k3d and minikube run their nodes
36// as containers or as a VM disk belonging to an engine that is already in the table
37// above, so a size beside a cluster name would be gigabytes counted twice.
38
39use std::path::PathBuf;
40
41use anyhow::Result;
42use colored::Colorize;
43use serde_json::Value;
44
45use crate::adapters;
46use crate::constants;
47use crate::json;
48use crate::output;
49
50/// A container engine dev-prune knows how to ask about its disk use.
51struct Engine {
52    /// What it is called in output, and the name accepted on the command line.
53    name: &'static str,
54    /// The executable to look for and to ask.
55    binary: &'static str,
56    /// Arguments that make it print its disk usage as JSON.
57    ///
58    /// Docker, nerdctl and finch take a Go template; Podman and Apple's `container` take
59    /// a format name. The first four then produce the same rows in one of two
60    /// punctuations, which is why one parser reads either; Apple's is a different
61    /// document and [`parse_rows`] says so.
62    df_args: &'static [&'static str],
63    /// The reclaim commands worth printing, narrowest first, each with what it costs.
64    ///
65    /// Printed and never run. The order is the order to try them in: the build cache is
66    /// almost always the biggest win and the only one that costs nothing but a slower
67    /// next build, and the volume-deleting variant is last because it is the one that
68    /// destroys data no registry can hand back.
69    prune: &'static [(&'static str, &'static str)],
70    /// The steps `devp caches clear <engine>` runs, in order.
71    ///
72    /// A separate table from `prune` on purpose. That one is every command worth knowing
73    /// about, including the volume-deleting variant nobody should reach for casually;
74    /// this one is only what dev-prune is willing to run itself. There is no argv here
75    /// that touches a volume, so "volumes are left alone" is a property of the table
76    /// rather than a flag someone can pass or a check that could be forgotten.
77    reclaim: &'static [ReclaimStep],
78    /// Whether this engine stops to ask before it prunes.
79    ///
80    /// Docker, Podman, nerdctl and finch all do, and all take `-f` to say the question
81    /// has already been asked — which dev-prune has, by name, in the plan it printed
82    /// first. Apple's `container` has neither the question nor the flag: `container
83    /// prune` removes stopped containers and prints what it reclaimed, and a `-f` it does
84    /// not define would turn every step into a usage error. So this is a fact about the
85    /// engine, checked in the tests, rather than a habit applied to all of them.
86    prompts: bool,
87}
88
89/// One command `devp caches clear <engine>` runs.
90struct ReclaimStep {
91    /// What it gives back, in the plan and in the result line.
92    what: &'static str,
93    /// The engine's own arguments, forced non-interactive.
94    ///
95    /// `-f` is not a shortcut past a confirmation the user never saw: dev-prune has
96    /// already asked, by name, for everything these steps do. What it prevents is the
97    /// engine asking a second question at a prompt this process may not own.
98    args: &'static [&'static str],
99}
100
101/// Width of the command column under "Reclaim it yourself".
102///
103/// `docker system prune --volumes` is the longest command printed at 29 characters, and
104/// every cost string below is written to fit the remainder inside 90 columns.
105const COMMAND_WIDTH: usize = 32;
106
107/// Every engine this command knows, in the order they are reported.
108const ENGINES: &[Engine] = &[
109    Engine {
110        name: "docker",
111        binary: "docker",
112        prompts: true,
113        df_args: &["system", "df", "--format", "{{json .}}"],
114        prune: &[
115            (
116                "docker builder prune",
117                "the build cache; costs a slower next build",
118            ),
119            (
120                "docker image prune",
121                "dangling images no tag points at any more",
122            ),
123            (
124                "docker container prune",
125                "stopped containers and each writable layer",
126            ),
127            (
128                "docker system prune",
129                "the three above at once; volumes untouched",
130            ),
131            (
132                "docker system prune --volumes",
133                "adds unused volumes — the one that deletes data",
134            ),
135        ],
136        reclaim: &[
137            ReclaimStep {
138                what: "the build cache",
139                args: &["builder", "prune", "-a", "-f"],
140            },
141            ReclaimStep {
142                what: "images no container uses",
143                args: &["image", "prune", "-a", "-f"],
144            },
145            ReclaimStep {
146                what: "stopped containers and their writable layers",
147                args: &["container", "prune", "-f"],
148            },
149        ],
150    },
151    Engine {
152        name: "podman",
153        binary: "podman",
154        prompts: true,
155        df_args: &["system", "df", "--format", "json"],
156        prune: &[
157            (
158                "podman system prune",
159                "stopped containers, networks, dangling images",
160            ),
161            (
162                "podman image prune -a",
163                "every image no container uses, tagged or not",
164            ),
165            (
166                "podman system prune --volumes",
167                "adds unused volumes — the one that deletes data",
168            ),
169        ],
170        reclaim: &[
171            ReclaimStep {
172                what: "the build cache",
173                args: &["builder", "prune", "-a", "-f"],
174            },
175            ReclaimStep {
176                what: "images no container uses",
177                args: &["image", "prune", "-a", "-f"],
178            },
179            ReclaimStep {
180                what: "stopped containers and their writable layers",
181                args: &["container", "prune", "-f"],
182            },
183        ],
184    },
185    Engine {
186        name: "nerdctl",
187        binary: "nerdctl",
188        prompts: true,
189        df_args: &["system", "df", "--format", "{{json .}}"],
190        prune: &[
191            (
192                "nerdctl system prune",
193                "stopped containers, networks, dangling images",
194            ),
195            (
196                "nerdctl system prune --volumes",
197                "adds unused volumes — the one that deletes data",
198            ),
199        ],
200        // One step rather than three: nerdctl spells its narrow prune subcommands
201        // differently across versions, and `system prune` has meant the same thing —
202        // images, containers, build cache, volumes only with `--volumes` — since it
203        // gained the command.
204        reclaim: &[ReclaimStep {
205            what: "images, stopped containers and the build cache",
206            args: &["system", "prune", "-a", "-f"],
207        }],
208    },
209    // finch is nerdctl inside a Lima VM, and it forwards `system` to it verbatim with
210    // flag parsing turned off — so the nerdctl spellings above are the finch spellings,
211    // template and all. Its store is inside that VM's disk image, which is the same
212    // reason the host cannot size it and the engine has to be the one asked.
213    Engine {
214        name: "finch",
215        binary: "finch",
216        prompts: true,
217        df_args: &["system", "df", "--format", "{{json .}}"],
218        prune: &[
219            (
220                "finch system prune",
221                "stopped containers, networks, dangling images",
222            ),
223            (
224                "finch system prune --volumes",
225                "adds unused volumes — the one that deletes data",
226            ),
227        ],
228        reclaim: &[ReclaimStep {
229            what: "images, stopped containers and the build cache",
230            args: &["system", "prune", "-a", "-f"],
231        }],
232    },
233    // Apple's `container`, on Apple silicon. Named after its binary like the rest, so
234    // `devp caches clear container` is the command someone who has been typing
235    // `container` all day would guess.
236    //
237    // It is the odd one here twice over. Its `system df` answers with one object whose
238    // fields are the resource types rather than a row each, and its prune subcommands
239    // have no confirmation and therefore no `-f`. There is also nothing to clear a build
240    // cache with: BuildKit lives in a builder VM, and `container builder delete` removes
241    // the builder itself rather than pruning what it cached, which is more than being
242    // asked for.
243    Engine {
244        name: "container",
245        binary: "container",
246        prompts: false,
247        df_args: &["system", "df", "--format", "json"],
248        prune: &[
249            (
250                "container image prune -a",
251                "every image no container uses, tagged or not",
252            ),
253            (
254                "container prune",
255                "stopped containers and their writable layers",
256            ),
257            (
258                "container volume prune",
259                "unused volumes — the one that deletes data",
260            ),
261        ],
262        reclaim: &[
263            ReclaimStep {
264                what: "images no container uses",
265                args: &["image", "prune", "-a"],
266            },
267            ReclaimStep {
268                what: "stopped containers and their writable layers",
269                args: &["prune"],
270            },
271        ],
272    },
273];
274
275/// One line of an engine's own disk-usage report.
276pub struct Row {
277    /// `Images`, `Containers`, `Local Volumes`, `Build Cache` — the engine's own word
278    /// for it, kept verbatim so the row matches what `docker system df` prints.
279    pub kind: String,
280    /// How many of them there are, when the engine says.
281    pub total: Option<u64>,
282    /// How many of those are in use.
283    pub active: Option<u64>,
284    /// Bytes on disk.
285    pub bytes: Option<u64>,
286    /// Bytes the engine believes it could give back.
287    pub reclaimable: Option<u64>,
288}
289
290/// What was found for one engine.
291pub enum EngineState {
292    /// It answered, and this is what it said.
293    Ready(Vec<Row>),
294    /// The binary is installed and the query did not answer. Almost always a daemon
295    /// that is not running, so the engine's own words are carried through rather than
296    /// guessed at.
297    Unavailable(String),
298}
299
300/// One engine's entry in the report. Engines that are not installed produce none.
301pub struct EngineReport {
302    /// The engine's name.
303    pub name: &'static str,
304    /// Whether it answered, and what with.
305    pub state: EngineState,
306}
307
308impl EngineReport {
309    /// Total bytes across every row, or `None` when the engine did not answer.
310    pub fn total_bytes(&self) -> Option<u64> {
311        match &self.state {
312            EngineState::Ready(rows) => Some(rows.iter().filter_map(|r| r.bytes).sum()),
313            EngineState::Unavailable(_) => None,
314        }
315    }
316
317    /// Total reclaimable bytes across every row, or `None` when it did not answer.
318    pub fn reclaimable_bytes(&self) -> Option<u64> {
319        match &self.state {
320            EngineState::Ready(rows) => Some(rows.iter().filter_map(|r| r.reclaimable).sum()),
321            EngineState::Unavailable(_) => None,
322        }
323    }
324}
325
326/// Ask every installed engine, or only the one named.
327///
328/// `None` for `only` means every engine found. An engine whose binary is not on `PATH`
329/// is absent from the result entirely — there is nothing to say about a tool that is
330/// not installed, and a row saying so on every machine without Podman would be noise.
331pub fn collect(only: Option<&str>) -> Vec<EngineReport> {
332    ENGINES
333        .iter()
334        .filter(|e| only.is_none_or(|name| e.name.eq_ignore_ascii_case(name)))
335        .filter(|e| adapters::binary_available(e.binary))
336        .map(probe)
337        .collect()
338}
339
340/// Ask one engine how much disk it is using.
341fn probe(engine: &Engine) -> EngineReport {
342    let captured = adapters::capture_allowing_failure(
343        engine.binary,
344        engine.df_args,
345        &query_dir(),
346        std::time::Duration::from_secs(constants::CONTAINER_QUERY_TIMEOUT_SECS),
347    );
348
349    let state =
350        match captured {
351            Ok(out) if out.ok => {
352                let rows = parse_rows(&out.stdout);
353                if rows.is_empty() {
354                    // It exited zero and said nothing this parser recognised. Reporting a
355                    // total of zero would be a claim about the machine that was never made.
356                    EngineState::Unavailable(format!(
357                        "{} answered `system df` in a format dev-prune could not read",
358                        engine.name
359                    ))
360                } else {
361                    EngineState::Ready(rows)
362                }
363            }
364            Ok(out) => EngineState::Unavailable(first_line(&out.stderr).unwrap_or_else(|| {
365                format!("`{} system df` failed without saying why", engine.name)
366            })),
367            Err(e) => EngineState::Unavailable(
368                first_line(&e.to_string())
369                    .unwrap_or_else(|| format!("`{} system df` could not be run", engine.name)),
370            ),
371        };
372
373    EngineReport {
374        name: engine.name,
375        state,
376    }
377}
378
379/// The engine's first line of complaint, which is the part a human needs.
380///
381/// Docker follows "cannot connect to the daemon" with a paragraph about how to start it;
382/// Podman follows its own with a stack of socket paths. Neither belongs in a table.
383fn first_line(raw: &str) -> Option<String> {
384    let line = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
385    // Generous, because this is wrapped rather than laid out in a column: Docker's
386    // daemon-down message is about 200 characters and saying most of it is worse than
387    // saying all of it. The cap is only here so a pathological engine cannot paste a
388    // megabyte of one-line output into the report or into `--json`.
389    Some(output::truncate_display(line, 400))
390}
391
392/// Where to run the queries from.
393///
394/// The home directory, for the same reason `devp caches` uses it: a project directory
395/// can carry a `.dockerignore`, a Compose file or a `DOCKER_HOST` override in a `.env`
396/// that would answer for that project rather than for the machine.
397fn query_dir() -> PathBuf {
398    dirs::home_dir()
399        .or_else(|| std::env::current_dir().ok())
400        .unwrap_or_else(|| PathBuf::from("."))
401}
402
403/// Read an engine's `system df` answer.
404///
405/// Three shapes. Docker, nerdctl and finch print one JSON object per line; Podman prints
406/// a single array of the same objects; Apple's `container` prints one pretty-printed
407/// object whose *fields* are the resource types, with no `Type` anywhere to read. The
408/// first two differ only in punctuation, which is why one row parser reads either;
409/// the third is a different document and gets its own.
410///
411/// Accepting all three removes an entire class of "works on my machine" from a report
412/// whose whole job is to be believed.
413fn parse_rows(raw: &str) -> Vec<Row> {
414    let trimmed = raw.trim();
415    if trimmed.starts_with('[') {
416        return match serde_json::from_str::<Value>(trimmed) {
417            Ok(Value::Array(items)) => items.iter().filter_map(row_from).collect(),
418            _ => Vec::new(),
419        };
420    }
421    // Only a document that is one whole object gets this far as anything but an error:
422    // Docker's several-objects-on-several-lines does not parse as one value, and its
423    // single-object case has a `Type` and no `images`, so it falls through to the loop.
424    if let Ok(v) = serde_json::from_str::<Value>(trimmed)
425        && let Some(rows) = apple_rows(&v)
426    {
427        return rows;
428    }
429    trimmed
430        .lines()
431        .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
432        .filter_map(|v| row_from(&v))
433        .collect()
434}
435
436/// Apple's `container system df`, which answers with one object rather than a row each.
437///
438/// `{"images":{"total":4,"active":2,"sizeInBytes":12345,"reclaimable":678}, "containers":
439/// {…}, "volumes":{…}}` — counts and byte counts as numbers, no formatted strings to
440/// parse and no percentage to strip. All three keys are required, so anything else that
441/// happens to be one JSON object falls through to the row parser instead of becoming a
442/// report with holes in it.
443///
444/// The three labels are the ones the engine's own table prints, so somebody running
445/// `container system df` beside `devp caches containers` reads the same words in both.
446fn apple_rows(v: &Value) -> Option<Vec<Row>> {
447    let mut rows = Vec::new();
448    for (key, kind) in [
449        ("images", "Images"),
450        ("containers", "Containers"),
451        ("volumes", "Local Volumes"),
452    ] {
453        let usage = v.get(key)?.as_object()?;
454        rows.push(Row {
455            kind: kind.to_string(),
456            total: usage.get("total").and_then(Value::as_u64),
457            active: usage.get("active").and_then(Value::as_u64),
458            bytes: usage.get("sizeInBytes").and_then(Value::as_u64),
459            reclaimable: usage.get("reclaimable").and_then(Value::as_u64),
460        });
461    }
462    Some(rows)
463}
464
465/// One row, from whichever spelling of the fields this engine uses.
466fn row_from(v: &Value) -> Option<Row> {
467    let kind = v.get("Type")?.as_str()?.trim().to_string();
468    if kind.is_empty() {
469        return None;
470    }
471    Some(Row {
472        // Docker calls it `TotalCount`, Podman calls it `Total`.
473        total: count(v, "TotalCount").or_else(|| count(v, "Total")),
474        active: count(v, "Active"),
475        // Where the engine offers the raw byte count, it is the truth and the formatted
476        // string is a rounding of it: `1.093GB` has lost three digits before it is read.
477        bytes: bytes_at(v, "RawSize", "Size"),
478        reclaimable: bytes_at(v, "RawReclaimable", "Reclaimable"),
479        kind,
480    })
481}
482
483/// A count that may be a JSON number or a JSON string, because both are printed.
484fn count(v: &Value, key: &str) -> Option<u64> {
485    let field = v.get(key)?;
486    if let Some(n) = field.as_u64() {
487        return Some(n);
488    }
489    field.as_str()?.trim().parse().ok()
490}
491
492/// A size, preferring the engine's raw byte count over its formatted string.
493fn bytes_at(v: &Value, raw_key: &str, human_key: &str) -> Option<u64> {
494    if let Some(n) = v.get(raw_key).and_then(Value::as_u64) {
495        return Some(n);
496    }
497    parse_size(v.get(human_key)?.as_str()?)
498}
499
500/// Bytes out of a size the way a container engine writes one.
501///
502/// `1.093GB`, `0B`, `987.4MB`, and — for a reclaimable figure — `1.093GB (100%)`, where
503/// the percentage restates the same number and is dropped.
504fn parse_size(s: &str) -> Option<u64> {
505    // The percentage is the same figure expressed a second way.
506    let s = s.split('(').next()?.trim();
507    let split = s
508        .find(|c: char| !(c.is_ascii_digit() || c == '.'))
509        .unwrap_or(s.len());
510    let (number, unit) = s.split_at(split);
511    let value: f64 = number.parse().ok()?;
512    if !value.is_finite() || value < 0.0 {
513        return None;
514    }
515
516    let unit = unit.trim();
517    let mut chars = unit.chars();
518    let scale = chars.next();
519    // `GiB` is 1024-based and `GB` is 1000-based. Docker prints the second, Podman can
520    // print either, and across a 40 GB store the difference is about 3 GB — enough to
521    // change what someone decides to do about it.
522    let rest: String = chars.collect();
523    let base: f64 = if rest.eq_ignore_ascii_case("ib") {
524        1024.0
525    } else {
526        1000.0
527    };
528    let exponent = match scale.map(|c| c.to_ascii_lowercase()) {
529        None | Some('b') => 0,
530        Some('k') => 1,
531        Some('m') => 2,
532        Some('g') => 3,
533        Some('t') => 4,
534        Some('p') => 5,
535        _ => return None,
536    };
537
538    Some((value * base.powi(exponent)).round() as u64)
539}
540
541/// Kubernetes contexts on this machine that run on this machine.
542///
543/// Read out of the kubeconfig with `kubectl config get-contexts`, which touches no
544/// cluster and no network — a context pointing at a production cluster three time zones
545/// away is filtered out by name here rather than by being dialled.
546fn kube_contexts() -> Vec<String> {
547    if !adapters::binary_available("kubectl") {
548        return Vec::new();
549    }
550    let Ok(out) = adapters::capture_allowing_failure(
551        "kubectl",
552        &["config", "get-contexts", "-o", "name"],
553        &query_dir(),
554        std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
555    ) else {
556        return Vec::new();
557    };
558    if !out.ok {
559        return Vec::new();
560    }
561    out.stdout
562        .lines()
563        .map(str::trim)
564        .filter(|l| is_local_context(l))
565        .map(str::to_string)
566        .collect()
567}
568
569/// Whether a context name is one of the local-cluster tools rather than a remote.
570///
571/// Name-matching, because the alternative is contacting the cluster to find out, and a
572/// disk report has no business dialling a Kubernetes API server. Each of these names is
573/// fixed by the tool that writes it: `kind create cluster --name dev` always produces
574/// `kind-dev`, and minikube always writes `minikube`.
575fn is_local_context(name: &str) -> bool {
576    const LOCAL_PREFIXES: [&str; 2] = ["kind-", "k3d-"];
577    const LOCAL_EXACT: [&str; 5] = [
578        "minikube",
579        "docker-desktop",
580        "rancher-desktop",
581        "colima",
582        "microk8s",
583    ];
584    LOCAL_PREFIXES.iter().any(|p| name.starts_with(p))
585        || LOCAL_EXACT.iter().any(|n| name.eq_ignore_ascii_case(n))
586}
587
588/// Run `devp caches containers [engine]`, `devp caches docker` and `devp caches podman`.
589pub fn run(only: Option<&str>, json_output: bool) -> Result<()> {
590    if let Some(name) = only
591        && !ENGINES.iter().any(|e| e.name.eq_ignore_ascii_case(name))
592    {
593        return Err(anyhow::Error::new(crate::UsageError(format!(
594            "`{name}` is not a container engine dev-prune knows. Try one of: {}.",
595            known_engines().join(", ")
596        ))));
597    }
598
599    let pb = (!json_output).then(|| output::create_spinner("Asking the container engines..."));
600    let reports = collect(only);
601    let clusters = kube_contexts();
602    if let Some(pb) = pb {
603        pb.finish_and_clear();
604    }
605
606    if json_output {
607        return json::emit(&json::containers_document(&reports, &clusters));
608    }
609
610    print_report(&reports, &clusters, only);
611    Ok(())
612}
613
614/// What one reclaim step actually did.
615pub struct StepOutcome {
616    /// The command that ran, as a human would type it.
617    pub command: String,
618    /// What it was asked to give back.
619    pub what: &'static str,
620    /// `None` when it worked; otherwise the engine's own first line of complaint.
621    pub problem: Option<String>,
622}
623
624/// What `devp caches clear <engine>` did, measured rather than claimed.
625pub struct ClearOutcome {
626    /// The engine.
627    pub engine: &'static str,
628    /// Each step, in the order it ran.
629    pub steps: Vec<StepOutcome>,
630    /// The engine's own total before, from `system df`.
631    pub before: u64,
632    /// The engine's own total after, asked again rather than subtracted.
633    pub after: u64,
634}
635
636impl ClearOutcome {
637    /// Bytes given back to the disk.
638    pub fn freed(&self) -> u64 {
639        self.before.saturating_sub(self.after)
640    }
641}
642
643/// Run `devp caches clear <engine>`.
644///
645/// The one thing in this module that deletes. It exists because the alternative was
646/// worse: the report ended by printing four commands and asking the reader to run them
647/// in another window, which meant the space they reclaimed was theirs to have thought of
648/// and dev-prune could not count it, explain it, or put it in a history.
649///
650/// The rule this tool actually follows is not "never deletes what no lockfile covers" —
651/// `devp caches clear npm` has emptied shared caches no lockfile can prove rebuildable
652/// since 1.9.0. The rule is that the *unattended* pass deletes only what a lockfile
653/// rebuilds, and everything else is asked for by name, in the foreground, with what is
654/// about to go printed first. This is that second kind, and it is never schedulable: no
655/// daemon path reaches this function.
656///
657/// Volumes are the exception that stays one. An image can be pulled again and a build
658/// cache rebuilt; what is inside a named volume exists nowhere else, and there is no
659/// argv in any [`Engine::reclaim`] that touches one.
660pub fn run_clear(name: &str, yes: bool, dry_run: bool, json_output: bool) -> Result<()> {
661    let Some(engine) = ENGINES.iter().find(|e| e.name.eq_ignore_ascii_case(name)) else {
662        return Err(anyhow::Error::new(crate::UsageError(format!(
663            "`{name}` is not a container engine dev-prune knows. Try one of: {}.",
664            known_engines().join(", ")
665        ))));
666    };
667
668    // Same reason as `caches clear`: a prompt nobody can answer is a hang, and the line
669    // printed in its place would land in the middle of the JSON document.
670    if json_output && !yes && !dry_run {
671        return Err(anyhow::Error::new(crate::UsageError(
672            "`--json` cannot ask for confirmation — pass `--yes` as well, or `--dry-run` \
673             to see what would go."
674                .to_string(),
675        )));
676    }
677
678    if !adapters::binary_available(engine.binary) {
679        return Err(anyhow::Error::new(crate::UsageError(format!(
680            "{} is not installed on this machine, so there is nothing of its to clear.",
681            engine.name
682        ))));
683    }
684
685    let before = probe(engine);
686    let rows = match &before.state {
687        EngineState::Ready(rows) => rows,
688        // Quoted, not paraphrased. A stopped daemon and a permission problem on the
689        // socket read identically from here and are fixed completely differently.
690        EngineState::Unavailable(why) => {
691            return Err(anyhow::Error::new(crate::UsageError(format!(
692                "{} did not answer, so dev-prune will not start deleting on a guess: {why}",
693                engine.name
694            ))));
695        }
696    };
697    let before_bytes: u64 = rows.iter().filter_map(|r| r.bytes).sum();
698
699    if !json_output {
700        print_clear_plan(engine, rows, dry_run);
701    }
702    if dry_run {
703        if json_output {
704            let planned = ClearOutcome {
705                engine: engine.name,
706                steps: planned_steps(engine),
707                before: before_bytes,
708                after: before_bytes,
709            };
710            return json::emit(&json::containers_clear_document(&planned, true));
711        }
712        return Ok(());
713    }
714    if !json_output && !crate::commands::caches::confirm_clear(yes) {
715        output::print_info("Nothing was cleared.");
716        return Ok(());
717    }
718
719    let steps: Vec<StepOutcome> = engine.reclaim.iter().map(|s| run_step(engine, s)).collect();
720
721    // Asked again rather than subtracted from what each command claimed. `image prune`
722    // reports the layers it deleted, and layers are shared — three images can each report
723    // a gigabyte while the disk gets one back. `system df` is the only figure that
724    // describes the disk instead of the bookkeeping.
725    let after_bytes = probe(engine).total_bytes().unwrap_or(before_bytes);
726    let outcome = ClearOutcome {
727        engine: engine.name,
728        steps,
729        before: before_bytes,
730        after: after_bytes,
731    };
732    record_container_clear(outcome.freed());
733
734    if json_output {
735        json::emit(&json::containers_clear_document(&outcome, false))?;
736    } else {
737        print_clear_result(&outcome);
738    }
739
740    // Reported first, then failed, for the same reason `caches clear` does it in that
741    // order: the rows above are the useful part.
742    let failed = outcome.steps.iter().filter(|s| s.problem.is_some()).count();
743    if failed > 0 {
744        anyhow::bail!(
745            "{failed} of {}'s reclaim steps did not finish.",
746            outcome.engine
747        );
748    }
749    Ok(())
750}
751
752/// Every step as it would be reported had it run, for `--dry-run --json`.
753fn planned_steps(engine: &Engine) -> Vec<StepOutcome> {
754    engine
755        .reclaim
756        .iter()
757        .map(|s| StepOutcome {
758            command: step_command(engine, s),
759            what: s.what,
760            problem: None,
761        })
762        .collect()
763}
764
765/// The step as a human would type it, which is also the string that gets printed.
766fn step_command(engine: &Engine, step: &ReclaimStep) -> String {
767    format!("{} {}", engine.binary, step.args.join(" "))
768}
769
770/// Hand one step to the engine that owns it.
771fn run_step(engine: &Engine, step: &ReclaimStep) -> StepOutcome {
772    let captured = adapters::capture_allowing_failure(
773        engine.binary,
774        step.args,
775        &query_dir(),
776        std::time::Duration::from_secs(constants::CONTAINER_PRUNE_TIMEOUT_SECS),
777    );
778    let problem = match captured {
779        Ok(out) if out.ok => None,
780        Ok(out) => Some(first_line(&out.stderr).unwrap_or_else(|| {
781            format!("`{}` failed without saying why", step_command(engine, step))
782        })),
783        Err(e) => Some(
784            first_line(&e.to_string())
785                .unwrap_or_else(|| format!("`{}` could not be run", step_command(engine, step))),
786        ),
787    };
788    StepOutcome {
789        command: step_command(engine, step),
790        what: step.what,
791        problem,
792    }
793}
794
795/// Credit what was reclaimed to the machine's running container total.
796fn record_container_clear(bytes: u64) {
797    if bytes == 0 {
798        return;
799    }
800    if let Ok(mut registry) = crate::config::Registry::load() {
801        registry.record_container_clear(bytes);
802        let _ = registry.save();
803    }
804}
805
806/// What is about to run, and what the engine says it is holding.
807fn print_clear_plan(engine: &Engine, rows: &[Row], dry_run: bool) {
808    output::print_header(&format!("Clearing {}", engine.name));
809    println!();
810    for step in engine.reclaim {
811        println!("  {:<40}  {}", step_command(engine, step).bold(), step.what);
812    }
813    println!();
814    // The engine's reclaimable figure counts unused volumes, and not one of the commands
815    // above touches one. Printing it whole would promise back space these steps cannot
816    // give, so the volume row comes out of the estimate and is named as kept instead.
817    let volumes: u64 = rows
818        .iter()
819        .filter(|r| r.kind.eq_ignore_ascii_case("Local Volumes"))
820        .filter_map(|r| r.reclaimable)
821        .sum();
822    let reclaimable: u64 = rows.iter().filter_map(|r| r.reclaimable).sum();
823    output::print_wrapped(
824        "  ",
825        &format!(
826            "{} says about {} of this is reclaimable. Volumes are not touched by any of \
827             the commands above and never will be — a named volume is the one thing here \
828             that cannot be rebuilt from anywhere.",
829            engine.name,
830            output::format_bytes(reclaimable.saturating_sub(volumes))
831        ),
832    );
833    if volumes > 0 {
834        println!();
835        output::print_wrapped(
836            "  ",
837            &format!(
838                "{} of unused volumes is being left alone. If you have read what is in \
839                 them and want it gone, that one is yours to run: `{} volume prune`.",
840                output::format_bytes(volumes),
841                engine.binary
842            ),
843        );
844    }
845    if dry_run {
846        println!();
847        output::print_info("Dry run — nothing was deleted.");
848    }
849    println!();
850}
851
852/// What actually went, measured against the engine's own answer afterwards.
853fn print_clear_result(outcome: &ClearOutcome) {
854    println!();
855    for step in &outcome.steps {
856        match &step.problem {
857            None => println!("  {:<40}  done", step.command),
858            Some(why) => println!("  {:<40}  {why}", step.command),
859        }
860    }
861    println!();
862    output::print_success(&format!(
863        "{} freed — {} is now holding {}, down from {}.",
864        output::format_bytes(outcome.freed()),
865        outcome.engine,
866        output::format_bytes(outcome.after),
867        output::format_bytes(outcome.before)
868    ));
869}
870
871/// The engine names `devp caches containers <engine>` accepts.
872pub fn known_engines() -> Vec<&'static str> {
873    ENGINES.iter().map(|e| e.name).collect()
874}
875
876/// Whether a name is one of them, so `caches clear docker` can say where to go instead.
877pub fn is_engine(name: &str) -> bool {
878    ENGINES.iter().any(|e| e.name.eq_ignore_ascii_case(name))
879}
880
881fn print_report(reports: &[EngineReport], clusters: &[String], only: Option<&str>) {
882    output::print_header("Container engines");
883
884    if reports.is_empty() {
885        println!();
886        output::print_info(&match only {
887            Some(name) => format!("{name} is not installed on this machine."),
888            None => format!(
889                "No container engine found. dev-prune looks for {}.",
890                known_engines().join(", ")
891            ),
892        });
893        return;
894    }
895
896    for report in reports {
897        println!();
898        match &report.state {
899            EngineState::Unavailable(why) => print_unavailable(report.name, why),
900            EngineState::Ready(rows) => print_engine(report.name, rows),
901        }
902    }
903
904    if !clusters.is_empty() {
905        print_clusters(clusters);
906    }
907
908    println!();
909    output::print_wrapped(
910        "  ",
911        "Nothing above was deleted, and nothing dev-prune runs on a schedule will ever \
912         delete it. To have dev-prune run the narrow ones for you — build cache, unused \
913         images, stopped containers, and what that gave back counted on your stats — use \
914         `devp caches clear <engine>`. It asks first, and it never touches a volume: that \
915         is the one thing here that cannot be rebuilt at all, so it stays yours to run.",
916    );
917}
918
919/// An engine that is installed and did not answer.
920///
921/// Quoted rather than paraphrased. "Cannot connect to the Docker daemon" and "permission
922/// denied on /var/run/docker.sock" are different problems with different fixes, and a
923/// tidy dev-prune sentence in place of the engine's own would hide which one this is.
924fn print_unavailable(name: &str, why: &str) {
925    println!("  {name}");
926    println!();
927    output::print_wrapped("    ", why);
928    println!();
929    output::print_wrapped(
930        "    ",
931        &format!(
932            "So dev-prune has no figures for {name} — a blank rather than a zero. Start \
933             it and run this again."
934        ),
935    );
936}
937
938/// Column widths for the engine table, chosen so the longest real row — `Local
939/// Volumes`, a ten-character size, a ten-character reclaimable figure and `41 items, 9
940/// in use` — still lands inside the 90-column prose width the rest of the tool wraps to.
941const KIND_WIDTH: usize = 16;
942const SIZE_WIDTH: usize = 11;
943
944/// One engine's rows, its total, and the commands that would reclaim each part.
945fn print_engine(name: &str, rows: &[Row]) {
946    println!("  {name}");
947    println!();
948    for row in rows {
949        println!(
950            "  {:<KIND_WIDTH$}{:>SIZE_WIDTH$}   {}   {}",
951            row.kind,
952            row.bytes.map_or("—".to_string(), output::format_bytes),
953            reclaimable_cell(row.reclaimable),
954            counts(row),
955        );
956    }
957
958    let total: u64 = rows.iter().filter_map(|r| r.bytes).sum();
959    let reclaimable: u64 = rows.iter().filter_map(|r| r.reclaimable).sum();
960    println!();
961    println!(
962        "  {:<KIND_WIDTH$}{:>SIZE_WIDTH$}   {}",
963        "Total",
964        output::format_bytes(total),
965        reclaimable_cell(Some(reclaimable)),
966    );
967
968    let Some(engine) = ENGINES.iter().find(|e| e.name == name) else {
969        return;
970    };
971    println!();
972    println!(
973        "  {:<COMMAND_WIDTH$}what it takes with it",
974        "Reclaim it yourself"
975    );
976    for (command, cost) in engine.prune {
977        println!("  {command:<COMMAND_WIDTH$}{cost}");
978    }
979    if !engine.prompts {
980        println!();
981        // Worth one line, because the last command in that list deletes data and the
982        // reader's expectation comes from the other engines: everywhere else a prune
983        // stops and asks, and a `-f` in an example is the tell that it would have. This
984        // one has no such flag because it has no such question.
985        output::print_wrapped(
986            "  ",
987            &format!(
988                "{} asks nothing first. Each of those runs the moment you press Return,                  including the last one.",
989                engine.name
990            ),
991        );
992    }
993}
994
995/// The `9.20 GiB reclaimable` cell, blank-padded when the engine did not say.
996///
997/// Padded rather than left empty so the counts column after it stays in one place down
998/// the table; a row missing this figure otherwise pulls its neighbour eleven characters
999/// left and the whole block stops reading as a table.
1000fn reclaimable_cell(bytes: Option<u64>) -> String {
1001    match bytes {
1002        Some(b) => format!("{:>SIZE_WIDTH$} reclaimable", output::format_bytes(b)),
1003        None => " ".repeat(SIZE_WIDTH + " reclaimable".len()),
1004    }
1005}
1006
1007/// The "12 of them, 3 in use" half of a row.
1008fn counts(row: &Row) -> String {
1009    match (row.total, row.active) {
1010        (Some(total), Some(active)) => format!(
1011            "{total} {}, {active} in use",
1012            output::plural(total as usize, "item", "items")
1013        ),
1014        (Some(total), None) => format!(
1015            "{total} {}",
1016            output::plural(total as usize, "item", "items")
1017        ),
1018        _ => String::new(),
1019    }
1020}
1021
1022/// The local Kubernetes clusters, named and deliberately unsized.
1023fn print_clusters(clusters: &[String]) {
1024    println!();
1025    println!("  kubernetes");
1026    println!();
1027    for name in clusters {
1028        println!("  {:<18} local cluster", name);
1029    }
1030    println!();
1031    output::print_wrapped(
1032        "  ",
1033        "Named and not sized on purpose: kind, k3d and minikube run their nodes as \
1034         containers or as a VM disk belonging to an engine above, so their disk is \
1035         already in that engine's total. A figure here would be the same gigabytes \
1036         counted twice. Delete a cluster with its own tool — `kind delete cluster`, \
1037         `minikube delete`, `k3d cluster delete` — which is also what releases the \
1038         space.",
1039    );
1040}
1041
1042/// The one-line-per-engine block `devp caches` prints under its own table.
1043///
1044/// Short on purpose. `devp caches` is a report about package managers, and this is the
1045/// sentence that stops someone concluding they have reclaimed everything there is when
1046/// the largest thing on the disk was never in the table.
1047pub fn print_summary(reports: &[EngineReport]) {
1048    if reports.is_empty() {
1049        return;
1050    }
1051    println!();
1052    output::print_header("Container engines");
1053    println!();
1054    for report in reports {
1055        match &report.state {
1056            EngineState::Ready(_) => {
1057                let total = report.total_bytes().unwrap_or(0);
1058                let reclaimable = report.reclaimable_bytes().unwrap_or(0);
1059                println!(
1060                    "  {:<30} {:>10}  {} reclaimable · devp caches {}",
1061                    report.name,
1062                    output::format_bytes(total),
1063                    output::format_bytes(reclaimable),
1064                    report.name,
1065                );
1066            }
1067            EngineState::Unavailable(_) => {
1068                // The reason is a sentence from the engine and this is a
1069                // one-line-per-engine block, so it is shown by the command with room
1070                // for it.
1071                println!(
1072                    "  {:<30} {:>10}  did not answer · devp caches {}",
1073                    report.name, "—", report.name,
1074                );
1075            }
1076        }
1077    }
1078    println!();
1079    output::print_wrapped(
1080        "  ",
1081        "Container images, volumes and build cache are not package manager caches and are \
1082         not in the total above — dev-prune reports them and never deletes them.",
1083    );
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088    use super::*;
1089
1090    #[test]
1091    fn no_reclaim_step_can_touch_a_volume() {
1092        // The promise printed in the plan, checked against the argv rather than against
1093        // the prose. Every engine here has a `--volumes` spelling that would turn one of
1094        // these commands into the one that destroys data no registry can hand back, and
1095        // the only thing keeping it out is that nobody typed it into the table.
1096        for engine in ENGINES {
1097            for step in engine.reclaim {
1098                for arg in step.args {
1099                    assert!(
1100                        !arg.to_ascii_lowercase().contains("volume"),
1101                        "{} would run `{}`, which reaches a volume",
1102                        engine.name,
1103                        step_command(engine, step)
1104                    );
1105                }
1106            }
1107        }
1108    }
1109
1110    #[test]
1111    fn every_engine_that_can_be_reported_can_be_cleared() {
1112        // `caches clear <engine>` accepts any name `is_engine` knows, so an engine with an
1113        // empty reclaim table would confirm, run nothing, and report freeing zero bytes.
1114        for engine in ENGINES {
1115            assert!(
1116                !engine.reclaim.is_empty(),
1117                "{} can be named to clear and has no steps",
1118                engine.name
1119            );
1120        }
1121    }
1122
1123    #[test]
1124    fn every_reclaim_step_answers_for_itself_without_a_prompt() {
1125        // These run without a terminal behind them — inside `devp caches clear --yes`, and
1126        // from a shell whose stdin the engine does not own. A step that stops to ask is a
1127        // hang, and dev-prune has already asked the only question that matters.
1128        //
1129        // The engines that ask take `-f` to say it has been answered. Apple's `container`
1130        // never asks and defines no such flag, so passing one there would not be caution:
1131        // it would be a usage error on every step, which is the same hang's worth of
1132        // nothing reclaimed by a different route.
1133        for engine in ENGINES {
1134            for step in engine.reclaim {
1135                let forced = step.args.contains(&"-f") || step.args.contains(&"--force");
1136                assert_eq!(
1137                    forced,
1138                    engine.prompts,
1139                    "`{}` disagrees with what {} does about prompting",
1140                    step_command(engine, step),
1141                    engine.name
1142                );
1143            }
1144        }
1145    }
1146
1147    #[test]
1148    fn reads_apples_one_object_for_all_three() {
1149        // Apple's `container` is not installed on the machines this is developed on, so
1150        // the shape is pinned from `DiskUsageStats`/`ResourceUsage` in apple/container
1151        // rather than from a run. If those field names ever change, this fails here
1152        // instead of the report quietly showing an engine holding nothing.
1153        let raw = r#"{
1154          "images" : { "total" : 12, "active" : 3, "sizeInBytes" : 4210000000,
1155                       "reclaimable" : 3020000000 },
1156          "containers" : { "total" : 7, "active" : 1, "sizeInBytes" : 118400000,
1157                           "reclaimable" : 118400000 },
1158          "volumes" : { "total" : 2, "active" : 0, "sizeInBytes" : 2048,
1159                        "reclaimable" : 2048 }
1160        }"#;
1161        let rows = parse_rows(raw);
1162        assert_eq!(rows.len(), 3);
1163        assert_eq!(rows[0].kind, "Images");
1164        assert_eq!(rows[0].total, Some(12));
1165        assert_eq!(rows[0].active, Some(3));
1166        assert_eq!(rows[0].bytes, Some(4_210_000_000));
1167        assert_eq!(rows[0].reclaimable, Some(3_020_000_000));
1168        // The label is the engine's own, not the JSON key: `volumes` prints as
1169        // `Local Volumes`, which is also what the volume-keeping arithmetic matches on.
1170        assert_eq!(rows[2].kind, "Local Volumes");
1171        assert_eq!(rows[2].bytes, Some(2_048));
1172    }
1173
1174    #[test]
1175    fn a_json_object_that_is_not_apples_is_not_read_as_apples() {
1176        // Docker printing a single row — one object, on one line — must still be read as
1177        // that row rather than swallowed by the branch above.
1178        let rows = parse_rows(
1179            r#"{"Active":"3","Reclaimable":"3.02GB (71%)","Size":"4.21GB","TotalCount":"12","Type":"Images"}"#,
1180        );
1181        assert_eq!(rows.len(), 1);
1182        assert_eq!(rows[0].kind, "Images");
1183        // Two of the three keys is not the shape either, and half a report is worse than
1184        // the honest "could not read this" the caller prints for no rows.
1185        assert!(parse_rows(r#"{"images":{"total":1},"containers":{"total":1}}"#).is_empty());
1186    }
1187
1188    #[test]
1189    fn parses_docker_si_sizes() {
1190        assert_eq!(parse_size("0B"), Some(0));
1191        assert_eq!(parse_size("1.093GB"), Some(1_093_000_000));
1192        assert_eq!(parse_size("987.4MB"), Some(987_400_000));
1193        assert_eq!(parse_size("1.5kB"), Some(1_500));
1194        assert_eq!(parse_size("2TB"), Some(2_000_000_000_000));
1195    }
1196
1197    #[test]
1198    fn iec_suffix_is_base_1024() {
1199        assert_eq!(parse_size("1KiB"), Some(1_024));
1200        assert_eq!(parse_size("1GiB"), Some(1_073_741_824));
1201        // The distinction is the whole reason the suffix is inspected: the same number
1202        // with the other suffix is 7% smaller.
1203        assert_ne!(parse_size("1GiB"), parse_size("1GB"));
1204    }
1205
1206    #[test]
1207    fn reclaimable_percentage_is_dropped() {
1208        assert_eq!(parse_size("1.093GB (100%)"), Some(1_093_000_000));
1209        assert_eq!(parse_size("0B (0%)"), Some(0));
1210    }
1211
1212    #[test]
1213    fn rejects_what_is_not_a_size() {
1214        assert_eq!(parse_size(""), None);
1215        assert_eq!(parse_size("N/A"), None);
1216        assert_eq!(parse_size("GB"), None);
1217        assert_eq!(parse_size("12 apples"), None);
1218    }
1219
1220    #[test]
1221    fn reads_dockers_one_object_per_line() {
1222        let raw = concat!(
1223            r#"{"Active":"3","Reclaimable":"3.02GB (71%)","Size":"4.21GB","TotalCount":"12","Type":"Images"}"#,
1224            "\n",
1225            r#"{"Active":"1","Reclaimable":"118.4MB (100%)","Size":"118.4MB","TotalCount":"7","Type":"Containers"}"#,
1226            "\n",
1227            r#"{"Active":"0","Reclaimable":"6.75GB","Size":"6.75GB","TotalCount":"41","Type":"Build Cache"}"#,
1228        );
1229        let rows = parse_rows(raw);
1230        assert_eq!(rows.len(), 3);
1231        assert_eq!(rows[0].kind, "Images");
1232        assert_eq!(rows[0].total, Some(12));
1233        assert_eq!(rows[0].active, Some(3));
1234        assert_eq!(rows[0].bytes, Some(4_210_000_000));
1235        assert_eq!(rows[0].reclaimable, Some(3_020_000_000));
1236        assert_eq!(rows[2].kind, "Build Cache");
1237        assert_eq!(rows[2].active, Some(0));
1238    }
1239
1240    #[test]
1241    fn reads_podmans_single_array() {
1242        let raw = r#"[
1243            {"Type":"Images","Total":4,"Active":2,"Size":"1.5GB","Reclaimable":"500MB (33%)"},
1244            {"Type":"Local Volumes","Total":2,"Active":0,"RawSize":2048,"RawReclaimable":2048,
1245             "Size":"2.048kB","Reclaimable":"2.048kB (100%)"}
1246        ]"#;
1247        let rows = parse_rows(raw);
1248        assert_eq!(rows.len(), 2);
1249        assert_eq!(rows[0].total, Some(4));
1250        assert_eq!(rows[0].bytes, Some(1_500_000_000));
1251        // The raw byte count wins over the string rounded from it.
1252        assert_eq!(rows[1].bytes, Some(2_048));
1253        assert_eq!(rows[1].reclaimable, Some(2_048));
1254    }
1255
1256    #[test]
1257    fn unparseable_output_is_no_rows_rather_than_zero_bytes() {
1258        assert!(parse_rows("").is_empty());
1259        assert!(parse_rows("Cannot connect to the Docker daemon").is_empty());
1260        // Valid JSON, but not a df row: no `Type` to name.
1261        assert!(parse_rows(r#"{"Size":"4GB"}"#).is_empty());
1262    }
1263
1264    #[test]
1265    fn local_contexts_are_told_from_remote_ones() {
1266        assert!(is_local_context("kind-dev"));
1267        assert!(is_local_context("k3d-test"));
1268        assert!(is_local_context("minikube"));
1269        assert!(is_local_context("docker-desktop"));
1270        assert!(!is_local_context("arn:aws:eks:us-east-1:1234:cluster/prod"));
1271        assert!(!is_local_context("gke_project_us-central1_prod"));
1272        // A remote cluster somebody named after the tool is still remote, but this is
1273        // name-matching and the alternative is dialling it. Naming a production context
1274        // `minikube` is a problem that predates dev-prune.
1275        assert!(!is_local_context("kindly-prod"));
1276    }
1277
1278    #[test]
1279    fn every_engine_prints_at_least_one_reclaim_command() {
1280        for engine in ENGINES {
1281            assert!(
1282                !engine.prune.is_empty(),
1283                "{} has no reclaim command to print",
1284                engine.name
1285            );
1286            for (command, _) in engine.prune {
1287                assert!(
1288                    command.starts_with(engine.binary),
1289                    "{command} is not a {} command",
1290                    engine.name
1291                );
1292            }
1293        }
1294    }
1295
1296    #[test]
1297    fn no_reclaim_command_is_ever_run_by_dev_prune() {
1298        // The guard is that `prune` is only ever read into a `println!`. If a future
1299        // change hands one of these to a process spawner, this file is where the review
1300        // has to notice, so the strings are checked to be commands for a human to type
1301        // rather than argv this code could execute.
1302        for engine in ENGINES {
1303            for (command, _) in engine.prune {
1304                assert!(
1305                    command.contains(' '),
1306                    "{command} looks like a bare program name"
1307                );
1308            }
1309        }
1310    }
1311}