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 here deletes anything, and nothing here ever will.** That is not caution, it
13// is the same rule the rest of the tool follows: dev-prune deletes only what it can
14// prove a lockfile rebuilds. A container image has no lockfile — the registry tag it came
15// from can be retagged or deleted, the Dockerfile that built it may not be on this disk,
16// and a named volume is the one thing in the whole system that is *not* reproducible at
17// all. So this command measures, names the command that would reclaim each part, and
18// stops. Running it is a decision only the person at the keyboard can make.
19//
20// The numbers come from the engine's own `system df`, not from a directory walk. On
21// Docker Desktop and Podman the store lives inside a VM disk image that the host cannot
22// see, and `~/.docker` is a config directory rather than the data — a size taken from
23// the filesystem would be wrong by orders of magnitude, and wrong in the reassuring
24// direction. Asking the engine is also the only way to learn what is *reclaimable*,
25// which is the figure that decides anything: 40 GB of images with 38 GB dangling is a
26// different situation from 40 GB with 2 GB dangling.
27//
28// Kubernetes is reported as names and no bytes. kind, k3d and minikube run their nodes
29// as containers or as a VM disk belonging to an engine that is already in the table
30// above, so a size beside a cluster name would be gigabytes counted twice.
31
32use std::path::PathBuf;
33
34use anyhow::Result;
35use serde_json::Value;
36
37use crate::adapters;
38use crate::constants;
39use crate::json;
40use crate::output;
41
42/// A container engine dev-prune knows how to ask about its disk use.
43struct Engine {
44    /// What it is called in output, and the name accepted on the command line.
45    name: &'static str,
46    /// The executable to look for and to ask.
47    binary: &'static str,
48    /// Arguments that make it print its disk usage as JSON.
49    ///
50    /// Docker and nerdctl take a Go template; Podman takes a format name. Both produce
51    /// the same four rows, which is why one parser reads either.
52    df_args: &'static [&'static str],
53    /// The reclaim commands worth printing, narrowest first, each with what it costs.
54    ///
55    /// Printed and never run. The order is the order to try them in: the build cache is
56    /// almost always the biggest win and the only one that costs nothing but a slower
57    /// next build, and the volume-deleting variant is last because it is the one that
58    /// destroys data no registry can hand back.
59    prune: &'static [(&'static str, &'static str)],
60}
61
62/// Width of the command column under "Reclaim it yourself".
63///
64/// `docker system prune --volumes` is the longest command printed at 29 characters, and
65/// every cost string below is written to fit the remainder inside 90 columns.
66const COMMAND_WIDTH: usize = 32;
67
68/// Every engine this command knows, in the order they are reported.
69const ENGINES: &[Engine] = &[
70    Engine {
71        name: "docker",
72        binary: "docker",
73        df_args: &["system", "df", "--format", "{{json .}}"],
74        prune: &[
75            (
76                "docker builder prune",
77                "the build cache; costs a slower next build",
78            ),
79            (
80                "docker image prune",
81                "dangling images no tag points at any more",
82            ),
83            (
84                "docker container prune",
85                "stopped containers and each writable layer",
86            ),
87            (
88                "docker system prune",
89                "the three above at once; volumes untouched",
90            ),
91            (
92                "docker system prune --volumes",
93                "adds unused volumes — the one that deletes data",
94            ),
95        ],
96    },
97    Engine {
98        name: "podman",
99        binary: "podman",
100        df_args: &["system", "df", "--format", "json"],
101        prune: &[
102            (
103                "podman system prune",
104                "stopped containers, networks, dangling images",
105            ),
106            (
107                "podman image prune -a",
108                "every image no container uses, tagged or not",
109            ),
110            (
111                "podman system prune --volumes",
112                "adds unused volumes — the one that deletes data",
113            ),
114        ],
115    },
116    Engine {
117        name: "nerdctl",
118        binary: "nerdctl",
119        df_args: &["system", "df", "--format", "{{json .}}"],
120        prune: &[
121            (
122                "nerdctl system prune",
123                "stopped containers, networks, dangling images",
124            ),
125            (
126                "nerdctl system prune --volumes",
127                "adds unused volumes — the one that deletes data",
128            ),
129        ],
130    },
131];
132
133/// One line of an engine's own disk-usage report.
134pub struct Row {
135    /// `Images`, `Containers`, `Local Volumes`, `Build Cache` — the engine's own word
136    /// for it, kept verbatim so the row matches what `docker system df` prints.
137    pub kind: String,
138    /// How many of them there are, when the engine says.
139    pub total: Option<u64>,
140    /// How many of those are in use.
141    pub active: Option<u64>,
142    /// Bytes on disk.
143    pub bytes: Option<u64>,
144    /// Bytes the engine believes it could give back.
145    pub reclaimable: Option<u64>,
146}
147
148/// What was found for one engine.
149pub enum EngineState {
150    /// It answered, and this is what it said.
151    Ready(Vec<Row>),
152    /// The binary is installed and the query did not answer. Almost always a daemon
153    /// that is not running, so the engine's own words are carried through rather than
154    /// guessed at.
155    Unavailable(String),
156}
157
158/// One engine's entry in the report. Engines that are not installed produce none.
159pub struct EngineReport {
160    /// The engine's name.
161    pub name: &'static str,
162    /// Whether it answered, and what with.
163    pub state: EngineState,
164}
165
166impl EngineReport {
167    /// Total bytes across every row, or `None` when the engine did not answer.
168    pub fn total_bytes(&self) -> Option<u64> {
169        match &self.state {
170            EngineState::Ready(rows) => Some(rows.iter().filter_map(|r| r.bytes).sum()),
171            EngineState::Unavailable(_) => None,
172        }
173    }
174
175    /// Total reclaimable bytes across every row, or `None` when it did not answer.
176    pub fn reclaimable_bytes(&self) -> Option<u64> {
177        match &self.state {
178            EngineState::Ready(rows) => Some(rows.iter().filter_map(|r| r.reclaimable).sum()),
179            EngineState::Unavailable(_) => None,
180        }
181    }
182}
183
184/// Ask every installed engine, or only the one named.
185///
186/// `None` for `only` means every engine found. An engine whose binary is not on `PATH`
187/// is absent from the result entirely — there is nothing to say about a tool that is
188/// not installed, and a row saying so on every machine without Podman would be noise.
189pub fn collect(only: Option<&str>) -> Vec<EngineReport> {
190    ENGINES
191        .iter()
192        .filter(|e| only.is_none_or(|name| e.name.eq_ignore_ascii_case(name)))
193        .filter(|e| adapters::binary_available(e.binary))
194        .map(probe)
195        .collect()
196}
197
198/// Ask one engine how much disk it is using.
199fn probe(engine: &Engine) -> EngineReport {
200    let captured = adapters::capture_allowing_failure(
201        engine.binary,
202        engine.df_args,
203        &query_dir(),
204        std::time::Duration::from_secs(constants::CONTAINER_QUERY_TIMEOUT_SECS),
205    );
206
207    let state =
208        match captured {
209            Ok(out) if out.ok => {
210                let rows = parse_rows(&out.stdout);
211                if rows.is_empty() {
212                    // It exited zero and said nothing this parser recognised. Reporting a
213                    // total of zero would be a claim about the machine that was never made.
214                    EngineState::Unavailable(format!(
215                        "{} answered `system df` in a format dev-prune could not read",
216                        engine.name
217                    ))
218                } else {
219                    EngineState::Ready(rows)
220                }
221            }
222            Ok(out) => EngineState::Unavailable(first_line(&out.stderr).unwrap_or_else(|| {
223                format!("`{} system df` failed without saying why", engine.name)
224            })),
225            Err(e) => EngineState::Unavailable(
226                first_line(&e.to_string())
227                    .unwrap_or_else(|| format!("`{} system df` could not be run", engine.name)),
228            ),
229        };
230
231    EngineReport {
232        name: engine.name,
233        state,
234    }
235}
236
237/// The engine's first line of complaint, which is the part a human needs.
238///
239/// Docker follows "cannot connect to the daemon" with a paragraph about how to start it;
240/// Podman follows its own with a stack of socket paths. Neither belongs in a table.
241fn first_line(raw: &str) -> Option<String> {
242    let line = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
243    // Generous, because this is wrapped rather than laid out in a column: Docker's
244    // daemon-down message is about 200 characters and saying most of it is worse than
245    // saying all of it. The cap is only here so a pathological engine cannot paste a
246    // megabyte of one-line output into the report or into `--json`.
247    Some(output::truncate_display(line, 400))
248}
249
250/// Where to run the queries from.
251///
252/// The home directory, for the same reason `devp caches` uses it: a project directory
253/// can carry a `.dockerignore`, a Compose file or a `DOCKER_HOST` override in a `.env`
254/// that would answer for that project rather than for the machine.
255fn query_dir() -> PathBuf {
256    dirs::home_dir()
257        .or_else(|| std::env::current_dir().ok())
258        .unwrap_or_else(|| PathBuf::from("."))
259}
260
261/// Read an engine's `system df` answer.
262///
263/// Docker prints one JSON object per line; Podman prints a single array. Accepting both
264/// is three lines and removes an entire class of "works on my machine" from a report
265/// whose whole job is to be believed.
266fn parse_rows(raw: &str) -> Vec<Row> {
267    let trimmed = raw.trim();
268    if trimmed.starts_with('[') {
269        return match serde_json::from_str::<Value>(trimmed) {
270            Ok(Value::Array(items)) => items.iter().filter_map(row_from).collect(),
271            _ => Vec::new(),
272        };
273    }
274    trimmed
275        .lines()
276        .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
277        .filter_map(|v| row_from(&v))
278        .collect()
279}
280
281/// One row, from whichever spelling of the fields this engine uses.
282fn row_from(v: &Value) -> Option<Row> {
283    let kind = v.get("Type")?.as_str()?.trim().to_string();
284    if kind.is_empty() {
285        return None;
286    }
287    Some(Row {
288        // Docker calls it `TotalCount`, Podman calls it `Total`.
289        total: count(v, "TotalCount").or_else(|| count(v, "Total")),
290        active: count(v, "Active"),
291        // Where the engine offers the raw byte count, it is the truth and the formatted
292        // string is a rounding of it: `1.093GB` has lost three digits before it is read.
293        bytes: bytes_at(v, "RawSize", "Size"),
294        reclaimable: bytes_at(v, "RawReclaimable", "Reclaimable"),
295        kind,
296    })
297}
298
299/// A count that may be a JSON number or a JSON string, because both are printed.
300fn count(v: &Value, key: &str) -> Option<u64> {
301    let field = v.get(key)?;
302    if let Some(n) = field.as_u64() {
303        return Some(n);
304    }
305    field.as_str()?.trim().parse().ok()
306}
307
308/// A size, preferring the engine's raw byte count over its formatted string.
309fn bytes_at(v: &Value, raw_key: &str, human_key: &str) -> Option<u64> {
310    if let Some(n) = v.get(raw_key).and_then(Value::as_u64) {
311        return Some(n);
312    }
313    parse_size(v.get(human_key)?.as_str()?)
314}
315
316/// Bytes out of a size the way a container engine writes one.
317///
318/// `1.093GB`, `0B`, `987.4MB`, and — for a reclaimable figure — `1.093GB (100%)`, where
319/// the percentage restates the same number and is dropped.
320fn parse_size(s: &str) -> Option<u64> {
321    // The percentage is the same figure expressed a second way.
322    let s = s.split('(').next()?.trim();
323    let split = s
324        .find(|c: char| !(c.is_ascii_digit() || c == '.'))
325        .unwrap_or(s.len());
326    let (number, unit) = s.split_at(split);
327    let value: f64 = number.parse().ok()?;
328    if !value.is_finite() || value < 0.0 {
329        return None;
330    }
331
332    let unit = unit.trim();
333    let mut chars = unit.chars();
334    let scale = chars.next();
335    // `GiB` is 1024-based and `GB` is 1000-based. Docker prints the second, Podman can
336    // print either, and across a 40 GB store the difference is about 3 GB — enough to
337    // change what someone decides to do about it.
338    let rest: String = chars.collect();
339    let base: f64 = if rest.eq_ignore_ascii_case("ib") {
340        1024.0
341    } else {
342        1000.0
343    };
344    let exponent = match scale.map(|c| c.to_ascii_lowercase()) {
345        None | Some('b') => 0,
346        Some('k') => 1,
347        Some('m') => 2,
348        Some('g') => 3,
349        Some('t') => 4,
350        Some('p') => 5,
351        _ => return None,
352    };
353
354    Some((value * base.powi(exponent)).round() as u64)
355}
356
357/// Kubernetes contexts on this machine that run on this machine.
358///
359/// Read out of the kubeconfig with `kubectl config get-contexts`, which touches no
360/// cluster and no network — a context pointing at a production cluster three time zones
361/// away is filtered out by name here rather than by being dialled.
362fn kube_contexts() -> Vec<String> {
363    if !adapters::binary_available("kubectl") {
364        return Vec::new();
365    }
366    let Ok(out) = adapters::capture_allowing_failure(
367        "kubectl",
368        &["config", "get-contexts", "-o", "name"],
369        &query_dir(),
370        std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
371    ) else {
372        return Vec::new();
373    };
374    if !out.ok {
375        return Vec::new();
376    }
377    out.stdout
378        .lines()
379        .map(str::trim)
380        .filter(|l| is_local_context(l))
381        .map(str::to_string)
382        .collect()
383}
384
385/// Whether a context name is one of the local-cluster tools rather than a remote.
386///
387/// Name-matching, because the alternative is contacting the cluster to find out, and a
388/// disk report has no business dialling a Kubernetes API server. Each of these names is
389/// fixed by the tool that writes it: `kind create cluster --name dev` always produces
390/// `kind-dev`, and minikube always writes `minikube`.
391fn is_local_context(name: &str) -> bool {
392    const LOCAL_PREFIXES: [&str; 2] = ["kind-", "k3d-"];
393    const LOCAL_EXACT: [&str; 5] = [
394        "minikube",
395        "docker-desktop",
396        "rancher-desktop",
397        "colima",
398        "microk8s",
399    ];
400    LOCAL_PREFIXES.iter().any(|p| name.starts_with(p))
401        || LOCAL_EXACT.iter().any(|n| name.eq_ignore_ascii_case(n))
402}
403
404/// Run `devp caches containers [engine]`, `devp caches docker` and `devp caches podman`.
405pub fn run(only: Option<&str>, json_output: bool) -> Result<()> {
406    if let Some(name) = only
407        && !ENGINES.iter().any(|e| e.name.eq_ignore_ascii_case(name))
408    {
409        return Err(anyhow::Error::new(crate::UsageError(format!(
410            "`{name}` is not a container engine dev-prune knows. Try one of: {}.",
411            known_engines().join(", ")
412        ))));
413    }
414
415    let pb = (!json_output).then(|| output::create_spinner("Asking the container engines..."));
416    let reports = collect(only);
417    let clusters = kube_contexts();
418    if let Some(pb) = pb {
419        pb.finish_and_clear();
420    }
421
422    if json_output {
423        return json::emit(&json::containers_document(&reports, &clusters));
424    }
425
426    print_report(&reports, &clusters, only);
427    Ok(())
428}
429
430/// The engine names `devp caches containers <engine>` accepts.
431pub fn known_engines() -> Vec<&'static str> {
432    ENGINES.iter().map(|e| e.name).collect()
433}
434
435/// Whether a name is one of them, so `caches clear docker` can say where to go instead.
436pub fn is_engine(name: &str) -> bool {
437    ENGINES.iter().any(|e| e.name.eq_ignore_ascii_case(name))
438}
439
440fn print_report(reports: &[EngineReport], clusters: &[String], only: Option<&str>) {
441    output::print_header("Container engines");
442
443    if reports.is_empty() {
444        println!();
445        output::print_info(&match only {
446            Some(name) => format!("{name} is not installed on this machine."),
447            None => format!(
448                "No container engine found. dev-prune looks for {}.",
449                known_engines().join(", ")
450            ),
451        });
452        return;
453    }
454
455    for report in reports {
456        println!();
457        match &report.state {
458            EngineState::Unavailable(why) => print_unavailable(report.name, why),
459            EngineState::Ready(rows) => print_engine(report.name, rows),
460        }
461    }
462
463    if !clusters.is_empty() {
464        print_clusters(clusters);
465    }
466
467    println!();
468    output::print_wrapped(
469        "  ",
470        "Nothing above was deleted, and nothing dev-prune runs on a schedule will ever \
471         delete it. An image has no lockfile to prove it can be rebuilt, and a named \
472         volume is the one thing here that cannot be rebuilt at all — so this command \
473         measures, prints the commands, and leaves the decision with you.",
474    );
475}
476
477/// An engine that is installed and did not answer.
478///
479/// Quoted rather than paraphrased. "Cannot connect to the Docker daemon" and "permission
480/// denied on /var/run/docker.sock" are different problems with different fixes, and a
481/// tidy dev-prune sentence in place of the engine's own would hide which one this is.
482fn print_unavailable(name: &str, why: &str) {
483    println!("  {name}");
484    println!();
485    output::print_wrapped("    ", why);
486    println!();
487    output::print_wrapped(
488        "    ",
489        &format!(
490            "So dev-prune has no figures for {name} — a blank rather than a zero. Start \
491             it and run this again."
492        ),
493    );
494}
495
496/// Column widths for the engine table, chosen so the longest real row — `Local
497/// Volumes`, a ten-character size, a ten-character reclaimable figure and `41 items, 9
498/// in use` — still lands inside the 90-column prose width the rest of the tool wraps to.
499const KIND_WIDTH: usize = 16;
500const SIZE_WIDTH: usize = 11;
501
502/// One engine's rows, its total, and the commands that would reclaim each part.
503fn print_engine(name: &str, rows: &[Row]) {
504    println!("  {name}");
505    println!();
506    for row in rows {
507        println!(
508            "  {:<KIND_WIDTH$}{:>SIZE_WIDTH$}   {}   {}",
509            row.kind,
510            row.bytes.map_or("—".to_string(), output::format_bytes),
511            reclaimable_cell(row.reclaimable),
512            counts(row),
513        );
514    }
515
516    let total: u64 = rows.iter().filter_map(|r| r.bytes).sum();
517    let reclaimable: u64 = rows.iter().filter_map(|r| r.reclaimable).sum();
518    println!();
519    println!(
520        "  {:<KIND_WIDTH$}{:>SIZE_WIDTH$}   {}",
521        "Total",
522        output::format_bytes(total),
523        reclaimable_cell(Some(reclaimable)),
524    );
525
526    let Some(engine) = ENGINES.iter().find(|e| e.name == name) else {
527        return;
528    };
529    println!();
530    println!(
531        "  {:<COMMAND_WIDTH$}what it takes with it",
532        "Reclaim it yourself"
533    );
534    for (command, cost) in engine.prune {
535        println!("  {command:<COMMAND_WIDTH$}{cost}");
536    }
537}
538
539/// The `9.20 GiB reclaimable` cell, blank-padded when the engine did not say.
540///
541/// Padded rather than left empty so the counts column after it stays in one place down
542/// the table; a row missing this figure otherwise pulls its neighbour eleven characters
543/// left and the whole block stops reading as a table.
544fn reclaimable_cell(bytes: Option<u64>) -> String {
545    match bytes {
546        Some(b) => format!("{:>SIZE_WIDTH$} reclaimable", output::format_bytes(b)),
547        None => " ".repeat(SIZE_WIDTH + " reclaimable".len()),
548    }
549}
550
551/// The "12 of them, 3 in use" half of a row.
552fn counts(row: &Row) -> String {
553    match (row.total, row.active) {
554        (Some(total), Some(active)) => format!(
555            "{total} {}, {active} in use",
556            output::plural(total as usize, "item", "items")
557        ),
558        (Some(total), None) => format!(
559            "{total} {}",
560            output::plural(total as usize, "item", "items")
561        ),
562        _ => String::new(),
563    }
564}
565
566/// The local Kubernetes clusters, named and deliberately unsized.
567fn print_clusters(clusters: &[String]) {
568    println!();
569    println!("  kubernetes");
570    println!();
571    for name in clusters {
572        println!("  {:<18} local cluster", name);
573    }
574    println!();
575    output::print_wrapped(
576        "  ",
577        "Named and not sized on purpose: kind, k3d and minikube run their nodes as \
578         containers or as a VM disk belonging to an engine above, so their disk is \
579         already in that engine's total. A figure here would be the same gigabytes \
580         counted twice. Delete a cluster with its own tool — `kind delete cluster`, \
581         `minikube delete`, `k3d cluster delete` — which is also what releases the \
582         space.",
583    );
584}
585
586/// The one-line-per-engine block `devp caches` prints under its own table.
587///
588/// Short on purpose. `devp caches` is a report about package managers, and this is the
589/// sentence that stops someone concluding they have reclaimed everything there is when
590/// the largest thing on the disk was never in the table.
591pub fn print_summary(reports: &[EngineReport]) {
592    if reports.is_empty() {
593        return;
594    }
595    println!();
596    output::print_header("Container engines");
597    println!();
598    for report in reports {
599        match &report.state {
600            EngineState::Ready(_) => {
601                let total = report.total_bytes().unwrap_or(0);
602                let reclaimable = report.reclaimable_bytes().unwrap_or(0);
603                println!(
604                    "  {:<30} {:>10}  {} reclaimable · devp caches {}",
605                    report.name,
606                    output::format_bytes(total),
607                    output::format_bytes(reclaimable),
608                    report.name,
609                );
610            }
611            EngineState::Unavailable(_) => {
612                // The reason is a sentence from the engine and this is a
613                // one-line-per-engine block, so it is shown by the command with room
614                // for it.
615                println!(
616                    "  {:<30} {:>10}  did not answer · devp caches {}",
617                    report.name, "—", report.name,
618                );
619            }
620        }
621    }
622    println!();
623    output::print_wrapped(
624        "  ",
625        "Container images, volumes and build cache are not package manager caches and are \
626         not in the total above — dev-prune reports them and never deletes them.",
627    );
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    #[test]
635    fn parses_docker_si_sizes() {
636        assert_eq!(parse_size("0B"), Some(0));
637        assert_eq!(parse_size("1.093GB"), Some(1_093_000_000));
638        assert_eq!(parse_size("987.4MB"), Some(987_400_000));
639        assert_eq!(parse_size("1.5kB"), Some(1_500));
640        assert_eq!(parse_size("2TB"), Some(2_000_000_000_000));
641    }
642
643    #[test]
644    fn iec_suffix_is_base_1024() {
645        assert_eq!(parse_size("1KiB"), Some(1_024));
646        assert_eq!(parse_size("1GiB"), Some(1_073_741_824));
647        // The distinction is the whole reason the suffix is inspected: the same number
648        // with the other suffix is 7% smaller.
649        assert_ne!(parse_size("1GiB"), parse_size("1GB"));
650    }
651
652    #[test]
653    fn reclaimable_percentage_is_dropped() {
654        assert_eq!(parse_size("1.093GB (100%)"), Some(1_093_000_000));
655        assert_eq!(parse_size("0B (0%)"), Some(0));
656    }
657
658    #[test]
659    fn rejects_what_is_not_a_size() {
660        assert_eq!(parse_size(""), None);
661        assert_eq!(parse_size("N/A"), None);
662        assert_eq!(parse_size("GB"), None);
663        assert_eq!(parse_size("12 apples"), None);
664    }
665
666    #[test]
667    fn reads_dockers_one_object_per_line() {
668        let raw = concat!(
669            r#"{"Active":"3","Reclaimable":"3.02GB (71%)","Size":"4.21GB","TotalCount":"12","Type":"Images"}"#,
670            "\n",
671            r#"{"Active":"1","Reclaimable":"118.4MB (100%)","Size":"118.4MB","TotalCount":"7","Type":"Containers"}"#,
672            "\n",
673            r#"{"Active":"0","Reclaimable":"6.75GB","Size":"6.75GB","TotalCount":"41","Type":"Build Cache"}"#,
674        );
675        let rows = parse_rows(raw);
676        assert_eq!(rows.len(), 3);
677        assert_eq!(rows[0].kind, "Images");
678        assert_eq!(rows[0].total, Some(12));
679        assert_eq!(rows[0].active, Some(3));
680        assert_eq!(rows[0].bytes, Some(4_210_000_000));
681        assert_eq!(rows[0].reclaimable, Some(3_020_000_000));
682        assert_eq!(rows[2].kind, "Build Cache");
683        assert_eq!(rows[2].active, Some(0));
684    }
685
686    #[test]
687    fn reads_podmans_single_array() {
688        let raw = r#"[
689            {"Type":"Images","Total":4,"Active":2,"Size":"1.5GB","Reclaimable":"500MB (33%)"},
690            {"Type":"Local Volumes","Total":2,"Active":0,"RawSize":2048,"RawReclaimable":2048,
691             "Size":"2.048kB","Reclaimable":"2.048kB (100%)"}
692        ]"#;
693        let rows = parse_rows(raw);
694        assert_eq!(rows.len(), 2);
695        assert_eq!(rows[0].total, Some(4));
696        assert_eq!(rows[0].bytes, Some(1_500_000_000));
697        // The raw byte count wins over the string rounded from it.
698        assert_eq!(rows[1].bytes, Some(2_048));
699        assert_eq!(rows[1].reclaimable, Some(2_048));
700    }
701
702    #[test]
703    fn unparseable_output_is_no_rows_rather_than_zero_bytes() {
704        assert!(parse_rows("").is_empty());
705        assert!(parse_rows("Cannot connect to the Docker daemon").is_empty());
706        // Valid JSON, but not a df row: no `Type` to name.
707        assert!(parse_rows(r#"{"Size":"4GB"}"#).is_empty());
708    }
709
710    #[test]
711    fn local_contexts_are_told_from_remote_ones() {
712        assert!(is_local_context("kind-dev"));
713        assert!(is_local_context("k3d-test"));
714        assert!(is_local_context("minikube"));
715        assert!(is_local_context("docker-desktop"));
716        assert!(!is_local_context("arn:aws:eks:us-east-1:1234:cluster/prod"));
717        assert!(!is_local_context("gke_project_us-central1_prod"));
718        // A remote cluster somebody named after the tool is still remote, but this is
719        // name-matching and the alternative is dialling it. Naming a production context
720        // `minikube` is a problem that predates dev-prune.
721        assert!(!is_local_context("kindly-prod"));
722    }
723
724    #[test]
725    fn every_engine_prints_at_least_one_reclaim_command() {
726        for engine in ENGINES {
727            assert!(
728                !engine.prune.is_empty(),
729                "{} has no reclaim command to print",
730                engine.name
731            );
732            for (command, _) in engine.prune {
733                assert!(
734                    command.starts_with(engine.binary),
735                    "{command} is not a {} command",
736                    engine.name
737                );
738            }
739        }
740    }
741
742    #[test]
743    fn no_reclaim_command_is_ever_run_by_dev_prune() {
744        // The guard is that `prune` is only ever read into a `println!`. If a future
745        // change hands one of these to a process spawner, this file is where the review
746        // has to notice, so the strings are checked to be commands for a human to type
747        // rather than argv this code could execute.
748        for engine in ENGINES {
749            for (command, _) in engine.prune {
750                assert!(
751                    command.contains(' '),
752                    "{command} looks like a bare program name"
753                );
754            }
755        }
756    }
757}