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 bulk volume-deleting variants stay printed and
24// unrun: no argv here ever contains `volume prune` or `--volumes`. What `--include-volumes`
25// adds is not a bulk delete but a pick list, unused volumes named one per line for a
26// person at a terminal to choose from, each choice becoming its own unforced `volume rm`.
27// It refuses `--yes`, `--json` and a piped stdin, so no script, scheduler, hook or agent
28// can reach it: the typing of each number is the consent. And the list is read before it
29// is picked from: the real command only arms within ten minutes of a completed dry run
30// for that engine, and outside that window it *is* the dry run, said out loud.
31//
32// The numbers come from the engine's own `system df`, not from a directory walk. On
33// Docker Desktop and Podman the store lives inside a VM disk image that the host cannot
34// see, and `~/.docker` is a config directory rather than the data — a size taken from
35// the filesystem would be wrong by orders of magnitude, and wrong in the reassuring
36// direction. Asking the engine is also the only way to learn what is *reclaimable*,
37// which is the figure that decides anything: 40 GB of images with 38 GB dangling is a
38// different situation from 40 GB with 2 GB dangling.
39//
40// Kubernetes is reported as names and no bytes. kind, k3d and minikube run their nodes
41// as containers or as a VM disk belonging to an engine that is already in the table
42// above, so a size beside a cluster name would be gigabytes counted twice.
43
44use std::path::PathBuf;
45
46use anyhow::Result;
47use colored::Colorize;
48use serde_json::Value;
49
50use crate::adapters;
51use crate::constants;
52use crate::json;
53use crate::output;
54
55/// A container engine dev-prune knows how to ask about its disk use.
56struct Engine {
57    /// What it is called in output, and the name accepted on the command line.
58    name: &'static str,
59    /// The executable to look for and to ask.
60    binary: &'static str,
61    /// Arguments that make it print its disk usage as JSON.
62    ///
63    /// Docker, nerdctl and finch take a Go template; Podman and Apple's `container` take
64    /// a format name. The first four then produce the same rows in one of two
65    /// punctuations, which is why one parser reads either; Apple's is a different
66    /// document and [`parse_rows`] says so.
67    df_args: &'static [&'static str],
68    /// The reclaim commands worth printing, narrowest first, each with what it costs.
69    ///
70    /// Printed and never run. The order is the order to try them in: the build cache is
71    /// almost always the biggest win and the only one that costs nothing but a slower
72    /// next build, and the volume-deleting variant is last because it is the one that
73    /// destroys data no registry can hand back.
74    prune: &'static [(&'static str, &'static str)],
75    /// The steps `devp caches clear <engine>` runs, in order.
76    ///
77    /// A separate table from `prune` on purpose. That one is every command worth knowing
78    /// about, including the volume-deleting variant nobody should reach for casually;
79    /// this one is only what dev-prune is willing to run itself. There is no argv here
80    /// that touches a volume, so "volumes are left alone" is a property of the table
81    /// rather than a flag someone can pass or a check that could be forgotten.
82    reclaim: &'static [ReclaimStep],
83    /// Whether this engine stops to ask before it prunes.
84    ///
85    /// Docker, Podman, nerdctl and finch all do, and all take `-f` to say the question
86    /// has already been asked — which dev-prune has, by name, in the plan it printed
87    /// first. Apple's `container` has neither the question nor the flag: `container
88    /// prune` removes stopped containers and prints what it reclaimed, and a `-f` it does
89    /// not define would turn every step into a usage error. So this is a fact about the
90    /// engine, checked in the tests, rather than a habit applied to all of them.
91    prompts: bool,
92    /// How to name this engine's unused volumes, when it can. See [`VolumeSurface`].
93    ///
94    /// `None` for the engines that cannot: nerdctl's `volume ls` filters on label, name
95    /// and size but not on dangling (its own command reference says "not supported
96    /// yet"), finch forwards to nerdctl verbatim, and Apple's `container` exposes no
97    /// per-volume usage at all. For those, `--include-volumes` is a usage error that
98    /// points at the engine's own `volume ls`, because a pick list dev-prune cannot
99    /// prove is unused would be a list of guesses.
100    volume_candidates: Option<VolumeSurface>,
101}
102
103/// The three commands behind `--include-volumes`, for an engine that has them.
104///
105/// The only deleting argv in here is `rm_args`, and it is deliberately incomplete: its
106/// final argument is one volume's name, appended only after a person picked that volume
107/// off a numbered list at a terminal. It is never forced, so a volume something still
108/// uses is the engine's own refusal rather than dev-prune's judgement call.
109struct VolumeSurface {
110    /// Arguments that print one unused volume name per line and nothing else.
111    ls_args: &'static [&'static str],
112    /// Arguments for the verbose disk usage that sizes volumes individually.
113    ///
114    /// Decoration on the pick list, not a gate: an answer in a shape
115    /// [`parse_volume_sizes`] cannot read degrades to "size unknown" on each row.
116    df_args: &'static [&'static str],
117    /// The delete, missing its final argument: one picked volume's name.
118    rm_args: &'static [&'static str],
119}
120
121/// One command `devp caches clear <engine>` runs.
122struct ReclaimStep {
123    /// What it gives back, in the plan and in the result line.
124    what: &'static str,
125    /// The engine's own arguments, forced non-interactive.
126    ///
127    /// `-f` is not a shortcut past a confirmation the user never saw: dev-prune has
128    /// already asked, by name, for everything these steps do. What it prevents is the
129    /// engine asking a second question at a prompt this process may not own.
130    args: &'static [&'static str],
131}
132
133/// Width of the command column under "Reclaim it yourself".
134///
135/// `docker system prune --volumes` is the longest command printed at 29 characters, and
136/// every cost string below is written to fit the remainder inside 90 columns.
137const COMMAND_WIDTH: usize = 32;
138
139/// Every engine this command knows, in the order they are reported.
140const ENGINES: &[Engine] = &[
141    Engine {
142        name: "docker",
143        binary: "docker",
144        prompts: true,
145        df_args: &["system", "df", "--format", "{{json .}}"],
146        prune: &[
147            (
148                "docker builder prune",
149                "the build cache; costs a slower next build",
150            ),
151            (
152                "docker image prune",
153                "dangling images no tag points at any more",
154            ),
155            (
156                "docker container prune",
157                "stopped containers and each writable layer",
158            ),
159            (
160                "docker system prune",
161                "the three above at once; volumes untouched",
162            ),
163            (
164                "docker system prune --volumes",
165                "adds unused volumes — the one that deletes data",
166            ),
167        ],
168        reclaim: &[
169            ReclaimStep {
170                what: "the build cache",
171                args: &["builder", "prune", "-a", "-f"],
172            },
173            ReclaimStep {
174                what: "images no container uses",
175                args: &["image", "prune", "-a", "-f"],
176            },
177            ReclaimStep {
178                what: "stopped containers and their writable layers",
179                args: &["container", "prune", "-f"],
180            },
181        ],
182        volume_candidates: Some(VolumeSurface {
183            ls_args: &["volume", "ls", "-q", "--filter", "dangling=true"],
184            // `-v` is what puts a per-volume table in the answer; without it the
185            // document has only the four summary rows the report already reads.
186            df_args: &["system", "df", "-v", "--format", "{{json .}}"],
187            rm_args: &["volume", "rm"],
188        }),
189    },
190    Engine {
191        name: "podman",
192        binary: "podman",
193        prompts: true,
194        df_args: &["system", "df", "--format", "json"],
195        prune: &[
196            (
197                "podman system prune",
198                "stopped containers, networks, dangling images",
199            ),
200            (
201                "podman image prune -a",
202                "every image no container uses, tagged or not",
203            ),
204            (
205                "podman system prune --volumes",
206                "adds unused volumes — the one that deletes data",
207            ),
208        ],
209        reclaim: &[
210            ReclaimStep {
211                what: "the build cache",
212                args: &["builder", "prune", "-a", "-f"],
213            },
214            ReclaimStep {
215                what: "images no container uses",
216                args: &["image", "prune", "-a", "-f"],
217            },
218            ReclaimStep {
219                what: "stopped containers and their writable layers",
220                args: &["container", "prune", "-f"],
221            },
222        ],
223        // Podman documents the same `dangling=true` filter as Docker: "matches all
224        // volumes not referenced by any containers". Its verbose `system df` names its
225        // JSON fields its own way, which is why the size parser reads either spelling.
226        volume_candidates: Some(VolumeSurface {
227            ls_args: &["volume", "ls", "-q", "--filter", "dangling=true"],
228            df_args: &["system", "df", "-v", "--format", "json"],
229            rm_args: &["volume", "rm"],
230        }),
231    },
232    Engine {
233        name: "nerdctl",
234        binary: "nerdctl",
235        prompts: true,
236        df_args: &["system", "df", "--format", "{{json .}}"],
237        prune: &[
238            (
239                "nerdctl system prune",
240                "stopped containers, networks, dangling images",
241            ),
242            (
243                "nerdctl system prune --volumes",
244                "adds unused volumes — the one that deletes data",
245            ),
246        ],
247        // One step rather than three: nerdctl spells its narrow prune subcommands
248        // differently across versions, and `system prune` has meant the same thing —
249        // images, containers, build cache, volumes only with `--volumes` — since it
250        // gained the command.
251        reclaim: &[ReclaimStep {
252            what: "images, stopped containers and the build cache",
253            args: &["system", "prune", "-a", "-f"],
254        }],
255        volume_candidates: None,
256    },
257    // finch is nerdctl inside a Lima VM, and it forwards `system` to it verbatim with
258    // flag parsing turned off — so the nerdctl spellings above are the finch spellings,
259    // template and all. Its store is inside that VM's disk image, which is the same
260    // reason the host cannot size it and the engine has to be the one asked.
261    Engine {
262        name: "finch",
263        binary: "finch",
264        prompts: true,
265        df_args: &["system", "df", "--format", "{{json .}}"],
266        prune: &[
267            (
268                "finch system prune",
269                "stopped containers, networks, dangling images",
270            ),
271            (
272                "finch system prune --volumes",
273                "adds unused volumes — the one that deletes data",
274            ),
275        ],
276        reclaim: &[ReclaimStep {
277            what: "images, stopped containers and the build cache",
278            args: &["system", "prune", "-a", "-f"],
279        }],
280        volume_candidates: None,
281    },
282    // Apple's `container`, on Apple silicon. Named after its binary like the rest, so
283    // `devp caches clear container` is the command someone who has been typing
284    // `container` all day would guess.
285    //
286    // It is the odd one here twice over. Its `system df` answers with one object whose
287    // fields are the resource types rather than a row each, and its prune subcommands
288    // have no confirmation and therefore no `-f`. There is also nothing to clear a build
289    // cache with: BuildKit lives in a builder VM, and `container builder delete` removes
290    // the builder itself rather than pruning what it cached, which is more than being
291    // asked for.
292    Engine {
293        name: "container",
294        binary: "container",
295        prompts: false,
296        df_args: &["system", "df", "--format", "json"],
297        prune: &[
298            (
299                "container image prune -a",
300                "every image no container uses, tagged or not",
301            ),
302            (
303                "container prune",
304                "stopped containers and their writable layers",
305            ),
306            (
307                "container volume prune",
308                "unused volumes — the one that deletes data",
309            ),
310        ],
311        reclaim: &[
312            ReclaimStep {
313                what: "images no container uses",
314                args: &["image", "prune", "-a"],
315            },
316            ReclaimStep {
317                what: "stopped containers and their writable layers",
318                args: &["prune"],
319            },
320        ],
321        volume_candidates: None,
322    },
323];
324
325/// One line of an engine's own disk-usage report.
326pub struct Row {
327    /// `Images`, `Containers`, `Local Volumes`, `Build Cache` — the engine's own word
328    /// for it, kept verbatim so the row matches what `docker system df` prints.
329    pub kind: String,
330    /// How many of them there are, when the engine says.
331    pub total: Option<u64>,
332    /// How many of those are in use.
333    pub active: Option<u64>,
334    /// Bytes on disk.
335    pub bytes: Option<u64>,
336    /// Bytes the engine believes it could give back.
337    pub reclaimable: Option<u64>,
338}
339
340/// What was found for one engine.
341pub enum EngineState {
342    /// It answered, and this is what it said.
343    Ready(Vec<Row>),
344    /// The binary is installed and the query did not answer. Almost always a daemon
345    /// that is not running, so the engine's own words are carried through rather than
346    /// guessed at.
347    Unavailable(String),
348}
349
350/// One engine's entry in the report. Engines that are not installed produce none.
351pub struct EngineReport {
352    /// The engine's name.
353    pub name: &'static str,
354    /// Whether it answered, and what with.
355    pub state: EngineState,
356}
357
358impl EngineReport {
359    /// Total bytes across every row, or `None` when the engine did not answer.
360    pub fn total_bytes(&self) -> Option<u64> {
361        match &self.state {
362            EngineState::Ready(rows) => Some(rows.iter().filter_map(|r| r.bytes).sum()),
363            EngineState::Unavailable(_) => None,
364        }
365    }
366
367    /// Total reclaimable bytes across every row, or `None` when it did not answer.
368    pub fn reclaimable_bytes(&self) -> Option<u64> {
369        match &self.state {
370            EngineState::Ready(rows) => Some(rows.iter().filter_map(|r| r.reclaimable).sum()),
371            EngineState::Unavailable(_) => None,
372        }
373    }
374}
375
376/// Ask every installed engine, or only the one named.
377///
378/// `None` for `only` means every engine found. An engine whose binary is not on `PATH`
379/// is absent from the result entirely — there is nothing to say about a tool that is
380/// not installed, and a row saying so on every machine without Podman would be noise.
381pub fn collect(only: Option<&str>) -> Vec<EngineReport> {
382    ENGINES
383        .iter()
384        .filter(|e| only.is_none_or(|name| e.name.eq_ignore_ascii_case(name)))
385        .filter(|e| adapters::binary_available(e.binary))
386        .map(probe)
387        .collect()
388}
389
390/// Ask one engine how much disk it is using.
391fn probe(engine: &Engine) -> EngineReport {
392    let captured = adapters::capture_allowing_failure(
393        engine.binary,
394        engine.df_args,
395        &query_dir(),
396        std::time::Duration::from_secs(constants::CONTAINER_QUERY_TIMEOUT_SECS),
397    );
398
399    let state =
400        match captured {
401            Ok(out) if out.ok => {
402                let rows = parse_rows(&out.stdout);
403                if rows.is_empty() {
404                    // It exited zero and said nothing this parser recognised. Reporting a
405                    // total of zero would be a claim about the machine that was never made.
406                    EngineState::Unavailable(format!(
407                        "{} answered `system df` in a format dev-prune could not read",
408                        engine.name
409                    ))
410                } else {
411                    EngineState::Ready(rows)
412                }
413            }
414            Ok(out) => EngineState::Unavailable(first_line(&out.stderr).unwrap_or_else(|| {
415                format!("`{} system df` failed without saying why", engine.name)
416            })),
417            Err(e) => EngineState::Unavailable(
418                first_line(&e.to_string())
419                    .unwrap_or_else(|| format!("`{} system df` could not be run", engine.name)),
420            ),
421        };
422
423    EngineReport {
424        name: engine.name,
425        state,
426    }
427}
428
429/// The engine's first line of complaint, which is the part a human needs.
430///
431/// Docker follows "cannot connect to the daemon" with a paragraph about how to start it;
432/// Podman follows its own with a stack of socket paths. Neither belongs in a table.
433fn first_line(raw: &str) -> Option<String> {
434    let line = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
435    // Generous, because this is wrapped rather than laid out in a column: Docker's
436    // daemon-down message is about 200 characters and saying most of it is worse than
437    // saying all of it. The cap is only here so a pathological engine cannot paste a
438    // megabyte of one-line output into the report or into `--json`.
439    Some(output::truncate_display(line, 400))
440}
441
442/// Where to run the queries from.
443///
444/// The home directory, for the same reason `devp caches` uses it: a project directory
445/// can carry a `.dockerignore`, a Compose file or a `DOCKER_HOST` override in a `.env`
446/// that would answer for that project rather than for the machine.
447fn query_dir() -> PathBuf {
448    dirs::home_dir()
449        .or_else(|| std::env::current_dir().ok())
450        .unwrap_or_else(|| PathBuf::from("."))
451}
452
453/// Read an engine's `system df` answer.
454///
455/// Three shapes. Docker, nerdctl and finch print one JSON object per line; Podman prints
456/// a single array of the same objects; Apple's `container` prints one pretty-printed
457/// object whose *fields* are the resource types, with no `Type` anywhere to read. The
458/// first two differ only in punctuation, which is why one row parser reads either;
459/// the third is a different document and gets its own.
460///
461/// Accepting all three removes an entire class of "works on my machine" from a report
462/// whose whole job is to be believed.
463fn parse_rows(raw: &str) -> Vec<Row> {
464    let trimmed = raw.trim();
465    if trimmed.starts_with('[') {
466        return match serde_json::from_str::<Value>(trimmed) {
467            Ok(Value::Array(items)) => items.iter().filter_map(row_from).collect(),
468            _ => Vec::new(),
469        };
470    }
471    // Only a document that is one whole object gets this far as anything but an error:
472    // Docker's several-objects-on-several-lines does not parse as one value, and its
473    // single-object case has a `Type` and no `images`, so it falls through to the loop.
474    if let Ok(v) = serde_json::from_str::<Value>(trimmed)
475        && let Some(rows) = apple_rows(&v)
476    {
477        return rows;
478    }
479    trimmed
480        .lines()
481        .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
482        .filter_map(|v| row_from(&v))
483        .collect()
484}
485
486/// Apple's `container system df`, which answers with one object rather than a row each.
487///
488/// `{"images":{"total":4,"active":2,"sizeInBytes":12345,"reclaimable":678}, "containers":
489/// {…}, "volumes":{…}}` — counts and byte counts as numbers, no formatted strings to
490/// parse and no percentage to strip. All three keys are required, so anything else that
491/// happens to be one JSON object falls through to the row parser instead of becoming a
492/// report with holes in it.
493///
494/// The three labels are the ones the engine's own table prints, so somebody running
495/// `container system df` beside `devp caches containers` reads the same words in both.
496fn apple_rows(v: &Value) -> Option<Vec<Row>> {
497    let mut rows = Vec::new();
498    for (key, kind) in [
499        ("images", "Images"),
500        ("containers", "Containers"),
501        ("volumes", "Local Volumes"),
502    ] {
503        let usage = v.get(key)?.as_object()?;
504        rows.push(Row {
505            kind: kind.to_string(),
506            total: usage.get("total").and_then(Value::as_u64),
507            active: usage.get("active").and_then(Value::as_u64),
508            bytes: usage.get("sizeInBytes").and_then(Value::as_u64),
509            reclaimable: usage.get("reclaimable").and_then(Value::as_u64),
510        });
511    }
512    Some(rows)
513}
514
515/// One row, from whichever spelling of the fields this engine uses.
516fn row_from(v: &Value) -> Option<Row> {
517    let kind = v.get("Type")?.as_str()?.trim().to_string();
518    if kind.is_empty() {
519        return None;
520    }
521    Some(Row {
522        // Docker calls it `TotalCount`, Podman calls it `Total`.
523        total: count(v, "TotalCount").or_else(|| count(v, "Total")),
524        active: count(v, "Active"),
525        // Where the engine offers the raw byte count, it is the truth and the formatted
526        // string is a rounding of it: `1.093GB` has lost three digits before it is read.
527        bytes: bytes_at(v, "RawSize", "Size"),
528        reclaimable: bytes_at(v, "RawReclaimable", "Reclaimable"),
529        kind,
530    })
531}
532
533/// A count that may be a JSON number or a JSON string, because both are printed.
534fn count(v: &Value, key: &str) -> Option<u64> {
535    let field = v.get(key)?;
536    if let Some(n) = field.as_u64() {
537        return Some(n);
538    }
539    field.as_str()?.trim().parse().ok()
540}
541
542/// A size, preferring the engine's raw byte count over its formatted string.
543fn bytes_at(v: &Value, raw_key: &str, human_key: &str) -> Option<u64> {
544    if let Some(n) = v.get(raw_key).and_then(Value::as_u64) {
545        return Some(n);
546    }
547    parse_size(v.get(human_key)?.as_str()?)
548}
549
550/// Bytes out of a size the way a container engine writes one.
551///
552/// `1.093GB`, `0B`, `987.4MB`, and — for a reclaimable figure — `1.093GB (100%)`, where
553/// the percentage restates the same number and is dropped.
554fn parse_size(s: &str) -> Option<u64> {
555    // The percentage is the same figure expressed a second way.
556    let s = s.split('(').next()?.trim();
557    let split = s
558        .find(|c: char| !(c.is_ascii_digit() || c == '.'))
559        .unwrap_or(s.len());
560    let (number, unit) = s.split_at(split);
561    let value: f64 = number.parse().ok()?;
562    if !value.is_finite() || value < 0.0 {
563        return None;
564    }
565
566    let unit = unit.trim();
567    let mut chars = unit.chars();
568    let scale = chars.next();
569    // `GiB` is 1024-based and `GB` is 1000-based. Docker prints the second, Podman can
570    // print either, and across a 40 GB store the difference is about 3 GB — enough to
571    // change what someone decides to do about it.
572    let rest: String = chars.collect();
573    let base: f64 = if rest.eq_ignore_ascii_case("ib") {
574        1024.0
575    } else {
576        1000.0
577    };
578    let exponent = match scale.map(|c| c.to_ascii_lowercase()) {
579        None | Some('b') => 0,
580        Some('k') => 1,
581        Some('m') => 2,
582        Some('g') => 3,
583        Some('t') => 4,
584        Some('p') => 5,
585        _ => return None,
586    };
587
588    Some((value * base.powi(exponent)).round() as u64)
589}
590
591/// Kubernetes contexts on this machine that run on this machine.
592///
593/// Read out of the kubeconfig with `kubectl config get-contexts`, which touches no
594/// cluster and no network — a context pointing at a production cluster three time zones
595/// away is filtered out by name here rather than by being dialled.
596fn kube_contexts() -> Vec<String> {
597    if !adapters::binary_available("kubectl") {
598        return Vec::new();
599    }
600    let Ok(out) = adapters::capture_allowing_failure(
601        "kubectl",
602        &["config", "get-contexts", "-o", "name"],
603        &query_dir(),
604        std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
605    ) else {
606        return Vec::new();
607    };
608    if !out.ok {
609        return Vec::new();
610    }
611    out.stdout
612        .lines()
613        .map(str::trim)
614        .filter(|l| is_local_context(l))
615        .map(str::to_string)
616        .collect()
617}
618
619/// Whether a context name is one of the local-cluster tools rather than a remote.
620///
621/// Name-matching, because the alternative is contacting the cluster to find out, and a
622/// disk report has no business dialling a Kubernetes API server. Each of these names is
623/// fixed by the tool that writes it: `kind create cluster --name dev` always produces
624/// `kind-dev`, and minikube always writes `minikube`.
625fn is_local_context(name: &str) -> bool {
626    const LOCAL_PREFIXES: [&str; 2] = ["kind-", "k3d-"];
627    const LOCAL_EXACT: [&str; 5] = [
628        "minikube",
629        "docker-desktop",
630        "rancher-desktop",
631        "colima",
632        "microk8s",
633    ];
634    LOCAL_PREFIXES.iter().any(|p| name.starts_with(p))
635        || LOCAL_EXACT.iter().any(|n| name.eq_ignore_ascii_case(n))
636}
637
638/// Run `devp caches containers [engine]`, `devp caches docker` and `devp caches podman`.
639pub fn run(only: Option<&str>, json_output: bool) -> Result<()> {
640    if let Some(name) = only
641        && !ENGINES.iter().any(|e| e.name.eq_ignore_ascii_case(name))
642    {
643        return Err(anyhow::Error::new(crate::UsageError(format!(
644            "`{name}` is not a container engine dev-prune knows. Try one of: {}.",
645            known_engines().join(", ")
646        ))));
647    }
648
649    let pb = (!json_output).then(|| output::create_spinner("Asking the container engines..."));
650    let reports = collect(only);
651    let clusters = kube_contexts();
652    if let Some(pb) = pb {
653        pb.finish_and_clear();
654    }
655
656    if json_output {
657        return json::emit(&json::containers_document(&reports, &clusters));
658    }
659
660    print_report(&reports, &clusters, only);
661    Ok(())
662}
663
664/// What one reclaim step actually did.
665pub struct StepOutcome {
666    /// The command that ran, as a human would type it.
667    pub command: String,
668    /// What it was asked to give back.
669    pub what: &'static str,
670    /// `None` when it worked; otherwise the engine's own first line of complaint.
671    pub problem: Option<String>,
672}
673
674/// What `devp caches clear <engine>` did, measured rather than claimed.
675pub struct ClearOutcome {
676    /// The engine.
677    pub engine: &'static str,
678    /// Each step, in the order it ran.
679    pub steps: Vec<StepOutcome>,
680    /// The engine's own total before, from `system df`.
681    pub before: u64,
682    /// The engine's own total after, asked again rather than subtracted.
683    pub after: u64,
684}
685
686impl ClearOutcome {
687    /// Bytes given back to the disk.
688    pub fn freed(&self) -> u64 {
689        self.before.saturating_sub(self.after)
690    }
691}
692
693/// Run `devp caches clear <engine>`.
694///
695/// The one thing in this module that deletes. It exists because the alternative was
696/// worse: the report ended by printing four commands and asking the reader to run them
697/// in another window, which meant the space they reclaimed was theirs to have thought of
698/// and dev-prune could not count it, explain it, or put it in a history.
699///
700/// The rule this tool actually follows is not "never deletes what no lockfile covers" —
701/// `devp caches clear npm` has emptied shared caches no lockfile can prove rebuildable
702/// since 1.9.0. The rule is that the *unattended* pass deletes only what a lockfile
703/// rebuilds, and everything else is asked for by name, in the foreground, with what is
704/// about to go printed first. This is that second kind, and it is never schedulable: no
705/// daemon path reaches this function.
706///
707/// Volumes are the exception that stays one. An image can be pulled again and a build
708/// cache rebuilt; what is inside a named volume exists nowhere else, and there is no
709/// argv in any [`Engine::reclaim`] that touches one. `include_volumes` does not soften
710/// that: it appends a phase that lists unused volumes by name and deletes only the ones
711/// a person picks off that list at a terminal, one unforced `volume rm` each, and it
712/// refuses `--yes`, `--json` and a piped stdin so nothing unattended can reach it.
713/// The pick list also only arms within [`constants::VOLUME_PICK_WINDOW_SECS`] of a
714/// completed dry run for the engine; any other invocation runs the dry run instead,
715/// loudly, and stamps the window so the retyped line goes through.
716pub fn run_clear(
717    name: &str,
718    include_volumes: bool,
719    yes: bool,
720    dry_run: bool,
721    json_output: bool,
722) -> Result<()> {
723    let Some(engine) = ENGINES.iter().find(|e| e.name.eq_ignore_ascii_case(name)) else {
724        return Err(anyhow::Error::new(crate::UsageError(format!(
725            "`{name}` is not a container engine dev-prune knows. Try one of: {}.",
726            known_engines().join(", ")
727        ))));
728    };
729
730    // Every one of these is a usage error rather than a silent downgrade, so a script
731    // that reaches for the flag learns it is not for scripts instead of quietly getting
732    // the volume-free clear it never asked about. Capability first, because "this
733    // engine cannot do it at all" outranks how the command was spelled.
734    if include_volumes {
735        if engine.volume_candidates.is_none() {
736            return Err(anyhow::Error::new(crate::UsageError(format!(
737                "{} has no way to name only its unused volumes, so dev-prune cannot put \
738                 an honest pick list in front of you. Run `{} volume ls` and decide by \
739                 name yourself.",
740                engine.name, engine.binary
741            ))));
742        }
743        if json_output {
744            return Err(anyhow::Error::new(crate::UsageError(
745                "`--include-volumes` is a hand-picked deletion at a terminal, and \
746                 `--json` is for a program reading the answer. They do not combine; \
747                 drop one."
748                    .to_string(),
749            )));
750        }
751        if yes {
752            return Err(anyhow::Error::new(crate::UsageError(
753                "`--include-volumes` has no pre-answered form: the picking is the \
754                 point, and a volume goes only when its number is typed at the list. \
755                 Drop `--yes`."
756                    .to_string(),
757            )));
758        }
759        // Skipped for a dry run, which lists and deletes nothing; that much a
760        // redirected terminal may as well have.
761        if !dry_run && !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
762            return Err(anyhow::Error::new(crate::UsageError(
763                "`--include-volumes` needs a terminal, because someone has to pick each \
764                 volume off the list, and that is deliberate. From a script or an \
765                 agent, add `--dry-run`: it lists the unused volumes by name and \
766                 prints the command a person runs to do the picking themselves."
767                    .to_string(),
768            )));
769        }
770    }
771
772    // The real pick list only arms within the window after a completed dry run for
773    // this engine. Anyone who has not just read the list gets the list: the command
774    // becomes the dry run — same output, and it writes the same stamp — so typing the
775    // identical line again inside the window does the picking. Loudly, below, never
776    // silently: this module refuses silent downgrades everywhere else too.
777    let redirected = include_volumes && !dry_run && !volume_stamp_fresh(engine);
778    let dry_run = dry_run || redirected;
779
780    // Same reason as `caches clear`: a prompt nobody can answer is a hang, and the line
781    // printed in its place would land in the middle of the JSON document.
782    if json_output && !yes && !dry_run {
783        return Err(anyhow::Error::new(crate::UsageError(
784            "`--json` cannot ask for confirmation — pass `--yes` as well, or `--dry-run` \
785             to see what would go."
786                .to_string(),
787        )));
788    }
789
790    if !adapters::binary_available(engine.binary) {
791        return Err(anyhow::Error::new(crate::UsageError(format!(
792            "{} is not installed on this machine, so there is nothing of its to clear.",
793            engine.name
794        ))));
795    }
796
797    let before = probe(engine);
798    let rows = match &before.state {
799        EngineState::Ready(rows) => rows,
800        // Quoted, not paraphrased. A stopped daemon and a permission problem on the
801        // socket read identically from here and are fixed completely differently.
802        EngineState::Unavailable(why) => {
803            return Err(anyhow::Error::new(crate::UsageError(format!(
804                "{} did not answer, so dev-prune will not start deleting on a guess: {why}",
805                engine.name
806            ))));
807        }
808    };
809    let before_bytes: u64 = rows.iter().filter_map(|r| r.bytes).sum();
810
811    if redirected {
812        let minutes = constants::VOLUME_PICK_WINDOW_SECS / 60;
813        output::print_header("This run is the dry run");
814        println!();
815        output::print_wrapped(
816            "  ",
817            &format!(
818                "No volume dry run for {} has finished in the last {minutes} minutes, \
819                 so nothing below is deleted: the pick list only arms right after the \
820                 list has been read. Run the exact same line again within {minutes} \
821                 minutes to do the picking.",
822                engine.name
823            ),
824        );
825        println!();
826    }
827    if !json_output {
828        print_clear_plan(engine, rows, include_volumes, dry_run);
829    }
830    if dry_run {
831        if json_output {
832            let planned = ClearOutcome {
833                engine: engine.name,
834                steps: planned_steps(engine),
835                before: before_bytes,
836                after: before_bytes,
837            };
838            return json::emit(&json::containers_clear_document(&planned, true));
839        }
840        if include_volumes && let Some(surface) = &engine.volume_candidates {
841            print_volume_dry_run(engine, surface);
842            write_volume_stamp(engine);
843        }
844        return Ok(());
845    }
846    if !json_output && !crate::commands::caches::confirm_clear(yes) {
847        output::print_info("Nothing was cleared.");
848        return Ok(());
849    }
850
851    let mut steps: Vec<StepOutcome> = engine.reclaim.iter().map(|s| run_step(engine, s)).collect();
852
853    // After the reclaim steps on purpose: `container prune` is what turns a stopped
854    // container's anonymous volumes dangling, so a list drawn first would be missing
855    // the rows this very command just freed up.
856    if include_volumes && let Some(surface) = &engine.volume_candidates {
857        run_volume_phase(engine, surface, &mut steps);
858    }
859
860    // Asked again rather than subtracted from what each command claimed. `image prune`
861    // reports the layers it deleted, and layers are shared — three images can each report
862    // a gigabyte while the disk gets one back. `system df` is the only figure that
863    // describes the disk instead of the bookkeeping.
864    let after_bytes = probe(engine).total_bytes().unwrap_or(before_bytes);
865    let outcome = ClearOutcome {
866        engine: engine.name,
867        steps,
868        before: before_bytes,
869        after: after_bytes,
870    };
871    record_container_clear(outcome.freed());
872
873    if json_output {
874        json::emit(&json::containers_clear_document(&outcome, false))?;
875    } else {
876        print_clear_result(&outcome);
877    }
878
879    // Reported first, then failed, for the same reason `caches clear` does it in that
880    // order: the rows above are the useful part.
881    let failed = outcome.steps.iter().filter(|s| s.problem.is_some()).count();
882    if failed > 0 {
883        anyhow::bail!(
884            "{failed} of {}'s reclaim steps did not finish.",
885            outcome.engine
886        );
887    }
888    Ok(())
889}
890
891/// Every step as it would be reported had it run, for `--dry-run --json`.
892fn planned_steps(engine: &Engine) -> Vec<StepOutcome> {
893    engine
894        .reclaim
895        .iter()
896        .map(|s| StepOutcome {
897            command: step_command(engine, s),
898            what: s.what,
899            problem: None,
900        })
901        .collect()
902}
903
904/// The step as a human would type it, which is also the string that gets printed.
905fn step_command(engine: &Engine, step: &ReclaimStep) -> String {
906    format!("{} {}", engine.binary, step.args.join(" "))
907}
908
909/// Hand one step to the engine that owns it.
910fn run_step(engine: &Engine, step: &ReclaimStep) -> StepOutcome {
911    let captured = adapters::capture_allowing_failure(
912        engine.binary,
913        step.args,
914        &query_dir(),
915        std::time::Duration::from_secs(constants::CONTAINER_PRUNE_TIMEOUT_SECS),
916    );
917    let problem = match captured {
918        Ok(out) if out.ok => None,
919        Ok(out) => Some(first_line(&out.stderr).unwrap_or_else(|| {
920            format!("`{}` failed without saying why", step_command(engine, step))
921        })),
922        Err(e) => Some(
923            first_line(&e.to_string())
924                .unwrap_or_else(|| format!("`{}` could not be run", step_command(engine, step))),
925        ),
926    };
927    StepOutcome {
928        command: step_command(engine, step),
929        what: step.what,
930        problem,
931    }
932}
933
934/// The unused volumes an engine can name, one per line from its own `volume ls`.
935fn unused_volumes(engine: &Engine, surface: &VolumeSurface) -> Result<Vec<String>, String> {
936    let captured = adapters::capture_allowing_failure(
937        engine.binary,
938        surface.ls_args,
939        &query_dir(),
940        std::time::Duration::from_secs(constants::CONTAINER_QUERY_TIMEOUT_SECS),
941    );
942    match captured {
943        Ok(out) if out.ok => Ok(out
944            .stdout
945            .lines()
946            .map(str::trim)
947            .filter(|l| !l.is_empty())
948            .map(str::to_string)
949            .collect()),
950        Ok(out) => Err(first_line(&out.stderr)
951            .unwrap_or_else(|| format!("`{} volume ls` failed without saying why", engine.binary))),
952        Err(e) => Err(first_line(&e.to_string())
953            .unwrap_or_else(|| format!("`{} volume ls` could not be run", engine.binary))),
954    }
955}
956
957/// Each volume's size, for the pick list. Best-effort: an empty map on any failure.
958fn volume_sizes(
959    engine: &Engine,
960    surface: &VolumeSurface,
961) -> std::collections::HashMap<String, String> {
962    let Ok(out) = adapters::capture_allowing_failure(
963        engine.binary,
964        surface.df_args,
965        &query_dir(),
966        std::time::Duration::from_secs(constants::CONTAINER_QUERY_TIMEOUT_SECS),
967    ) else {
968        return std::collections::HashMap::new();
969    };
970    if !out.ok {
971        return std::collections::HashMap::new();
972    }
973    parse_volume_sizes(&out.stdout)
974}
975
976/// Per-volume sizes out of a verbose `system df`, in whichever spelling this engine uses.
977///
978/// Docker's `{{json .}}` with `-v` is one object holding a `Volumes` array whose entries
979/// carry `Name` and a formatted `Size`; Podman formats the same idea with its own field
980/// names and sometimes raw byte counts. The sizes decorate the pick list rather than
981/// gate it, so anything unrecognised degrades to "size unknown" on that row instead of
982/// refusing the phase.
983fn parse_volume_sizes(raw: &str) -> std::collections::HashMap<String, String> {
984    let mut sizes = std::collections::HashMap::new();
985    for candidate in std::iter::once(raw.trim()).chain(raw.lines().map(str::trim)) {
986        let Ok(v) = serde_json::from_str::<Value>(candidate) else {
987            continue;
988        };
989        let Some(volumes) = ["Volumes", "volumes"]
990            .iter()
991            .find_map(|k| v.get(k))
992            .and_then(Value::as_array)
993        else {
994            continue;
995        };
996        for entry in volumes {
997            let Some(name) = ["Name", "VolumeName", "Names"]
998                .iter()
999                .find_map(|k| entry.get(k))
1000                .and_then(Value::as_str)
1001            else {
1002                continue;
1003            };
1004            let size = match ["Size", "size"].iter().find_map(|k| entry.get(k)) {
1005                Some(Value::String(s)) => s.trim().to_string(),
1006                Some(Value::Number(n)) => n.as_u64().map(output::format_bytes).unwrap_or_default(),
1007                _ => String::new(),
1008            };
1009            if !size.is_empty() {
1010                sizes.insert(name.to_string(), size);
1011            }
1012        }
1013        if !sizes.is_empty() {
1014            break;
1015        }
1016    }
1017    sizes
1018}
1019
1020/// The numbers typed at the pick list, as zero-based indexes in the order given.
1021///
1022/// An empty answer is a deliberate "none", `all` is every row, and anything else is
1023/// numbers and `3-5` ranges separated by commas or spaces, one-based to match the list,
1024/// duplicates dropped. Anything that does not read that way (a zero, a number past the
1025/// end, a word, a backwards range) is `None`, and `None` deletes nothing. One shot, no
1026/// retry loop: a mistyped answer costs re-running the command, not a volume.
1027fn parse_selection(input: &str, count: usize) -> Option<Vec<usize>> {
1028    let trimmed = input.trim();
1029    if trimmed.is_empty() {
1030        return Some(Vec::new());
1031    }
1032    if trimmed.eq_ignore_ascii_case("all") {
1033        return Some((0..count).collect());
1034    }
1035    let mut picked = Vec::new();
1036    let mut push = |index: usize| {
1037        if !picked.contains(&index) {
1038            picked.push(index);
1039        }
1040    };
1041    for token in trimmed.split([',', ' ']).filter(|t| !t.is_empty()) {
1042        if let Some((low, high)) = token.split_once('-') {
1043            let low: usize = low.trim().parse().ok()?;
1044            let high: usize = high.trim().parse().ok()?;
1045            if low == 0 || high < low || high > count {
1046                return None;
1047            }
1048            (low..=high).for_each(|n| push(n - 1));
1049        } else {
1050            let n: usize = token.parse().ok()?;
1051            if n == 0 || n > count {
1052                return None;
1053            }
1054            push(n - 1);
1055        }
1056    }
1057    Some(picked)
1058}
1059
1060/// The `--include-volumes` phase: list what is unused, ask which, delete only those.
1061///
1062/// Everything it prints goes to stderr with the question, so a redirected stdout cannot
1063/// eat the list the answer is about. Each deletion lands in `steps` like any reclaim
1064/// step, so the result rows, the failure count and the exit code cover it too.
1065fn run_volume_phase(engine: &Engine, surface: &VolumeSurface, steps: &mut Vec<StepOutcome>) {
1066    use std::io::Write;
1067    let names = match unused_volumes(engine, surface) {
1068        Ok(names) => names,
1069        Err(why) => {
1070            output::print_info(&format!(
1071                "{} could not name its unused volumes, so none was offered: {why}",
1072                engine.name
1073            ));
1074            return;
1075        }
1076    };
1077    if names.is_empty() {
1078        output::print_info(&format!(
1079            "{} reports no unused volumes, so there is nothing to pick from.",
1080            engine.name
1081        ));
1082        return;
1083    }
1084    let sizes = volume_sizes(engine, surface);
1085    eprintln!();
1086    eprintln!("  Unused volumes. A volume holds the only copy of what is in it; anything");
1087    eprintln!("  picked here is gone for good.");
1088    eprintln!();
1089    for (i, name) in names.iter().enumerate() {
1090        let size = sizes.get(name).map_or("size unknown", String::as_str);
1091        eprintln!("  {:>3}. {:<44} {}", i + 1, name, size);
1092    }
1093    eprintln!();
1094    eprint!("Delete which? [numbers or ranges like `1 3-5`, `all`, Enter for none]: ");
1095    if std::io::stderr().flush().is_err() {
1096        return;
1097    }
1098    let mut input = String::new();
1099    if std::io::stdin().read_line(&mut input).is_err() {
1100        output::print_info("No answer could be read, so no volume was deleted.");
1101        return;
1102    }
1103    let Some(picked) = parse_selection(&input, names.len()) else {
1104        output::print_info(
1105            "That did not read as numbers from the list, so no volume was deleted. Run \
1106             the command again to see the list once more.",
1107        );
1108        return;
1109    };
1110    if picked.is_empty() {
1111        output::print_info("No volume picked; all of them stay.");
1112        return;
1113    }
1114    for index in picked {
1115        steps.push(remove_volume(engine, surface, &names[index]));
1116    }
1117}
1118
1119/// One `volume rm <name>`, never forced.
1120///
1121/// Unforced on purpose: if the engine thinks something still uses this volume, its
1122/// refusal is the right answer and it lands in the result row, not overridden.
1123fn remove_volume(engine: &Engine, surface: &VolumeSurface, name: &str) -> StepOutcome {
1124    let mut args: Vec<&str> = surface.rm_args.to_vec();
1125    args.push(name);
1126    let command = format!("{} {}", engine.binary, args.join(" "));
1127    let captured = adapters::capture_allowing_failure(
1128        engine.binary,
1129        &args,
1130        &query_dir(),
1131        std::time::Duration::from_secs(constants::CONTAINER_PRUNE_TIMEOUT_SECS),
1132    );
1133    let problem = match captured {
1134        Ok(out) if out.ok => None,
1135        Ok(out) => Some(
1136            first_line(&out.stderr)
1137                .unwrap_or_else(|| format!("`{command}` failed without saying why")),
1138        ),
1139        Err(e) => Some(
1140            first_line(&e.to_string()).unwrap_or_else(|| format!("`{command}` could not be run")),
1141        ),
1142    };
1143    StepOutcome {
1144        command,
1145        what: "a volume picked by name",
1146        problem,
1147    }
1148}
1149
1150/// What `--include-volumes --dry-run` shows: the list, the one devp command to paste,
1151/// and nothing run. It exists for the hand-off where an agent or script does everything
1152/// up to the deletion and a person runs that command at a terminal; the command is
1153/// devp's own rather than the engine's so the picks land on `devp stats`.
1154/// Where this engine's volume dry-run stamp lives, when the config dir is resolvable.
1155fn volume_stamp_path(engine: &Engine) -> Option<PathBuf> {
1156    crate::config::Registry::config_dir().ok().map(|dir| {
1157        dir.join(format!(
1158            "{}{}{}",
1159            constants::VOLUME_PICK_STAMP_PREFIX,
1160            engine.name.to_ascii_lowercase(),
1161            constants::VOLUME_PICK_STAMP_SUFFIX
1162        ))
1163    })
1164}
1165
1166/// Seconds since the Unix epoch, or zero on a clock set before 1970 — which reads as
1167/// "no dry run is fresh", the safe direction.
1168fn unix_now() -> u64 {
1169    std::time::SystemTime::now()
1170        .duration_since(std::time::UNIX_EPOCH)
1171        .map(|d| d.as_secs())
1172        .unwrap_or(0)
1173}
1174
1175/// Whether the stamp at `path` was written within the pick window before `now`.
1176///
1177/// A stamp from the future counts as stale, not fresh: a clock that jumped backwards
1178/// must not leave a permanently armed pick list behind it.
1179fn volume_stamp_fresh_at(path: &std::path::Path, now: u64) -> bool {
1180    let Ok(contents) = std::fs::read_to_string(path) else {
1181        return false;
1182    };
1183    let Ok(stamped) = contents.trim().parse::<u64>() else {
1184        return false;
1185    };
1186    stamped <= now && now - stamped <= constants::VOLUME_PICK_WINDOW_SECS
1187}
1188
1189fn volume_stamp_fresh(engine: &Engine) -> bool {
1190    volume_stamp_path(engine).is_some_and(|path| volume_stamp_fresh_at(&path, unix_now()))
1191}
1192
1193/// Record that a volume dry run for `engine` just finished, arming the real pick list.
1194///
1195/// Same pid-suffixed temp-then-rename dance as the registry save: a torn stamp would
1196/// parse as garbage and read as stale, which only costs one more dry run, but the
1197/// pattern is cheap and this file lives in the same directory.
1198fn write_volume_stamp_at(path: &std::path::Path, now: u64) -> std::io::Result<()> {
1199    if let Some(parent) = path.parent() {
1200        std::fs::create_dir_all(parent)?;
1201    }
1202    let tmp_path = path.with_extension(format!("stamp.{}.tmp", std::process::id()));
1203    std::fs::write(&tmp_path, now.to_string())?;
1204    std::fs::rename(&tmp_path, path)
1205}
1206
1207fn write_volume_stamp(engine: &Engine) {
1208    let Some(path) = volume_stamp_path(engine) else {
1209        return;
1210    };
1211    // Surfaced rather than swallowed: if the stamp cannot land, the promise "run the
1212    // same line again within ten minutes" is not going to hold, and the person should
1213    // hear that now instead of meeting another dry run.
1214    if let Err(why) = write_volume_stamp_at(&path, unix_now()) {
1215        output::print_info(&format!(
1216            "Could not record this dry run at {}: {why}. The real `--include-volumes` \
1217             run will redirect here again until it can be recorded.",
1218            path.display()
1219        ));
1220    }
1221}
1222
1223fn print_volume_dry_run(engine: &Engine, surface: &VolumeSurface) {
1224    match unused_volumes(engine, surface) {
1225        Err(why) => output::print_info(&format!(
1226            "{} could not name its unused volumes: {why}",
1227            engine.name
1228        )),
1229        Ok(names) if names.is_empty() => output::print_info(&format!(
1230            "{} reports no unused volumes right now.",
1231            engine.name
1232        )),
1233        Ok(names) => {
1234            let sizes = volume_sizes(engine, surface);
1235            output::print_wrapped(
1236                "  ",
1237                "These volumes are unused right now and would be offered on the pick \
1238                 list. The real list can be longer, because it is drawn after the \
1239                 containers are pruned and a stopped container keeps its anonymous \
1240                 volumes counted as in use until it is gone.",
1241            );
1242            println!();
1243            for (i, name) in names.iter().enumerate() {
1244                let size = sizes.get(name).map_or("size unknown", String::as_str);
1245                println!("  {:>3}. {:<44} {}", i + 1, name, size);
1246            }
1247            println!();
1248            output::print_wrapped(
1249                "  ",
1250                &format!(
1251                    "Nothing was deleted. To delete any of them, a person runs the \
1252                     line below at their own terminal within the next {} minutes and \
1253                     types the picks at the list; each pick is one unforced `volume \
1254                     rm`, and what it frees is measured and counted on `devp stats`, \
1255                     which a raw `{} volume rm` typed by hand would not be. After \
1256                     that the line shows this list again first.",
1257                    constants::VOLUME_PICK_WINDOW_SECS / 60,
1258                    engine.binary
1259                ),
1260            );
1261            println!();
1262            println!("    devp caches clear {} --include-volumes", engine.name);
1263            println!();
1264        }
1265    }
1266}
1267
1268/// Credit what was reclaimed to the machine's running container total.
1269fn record_container_clear(bytes: u64) {
1270    if bytes == 0 {
1271        return;
1272    }
1273    if let Ok(mut registry) = crate::config::Registry::load() {
1274        registry.record_container_clear(bytes);
1275        let _ = registry.save();
1276    }
1277}
1278
1279/// What is about to run, and what the engine says it is holding.
1280fn print_clear_plan(engine: &Engine, rows: &[Row], include_volumes: bool, dry_run: bool) {
1281    output::print_header(&format!("Clearing {}", engine.name));
1282    println!();
1283    for step in engine.reclaim {
1284        println!("  {:<40}  {}", step_command(engine, step).bold(), step.what);
1285    }
1286    println!();
1287    // The engine's reclaimable figure counts unused volumes, and not one of the commands
1288    // above touches one. Printing it whole would promise back space these steps cannot
1289    // give, so the volume row comes out of the estimate and is named as kept instead.
1290    let volumes: u64 = rows
1291        .iter()
1292        .filter(|r| r.kind.eq_ignore_ascii_case("Local Volumes"))
1293        .filter_map(|r| r.reclaimable)
1294        .sum();
1295    let reclaimable: u64 = rows.iter().filter_map(|r| r.reclaimable).sum();
1296    output::print_wrapped(
1297        "  ",
1298        &format!(
1299            "{} says about {} of this is reclaimable. None of the commands above touches \
1300             a volume, and dev-prune never runs `{} volume prune`: a volume holds the one \
1301             copy of what is in it, so a volume goes only when someone names it.",
1302            engine.name,
1303            output::format_bytes(reclaimable.saturating_sub(volumes)),
1304            engine.binary
1305        ),
1306    );
1307    if volumes > 0 {
1308        println!();
1309        if include_volumes {
1310            output::print_wrapped(
1311                "  ",
1312                &format!(
1313                    "{} of unused volumes will be offered after these steps run, listed \
1314                     by name for you to pick from. After, because pruning the stopped \
1315                     containers is what frees their anonymous volumes onto the list. \
1316                     Each pick is one `{} volume rm`, never forced.",
1317                    output::format_bytes(volumes),
1318                    engine.binary
1319                ),
1320            );
1321        } else if engine.volume_candidates.is_some() {
1322            output::print_wrapped(
1323                "  ",
1324                &format!(
1325                    "{} of unused volumes is being left alone. To pick which of them go, \
1326                     by name and one at a time, add `--include-volumes`.",
1327                    output::format_bytes(volumes)
1328                ),
1329            );
1330        } else {
1331            output::print_wrapped(
1332                "  ",
1333                &format!(
1334                    "{} of unused volumes is being left alone. If you have read what is \
1335                     in them and want it gone, that one is yours to run: `{} volume \
1336                     prune`.",
1337                    output::format_bytes(volumes),
1338                    engine.binary
1339                ),
1340            );
1341        }
1342    }
1343    if dry_run {
1344        println!();
1345        output::print_info("Dry run — nothing was deleted.");
1346    }
1347    println!();
1348}
1349
1350/// What actually went, measured against the engine's own answer afterwards.
1351fn print_clear_result(outcome: &ClearOutcome) {
1352    println!();
1353    for step in &outcome.steps {
1354        match &step.problem {
1355            None => println!("  {:<40}  done", step.command),
1356            Some(why) => println!("  {:<40}  {why}", step.command),
1357        }
1358    }
1359    println!();
1360    output::print_success(&format!(
1361        "{} freed — {} is now holding {}, down from {}.",
1362        output::format_bytes(outcome.freed()),
1363        outcome.engine,
1364        output::format_bytes(outcome.after),
1365        output::format_bytes(outcome.before)
1366    ));
1367}
1368
1369/// The engine names `devp caches containers <engine>` accepts.
1370pub fn known_engines() -> Vec<&'static str> {
1371    ENGINES.iter().map(|e| e.name).collect()
1372}
1373
1374/// Whether a name is one of them, so `caches clear docker` can say where to go instead.
1375pub fn is_engine(name: &str) -> bool {
1376    ENGINES.iter().any(|e| e.name.eq_ignore_ascii_case(name))
1377}
1378
1379fn print_report(reports: &[EngineReport], clusters: &[String], only: Option<&str>) {
1380    output::print_header("Container engines");
1381
1382    if reports.is_empty() {
1383        println!();
1384        output::print_info(&match only {
1385            Some(name) => format!("{name} is not installed on this machine."),
1386            None => format!(
1387                "No container engine found. dev-prune looks for {}.",
1388                known_engines().join(", ")
1389            ),
1390        });
1391        return;
1392    }
1393
1394    for report in reports {
1395        println!();
1396        match &report.state {
1397            EngineState::Unavailable(why) => print_unavailable(report.name, why),
1398            EngineState::Ready(rows) => print_engine(report.name, rows),
1399        }
1400    }
1401
1402    if !clusters.is_empty() {
1403        print_clusters(clusters);
1404    }
1405
1406    println!();
1407    output::print_wrapped(
1408        "  ",
1409        "Nothing above was deleted, and nothing dev-prune runs on a schedule will ever \
1410         delete it. To have dev-prune run the narrow ones for you — build cache, unused \
1411         images, stopped containers, and what that gave back counted on your stats — use \
1412         `devp caches clear <engine>`. It asks first, and it touches no volume on its \
1413         own: `--include-volumes` lists the unused ones by name for you to pick from, \
1414         one at a time, at a terminal.",
1415    );
1416}
1417
1418/// An engine that is installed and did not answer.
1419///
1420/// Quoted rather than paraphrased. "Cannot connect to the Docker daemon" and "permission
1421/// denied on /var/run/docker.sock" are different problems with different fixes, and a
1422/// tidy dev-prune sentence in place of the engine's own would hide which one this is.
1423fn print_unavailable(name: &str, why: &str) {
1424    println!("  {name}");
1425    println!();
1426    output::print_wrapped("    ", why);
1427    println!();
1428    output::print_wrapped(
1429        "    ",
1430        &format!(
1431            "So dev-prune has no figures for {name} — a blank rather than a zero. Start \
1432             it and run this again."
1433        ),
1434    );
1435}
1436
1437/// Column widths for the engine table, chosen so the longest real row — `Local
1438/// Volumes`, a ten-character size, a ten-character reclaimable figure and `41 items, 9
1439/// in use` — still lands inside the 90-column prose width the rest of the tool wraps to.
1440const KIND_WIDTH: usize = 16;
1441const SIZE_WIDTH: usize = 11;
1442
1443/// One engine's rows, its total, and the commands that would reclaim each part.
1444fn print_engine(name: &str, rows: &[Row]) {
1445    println!("  {name}");
1446    println!();
1447    for row in rows {
1448        println!(
1449            "  {:<KIND_WIDTH$}{:>SIZE_WIDTH$}   {}   {}",
1450            row.kind,
1451            row.bytes.map_or("—".to_string(), output::format_bytes),
1452            reclaimable_cell(row.reclaimable),
1453            counts(row),
1454        );
1455    }
1456
1457    let total: u64 = rows.iter().filter_map(|r| r.bytes).sum();
1458    let reclaimable: u64 = rows.iter().filter_map(|r| r.reclaimable).sum();
1459    println!();
1460    println!(
1461        "  {:<KIND_WIDTH$}{:>SIZE_WIDTH$}   {}",
1462        "Total",
1463        output::format_bytes(total),
1464        reclaimable_cell(Some(reclaimable)),
1465    );
1466
1467    let Some(engine) = ENGINES.iter().find(|e| e.name == name) else {
1468        return;
1469    };
1470    println!();
1471    println!(
1472        "  {:<COMMAND_WIDTH$}what it takes with it",
1473        "Reclaim it yourself"
1474    );
1475    for (command, cost) in engine.prune {
1476        println!("  {command:<COMMAND_WIDTH$}{cost}");
1477    }
1478    if !engine.prompts {
1479        println!();
1480        // Worth one line, because the last command in that list deletes data and the
1481        // reader's expectation comes from the other engines: everywhere else a prune
1482        // stops and asks, and a `-f` in an example is the tell that it would have. This
1483        // one has no such flag because it has no such question.
1484        output::print_wrapped(
1485            "  ",
1486            &format!(
1487                "{} asks nothing first. Each of those runs the moment you press Return,                  including the last one.",
1488                engine.name
1489            ),
1490        );
1491    }
1492}
1493
1494/// The `9.20 GiB reclaimable` cell, blank-padded when the engine did not say.
1495///
1496/// Padded rather than left empty so the counts column after it stays in one place down
1497/// the table; a row missing this figure otherwise pulls its neighbour eleven characters
1498/// left and the whole block stops reading as a table.
1499fn reclaimable_cell(bytes: Option<u64>) -> String {
1500    match bytes {
1501        Some(b) => format!("{:>SIZE_WIDTH$} reclaimable", output::format_bytes(b)),
1502        None => " ".repeat(SIZE_WIDTH + " reclaimable".len()),
1503    }
1504}
1505
1506/// The "12 of them, 3 in use" half of a row.
1507fn counts(row: &Row) -> String {
1508    match (row.total, row.active) {
1509        (Some(total), Some(active)) => format!(
1510            "{total} {}, {active} in use",
1511            output::plural(total as usize, "item", "items")
1512        ),
1513        (Some(total), None) => format!(
1514            "{total} {}",
1515            output::plural(total as usize, "item", "items")
1516        ),
1517        _ => String::new(),
1518    }
1519}
1520
1521/// The local Kubernetes clusters, named and deliberately unsized.
1522fn print_clusters(clusters: &[String]) {
1523    println!();
1524    println!("  kubernetes");
1525    println!();
1526    for name in clusters {
1527        println!("  {:<18} local cluster", name);
1528    }
1529    println!();
1530    output::print_wrapped(
1531        "  ",
1532        "Named and not sized on purpose: kind, k3d and minikube run their nodes as \
1533         containers or as a VM disk belonging to an engine above, so their disk is \
1534         already in that engine's total. A figure here would be the same gigabytes \
1535         counted twice. Delete a cluster with its own tool — `kind delete cluster`, \
1536         `minikube delete`, `k3d cluster delete` — which is also what releases the \
1537         space.",
1538    );
1539}
1540
1541/// The one-line-per-engine block `devp caches` prints under its own table.
1542///
1543/// Short on purpose. `devp caches` is a report about package managers, and this is the
1544/// sentence that stops someone concluding they have reclaimed everything there is when
1545/// the largest thing on the disk was never in the table.
1546pub fn print_summary(reports: &[EngineReport]) {
1547    if reports.is_empty() {
1548        return;
1549    }
1550    println!();
1551    output::print_header("Container engines");
1552    println!();
1553    for report in reports {
1554        match &report.state {
1555            EngineState::Ready(_) => {
1556                let total = report.total_bytes().unwrap_or(0);
1557                let reclaimable = report.reclaimable_bytes().unwrap_or(0);
1558                println!(
1559                    "  {:<30} {:>10}  {} reclaimable · devp caches {}",
1560                    report.name,
1561                    output::format_bytes(total),
1562                    output::format_bytes(reclaimable),
1563                    report.name,
1564                );
1565            }
1566            EngineState::Unavailable(_) => {
1567                // The reason is a sentence from the engine and this is a
1568                // one-line-per-engine block, so it is shown by the command with room
1569                // for it.
1570                println!(
1571                    "  {:<30} {:>10}  did not answer · devp caches {}",
1572                    report.name, "—", report.name,
1573                );
1574            }
1575        }
1576    }
1577    println!();
1578    output::print_wrapped(
1579        "  ",
1580        "Container images, volumes and build cache are not package manager caches and are \
1581         not in the total above — dev-prune reports them, and deletes nothing of them \
1582         except through `devp caches clear <engine>`, which asks first.",
1583    );
1584}
1585
1586#[cfg(test)]
1587mod tests {
1588    use super::*;
1589
1590    #[test]
1591    fn no_reclaim_step_can_touch_a_volume() {
1592        // The promise printed in the plan, checked against the argv rather than against
1593        // the prose. Every engine here has a `--volumes` spelling that would turn one of
1594        // these commands into the one that destroys data no registry can hand back, and
1595        // the only thing keeping it out is that nobody typed it into the table.
1596        for engine in ENGINES {
1597            for step in engine.reclaim {
1598                for arg in step.args {
1599                    assert!(
1600                        !arg.to_ascii_lowercase().contains("volume"),
1601                        "{} would run `{}`, which reaches a volume",
1602                        engine.name,
1603                        step_command(engine, step)
1604                    );
1605                }
1606            }
1607        }
1608    }
1609
1610    #[test]
1611    fn the_volume_surface_deletes_one_named_volume_and_never_forces_it() {
1612        // The `--include-volumes` promise, checked against the argv like the one above:
1613        // the listing asks only for what nothing uses, the deletion takes exactly one
1614        // name, and no spelling of force or bulk prune appears anywhere on the surface.
1615        for engine in ENGINES {
1616            let Some(surface) = &engine.volume_candidates else {
1617                continue;
1618            };
1619            assert_eq!(
1620                surface.rm_args,
1621                ["volume", "rm"],
1622                "{} would delete with `{}`, not a single unforced rm",
1623                engine.name,
1624                surface.rm_args.join(" ")
1625            );
1626            assert!(
1627                surface.ls_args.contains(&"dangling=true") && surface.ls_args.contains(&"-q"),
1628                "{} would list volumes without narrowing to unused names",
1629                engine.name
1630            );
1631            for arg in surface
1632                .ls_args
1633                .iter()
1634                .chain(surface.df_args)
1635                .chain(surface.rm_args)
1636            {
1637                let lower = arg.to_ascii_lowercase();
1638                assert!(
1639                    lower != "-f"
1640                        && lower != "--force"
1641                        && !lower.contains("prune")
1642                        && lower != "--volumes",
1643                    "{} carries `{arg}` on its volume surface",
1644                    engine.name
1645                );
1646            }
1647        }
1648    }
1649
1650    #[test]
1651    fn only_engines_that_can_name_unused_volumes_offer_them() {
1652        // nerdctl's `volume ls` filter knows label, name and size but not dangling
1653        // ("not supported yet" in its command reference), finch forwards nerdctl's
1654        // surface verbatim, and Apple's container exposes no per-volume usage. Offering
1655        // a pick list an engine cannot narrow to unused names would put in-use volumes
1656        // on it, so those three get a usage error instead.
1657        for engine in ENGINES {
1658            let can = matches!(engine.name, "docker" | "podman");
1659            assert_eq!(
1660                engine.volume_candidates.is_some(),
1661                can,
1662                "{} disagrees about offering volumes",
1663                engine.name
1664            );
1665        }
1666    }
1667
1668    #[test]
1669    fn a_missing_stamp_never_arms_the_pick_list() {
1670        let tmp = tempfile::TempDir::new().unwrap();
1671        let path = tmp.path().join("volume-pick-docker.stamp");
1672        assert!(!volume_stamp_fresh_at(&path, 1_000_000));
1673    }
1674
1675    #[test]
1676    fn a_fresh_stamp_arms_it_and_an_expired_one_does_not() {
1677        let tmp = tempfile::TempDir::new().unwrap();
1678        let path = tmp.path().join("volume-pick-docker.stamp");
1679        let now = 1_000_000;
1680        write_volume_stamp_at(&path, now).unwrap();
1681        assert!(volume_stamp_fresh_at(&path, now));
1682        assert!(volume_stamp_fresh_at(
1683            &path,
1684            now + constants::VOLUME_PICK_WINDOW_SECS
1685        ));
1686        assert!(!volume_stamp_fresh_at(
1687            &path,
1688            now + constants::VOLUME_PICK_WINDOW_SECS + 1
1689        ));
1690    }
1691
1692    #[test]
1693    fn a_stamp_from_the_future_reads_as_stale() {
1694        // A clock that jumped backwards must not leave a permanently armed pick list.
1695        let tmp = tempfile::TempDir::new().unwrap();
1696        let path = tmp.path().join("volume-pick-docker.stamp");
1697        write_volume_stamp_at(&path, 2_000_000).unwrap();
1698        assert!(!volume_stamp_fresh_at(&path, 1_000_000));
1699    }
1700
1701    #[test]
1702    fn a_garbled_stamp_reads_as_stale() {
1703        let tmp = tempfile::TempDir::new().unwrap();
1704        let path = tmp.path().join("volume-pick-docker.stamp");
1705        std::fs::write(&path, "not a number").unwrap();
1706        assert!(!volume_stamp_fresh_at(&path, 1_000_000));
1707    }
1708
1709    #[test]
1710    fn each_engine_stamps_its_own_file() {
1711        // A dry run for podman must not arm docker's pick list.
1712        let docker = ENGINES.iter().find(|e| e.name == "docker").unwrap();
1713        let podman = ENGINES.iter().find(|e| e.name == "podman").unwrap();
1714        let (Some(a), Some(b)) = (volume_stamp_path(docker), volume_stamp_path(podman)) else {
1715            // No resolvable config dir on this machine; nothing to compare.
1716            return;
1717        };
1718        assert_ne!(a, b);
1719    }
1720
1721    #[test]
1722    fn an_empty_answer_keeps_every_volume() {
1723        assert_eq!(parse_selection("", 5), Some(vec![]));
1724        assert_eq!(parse_selection("   \n", 5), Some(vec![]));
1725    }
1726
1727    #[test]
1728    fn all_is_every_row_once() {
1729        assert_eq!(parse_selection("all", 3), Some(vec![0, 1, 2]));
1730        assert_eq!(parse_selection("ALL", 3), Some(vec![0, 1, 2]));
1731    }
1732
1733    #[test]
1734    fn numbers_and_ranges_read_one_based_in_the_order_given() {
1735        assert_eq!(parse_selection("1 3-5", 5), Some(vec![0, 2, 3, 4]));
1736        assert_eq!(parse_selection("2,2,1", 3), Some(vec![1, 0]));
1737    }
1738
1739    #[test]
1740    fn anything_that_is_not_a_row_number_deletes_nothing() {
1741        // `None` is the safe verdict, and it must catch every malformed shape: the list
1742        // is one-based so zero names nothing, a number past the end names nothing, a
1743        // backwards range is a typo, and words (including anything shell-shaped) are
1744        // not numbers.
1745        assert_eq!(parse_selection("0", 3), None);
1746        assert_eq!(parse_selection("4", 3), None);
1747        assert_eq!(parse_selection("2-1", 3), None);
1748        assert_eq!(parse_selection("yes please", 3), None);
1749        assert_eq!(parse_selection("1; rm -rf /", 3), None);
1750    }
1751
1752    #[test]
1753    fn reads_dockers_volume_sizes_and_podmans() {
1754        // Docker's shape, from a run of `system df -v --format "{{json .}}"`: one
1755        // object, `Volumes` array, `Name` and a formatted `Size`.
1756        let docker = r#"{"Volumes":[
1757            {"Name":"chronos_cache_store","Size":"89B","Links":"0"},
1758            {"Name":"pgdata","Size":"1.2GB","Links":"0"}
1759        ]}"#;
1760        let sizes = parse_volume_sizes(docker);
1761        assert_eq!(
1762            sizes.get("chronos_cache_store").map(String::as_str),
1763            Some("89B")
1764        );
1765        assert_eq!(sizes.get("pgdata").map(String::as_str), Some("1.2GB"));
1766        // Podman spells the fields its own way and sometimes counts raw bytes; the
1767        // parser only has to find a name and render something, not match a format.
1768        let podman = r#"{"Volumes":[{"VolumeName":"data","Size":2048}]}"#;
1769        assert!(parse_volume_sizes(podman).contains_key("data"));
1770        // Garbage decorates nothing rather than failing anything.
1771        assert!(parse_volume_sizes("TYPE  TOTAL  ACTIVE").is_empty());
1772    }
1773
1774    #[test]
1775    fn every_engine_that_can_be_reported_can_be_cleared() {
1776        // `caches clear <engine>` accepts any name `is_engine` knows, so an engine with an
1777        // empty reclaim table would confirm, run nothing, and report freeing zero bytes.
1778        for engine in ENGINES {
1779            assert!(
1780                !engine.reclaim.is_empty(),
1781                "{} can be named to clear and has no steps",
1782                engine.name
1783            );
1784        }
1785    }
1786
1787    #[test]
1788    fn every_reclaim_step_answers_for_itself_without_a_prompt() {
1789        // These run without a terminal behind them — inside `devp caches clear --yes`, and
1790        // from a shell whose stdin the engine does not own. A step that stops to ask is a
1791        // hang, and dev-prune has already asked the only question that matters.
1792        //
1793        // The engines that ask take `-f` to say it has been answered. Apple's `container`
1794        // never asks and defines no such flag, so passing one there would not be caution:
1795        // it would be a usage error on every step, which is the same hang's worth of
1796        // nothing reclaimed by a different route.
1797        for engine in ENGINES {
1798            for step in engine.reclaim {
1799                let forced = step.args.contains(&"-f") || step.args.contains(&"--force");
1800                assert_eq!(
1801                    forced,
1802                    engine.prompts,
1803                    "`{}` disagrees with what {} does about prompting",
1804                    step_command(engine, step),
1805                    engine.name
1806                );
1807            }
1808        }
1809    }
1810
1811    #[test]
1812    fn reads_apples_one_object_for_all_three() {
1813        // Apple's `container` is not installed on the machines this is developed on, so
1814        // the shape is pinned from `DiskUsageStats`/`ResourceUsage` in apple/container
1815        // rather than from a run. If those field names ever change, this fails here
1816        // instead of the report quietly showing an engine holding nothing.
1817        let raw = r#"{
1818          "images" : { "total" : 12, "active" : 3, "sizeInBytes" : 4210000000,
1819                       "reclaimable" : 3020000000 },
1820          "containers" : { "total" : 7, "active" : 1, "sizeInBytes" : 118400000,
1821                           "reclaimable" : 118400000 },
1822          "volumes" : { "total" : 2, "active" : 0, "sizeInBytes" : 2048,
1823                        "reclaimable" : 2048 }
1824        }"#;
1825        let rows = parse_rows(raw);
1826        assert_eq!(rows.len(), 3);
1827        assert_eq!(rows[0].kind, "Images");
1828        assert_eq!(rows[0].total, Some(12));
1829        assert_eq!(rows[0].active, Some(3));
1830        assert_eq!(rows[0].bytes, Some(4_210_000_000));
1831        assert_eq!(rows[0].reclaimable, Some(3_020_000_000));
1832        // The label is the engine's own, not the JSON key: `volumes` prints as
1833        // `Local Volumes`, which is also what the volume-keeping arithmetic matches on.
1834        assert_eq!(rows[2].kind, "Local Volumes");
1835        assert_eq!(rows[2].bytes, Some(2_048));
1836    }
1837
1838    #[test]
1839    fn a_json_object_that_is_not_apples_is_not_read_as_apples() {
1840        // Docker printing a single row — one object, on one line — must still be read as
1841        // that row rather than swallowed by the branch above.
1842        let rows = parse_rows(
1843            r#"{"Active":"3","Reclaimable":"3.02GB (71%)","Size":"4.21GB","TotalCount":"12","Type":"Images"}"#,
1844        );
1845        assert_eq!(rows.len(), 1);
1846        assert_eq!(rows[0].kind, "Images");
1847        // Two of the three keys is not the shape either, and half a report is worse than
1848        // the honest "could not read this" the caller prints for no rows.
1849        assert!(parse_rows(r#"{"images":{"total":1},"containers":{"total":1}}"#).is_empty());
1850    }
1851
1852    #[test]
1853    fn parses_docker_si_sizes() {
1854        assert_eq!(parse_size("0B"), Some(0));
1855        assert_eq!(parse_size("1.093GB"), Some(1_093_000_000));
1856        assert_eq!(parse_size("987.4MB"), Some(987_400_000));
1857        assert_eq!(parse_size("1.5kB"), Some(1_500));
1858        assert_eq!(parse_size("2TB"), Some(2_000_000_000_000));
1859    }
1860
1861    #[test]
1862    fn iec_suffix_is_base_1024() {
1863        assert_eq!(parse_size("1KiB"), Some(1_024));
1864        assert_eq!(parse_size("1GiB"), Some(1_073_741_824));
1865        // The distinction is the whole reason the suffix is inspected: the same number
1866        // with the other suffix is 7% smaller.
1867        assert_ne!(parse_size("1GiB"), parse_size("1GB"));
1868    }
1869
1870    #[test]
1871    fn reclaimable_percentage_is_dropped() {
1872        assert_eq!(parse_size("1.093GB (100%)"), Some(1_093_000_000));
1873        assert_eq!(parse_size("0B (0%)"), Some(0));
1874    }
1875
1876    #[test]
1877    fn rejects_what_is_not_a_size() {
1878        assert_eq!(parse_size(""), None);
1879        assert_eq!(parse_size("N/A"), None);
1880        assert_eq!(parse_size("GB"), None);
1881        assert_eq!(parse_size("12 apples"), None);
1882    }
1883
1884    #[test]
1885    fn reads_dockers_one_object_per_line() {
1886        let raw = concat!(
1887            r#"{"Active":"3","Reclaimable":"3.02GB (71%)","Size":"4.21GB","TotalCount":"12","Type":"Images"}"#,
1888            "\n",
1889            r#"{"Active":"1","Reclaimable":"118.4MB (100%)","Size":"118.4MB","TotalCount":"7","Type":"Containers"}"#,
1890            "\n",
1891            r#"{"Active":"0","Reclaimable":"6.75GB","Size":"6.75GB","TotalCount":"41","Type":"Build Cache"}"#,
1892        );
1893        let rows = parse_rows(raw);
1894        assert_eq!(rows.len(), 3);
1895        assert_eq!(rows[0].kind, "Images");
1896        assert_eq!(rows[0].total, Some(12));
1897        assert_eq!(rows[0].active, Some(3));
1898        assert_eq!(rows[0].bytes, Some(4_210_000_000));
1899        assert_eq!(rows[0].reclaimable, Some(3_020_000_000));
1900        assert_eq!(rows[2].kind, "Build Cache");
1901        assert_eq!(rows[2].active, Some(0));
1902    }
1903
1904    #[test]
1905    fn reads_podmans_single_array() {
1906        let raw = r#"[
1907            {"Type":"Images","Total":4,"Active":2,"Size":"1.5GB","Reclaimable":"500MB (33%)"},
1908            {"Type":"Local Volumes","Total":2,"Active":0,"RawSize":2048,"RawReclaimable":2048,
1909             "Size":"2.048kB","Reclaimable":"2.048kB (100%)"}
1910        ]"#;
1911        let rows = parse_rows(raw);
1912        assert_eq!(rows.len(), 2);
1913        assert_eq!(rows[0].total, Some(4));
1914        assert_eq!(rows[0].bytes, Some(1_500_000_000));
1915        // The raw byte count wins over the string rounded from it.
1916        assert_eq!(rows[1].bytes, Some(2_048));
1917        assert_eq!(rows[1].reclaimable, Some(2_048));
1918    }
1919
1920    #[test]
1921    fn unparseable_output_is_no_rows_rather_than_zero_bytes() {
1922        assert!(parse_rows("").is_empty());
1923        assert!(parse_rows("Cannot connect to the Docker daemon").is_empty());
1924        // Valid JSON, but not a df row: no `Type` to name.
1925        assert!(parse_rows(r#"{"Size":"4GB"}"#).is_empty());
1926    }
1927
1928    #[test]
1929    fn local_contexts_are_told_from_remote_ones() {
1930        assert!(is_local_context("kind-dev"));
1931        assert!(is_local_context("k3d-test"));
1932        assert!(is_local_context("minikube"));
1933        assert!(is_local_context("docker-desktop"));
1934        assert!(!is_local_context("arn:aws:eks:us-east-1:1234:cluster/prod"));
1935        assert!(!is_local_context("gke_project_us-central1_prod"));
1936        // A remote cluster somebody named after the tool is still remote, but this is
1937        // name-matching and the alternative is dialling it. Naming a production context
1938        // `minikube` is a problem that predates dev-prune.
1939        assert!(!is_local_context("kindly-prod"));
1940    }
1941
1942    #[test]
1943    fn every_engine_prints_at_least_one_reclaim_command() {
1944        for engine in ENGINES {
1945            assert!(
1946                !engine.prune.is_empty(),
1947                "{} has no reclaim command to print",
1948                engine.name
1949            );
1950            for (command, _) in engine.prune {
1951                assert!(
1952                    command.starts_with(engine.binary),
1953                    "{command} is not a {} command",
1954                    engine.name
1955                );
1956            }
1957        }
1958    }
1959
1960    #[test]
1961    fn no_reclaim_command_is_ever_run_by_dev_prune() {
1962        // The guard is that `prune` is only ever read into a `println!`. If a future
1963        // change hands one of these to a process spawner, this file is where the review
1964        // has to notice, so the strings are checked to be commands for a human to type
1965        // rather than argv this code could execute.
1966        for engine in ENGINES {
1967            for (command, _) in engine.prune {
1968                assert!(
1969                    command.contains(' '),
1970                    "{command} looks like a bare program name"
1971                );
1972            }
1973        }
1974    }
1975}