1use 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
42struct Engine {
44 name: &'static str,
46 binary: &'static str,
48 df_args: &'static [&'static str],
53 prune: &'static [(&'static str, &'static str)],
60}
61
62const COMMAND_WIDTH: usize = 32;
67
68const 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
133pub struct Row {
135 pub kind: String,
138 pub total: Option<u64>,
140 pub active: Option<u64>,
142 pub bytes: Option<u64>,
144 pub reclaimable: Option<u64>,
146}
147
148pub enum EngineState {
150 Ready(Vec<Row>),
152 Unavailable(String),
156}
157
158pub struct EngineReport {
160 pub name: &'static str,
162 pub state: EngineState,
164}
165
166impl EngineReport {
167 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 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
184pub 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
198fn 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 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
237fn first_line(raw: &str) -> Option<String> {
242 let line = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
243 Some(output::truncate_display(line, 400))
248}
249
250fn query_dir() -> PathBuf {
256 dirs::home_dir()
257 .or_else(|| std::env::current_dir().ok())
258 .unwrap_or_else(|| PathBuf::from("."))
259}
260
261fn 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
281fn 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 total: count(v, "TotalCount").or_else(|| count(v, "Total")),
290 active: count(v, "Active"),
291 bytes: bytes_at(v, "RawSize", "Size"),
294 reclaimable: bytes_at(v, "RawReclaimable", "Reclaimable"),
295 kind,
296 })
297}
298
299fn 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
308fn 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
316fn parse_size(s: &str) -> Option<u64> {
321 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 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
357fn 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
385fn 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
404pub 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
430pub fn known_engines() -> Vec<&'static str> {
432 ENGINES.iter().map(|e| e.name).collect()
433}
434
435pub 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
477fn 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
496const KIND_WIDTH: usize = 16;
500const SIZE_WIDTH: usize = 11;
501
502fn 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
539fn 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
551fn 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
566fn 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
586pub 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 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 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 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 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 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 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}