1use std::path::PathBuf;
40
41use anyhow::Result;
42use colored::Colorize;
43use serde_json::Value;
44
45use crate::adapters;
46use crate::constants;
47use crate::json;
48use crate::output;
49
50struct Engine {
52 name: &'static str,
54 binary: &'static str,
56 df_args: &'static [&'static str],
63 prune: &'static [(&'static str, &'static str)],
70 reclaim: &'static [ReclaimStep],
78 prompts: bool,
87}
88
89struct ReclaimStep {
91 what: &'static str,
93 args: &'static [&'static str],
99}
100
101const COMMAND_WIDTH: usize = 32;
106
107const ENGINES: &[Engine] = &[
109 Engine {
110 name: "docker",
111 binary: "docker",
112 prompts: true,
113 df_args: &["system", "df", "--format", "{{json .}}"],
114 prune: &[
115 (
116 "docker builder prune",
117 "the build cache; costs a slower next build",
118 ),
119 (
120 "docker image prune",
121 "dangling images no tag points at any more",
122 ),
123 (
124 "docker container prune",
125 "stopped containers and each writable layer",
126 ),
127 (
128 "docker system prune",
129 "the three above at once; volumes untouched",
130 ),
131 (
132 "docker system prune --volumes",
133 "adds unused volumes — the one that deletes data",
134 ),
135 ],
136 reclaim: &[
137 ReclaimStep {
138 what: "the build cache",
139 args: &["builder", "prune", "-a", "-f"],
140 },
141 ReclaimStep {
142 what: "images no container uses",
143 args: &["image", "prune", "-a", "-f"],
144 },
145 ReclaimStep {
146 what: "stopped containers and their writable layers",
147 args: &["container", "prune", "-f"],
148 },
149 ],
150 },
151 Engine {
152 name: "podman",
153 binary: "podman",
154 prompts: true,
155 df_args: &["system", "df", "--format", "json"],
156 prune: &[
157 (
158 "podman system prune",
159 "stopped containers, networks, dangling images",
160 ),
161 (
162 "podman image prune -a",
163 "every image no container uses, tagged or not",
164 ),
165 (
166 "podman system prune --volumes",
167 "adds unused volumes — the one that deletes data",
168 ),
169 ],
170 reclaim: &[
171 ReclaimStep {
172 what: "the build cache",
173 args: &["builder", "prune", "-a", "-f"],
174 },
175 ReclaimStep {
176 what: "images no container uses",
177 args: &["image", "prune", "-a", "-f"],
178 },
179 ReclaimStep {
180 what: "stopped containers and their writable layers",
181 args: &["container", "prune", "-f"],
182 },
183 ],
184 },
185 Engine {
186 name: "nerdctl",
187 binary: "nerdctl",
188 prompts: true,
189 df_args: &["system", "df", "--format", "{{json .}}"],
190 prune: &[
191 (
192 "nerdctl system prune",
193 "stopped containers, networks, dangling images",
194 ),
195 (
196 "nerdctl system prune --volumes",
197 "adds unused volumes — the one that deletes data",
198 ),
199 ],
200 reclaim: &[ReclaimStep {
205 what: "images, stopped containers and the build cache",
206 args: &["system", "prune", "-a", "-f"],
207 }],
208 },
209 Engine {
214 name: "finch",
215 binary: "finch",
216 prompts: true,
217 df_args: &["system", "df", "--format", "{{json .}}"],
218 prune: &[
219 (
220 "finch system prune",
221 "stopped containers, networks, dangling images",
222 ),
223 (
224 "finch system prune --volumes",
225 "adds unused volumes — the one that deletes data",
226 ),
227 ],
228 reclaim: &[ReclaimStep {
229 what: "images, stopped containers and the build cache",
230 args: &["system", "prune", "-a", "-f"],
231 }],
232 },
233 Engine {
244 name: "container",
245 binary: "container",
246 prompts: false,
247 df_args: &["system", "df", "--format", "json"],
248 prune: &[
249 (
250 "container image prune -a",
251 "every image no container uses, tagged or not",
252 ),
253 (
254 "container prune",
255 "stopped containers and their writable layers",
256 ),
257 (
258 "container volume prune",
259 "unused volumes — the one that deletes data",
260 ),
261 ],
262 reclaim: &[
263 ReclaimStep {
264 what: "images no container uses",
265 args: &["image", "prune", "-a"],
266 },
267 ReclaimStep {
268 what: "stopped containers and their writable layers",
269 args: &["prune"],
270 },
271 ],
272 },
273];
274
275pub struct Row {
277 pub kind: String,
280 pub total: Option<u64>,
282 pub active: Option<u64>,
284 pub bytes: Option<u64>,
286 pub reclaimable: Option<u64>,
288}
289
290pub enum EngineState {
292 Ready(Vec<Row>),
294 Unavailable(String),
298}
299
300pub struct EngineReport {
302 pub name: &'static str,
304 pub state: EngineState,
306}
307
308impl EngineReport {
309 pub fn total_bytes(&self) -> Option<u64> {
311 match &self.state {
312 EngineState::Ready(rows) => Some(rows.iter().filter_map(|r| r.bytes).sum()),
313 EngineState::Unavailable(_) => None,
314 }
315 }
316
317 pub fn reclaimable_bytes(&self) -> Option<u64> {
319 match &self.state {
320 EngineState::Ready(rows) => Some(rows.iter().filter_map(|r| r.reclaimable).sum()),
321 EngineState::Unavailable(_) => None,
322 }
323 }
324}
325
326pub fn collect(only: Option<&str>) -> Vec<EngineReport> {
332 ENGINES
333 .iter()
334 .filter(|e| only.is_none_or(|name| e.name.eq_ignore_ascii_case(name)))
335 .filter(|e| adapters::binary_available(e.binary))
336 .map(probe)
337 .collect()
338}
339
340fn probe(engine: &Engine) -> EngineReport {
342 let captured = adapters::capture_allowing_failure(
343 engine.binary,
344 engine.df_args,
345 &query_dir(),
346 std::time::Duration::from_secs(constants::CONTAINER_QUERY_TIMEOUT_SECS),
347 );
348
349 let state =
350 match captured {
351 Ok(out) if out.ok => {
352 let rows = parse_rows(&out.stdout);
353 if rows.is_empty() {
354 EngineState::Unavailable(format!(
357 "{} answered `system df` in a format dev-prune could not read",
358 engine.name
359 ))
360 } else {
361 EngineState::Ready(rows)
362 }
363 }
364 Ok(out) => EngineState::Unavailable(first_line(&out.stderr).unwrap_or_else(|| {
365 format!("`{} system df` failed without saying why", engine.name)
366 })),
367 Err(e) => EngineState::Unavailable(
368 first_line(&e.to_string())
369 .unwrap_or_else(|| format!("`{} system df` could not be run", engine.name)),
370 ),
371 };
372
373 EngineReport {
374 name: engine.name,
375 state,
376 }
377}
378
379fn first_line(raw: &str) -> Option<String> {
384 let line = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
385 Some(output::truncate_display(line, 400))
390}
391
392fn query_dir() -> PathBuf {
398 dirs::home_dir()
399 .or_else(|| std::env::current_dir().ok())
400 .unwrap_or_else(|| PathBuf::from("."))
401}
402
403fn parse_rows(raw: &str) -> Vec<Row> {
414 let trimmed = raw.trim();
415 if trimmed.starts_with('[') {
416 return match serde_json::from_str::<Value>(trimmed) {
417 Ok(Value::Array(items)) => items.iter().filter_map(row_from).collect(),
418 _ => Vec::new(),
419 };
420 }
421 if let Ok(v) = serde_json::from_str::<Value>(trimmed)
425 && let Some(rows) = apple_rows(&v)
426 {
427 return rows;
428 }
429 trimmed
430 .lines()
431 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
432 .filter_map(|v| row_from(&v))
433 .collect()
434}
435
436fn apple_rows(v: &Value) -> Option<Vec<Row>> {
447 let mut rows = Vec::new();
448 for (key, kind) in [
449 ("images", "Images"),
450 ("containers", "Containers"),
451 ("volumes", "Local Volumes"),
452 ] {
453 let usage = v.get(key)?.as_object()?;
454 rows.push(Row {
455 kind: kind.to_string(),
456 total: usage.get("total").and_then(Value::as_u64),
457 active: usage.get("active").and_then(Value::as_u64),
458 bytes: usage.get("sizeInBytes").and_then(Value::as_u64),
459 reclaimable: usage.get("reclaimable").and_then(Value::as_u64),
460 });
461 }
462 Some(rows)
463}
464
465fn row_from(v: &Value) -> Option<Row> {
467 let kind = v.get("Type")?.as_str()?.trim().to_string();
468 if kind.is_empty() {
469 return None;
470 }
471 Some(Row {
472 total: count(v, "TotalCount").or_else(|| count(v, "Total")),
474 active: count(v, "Active"),
475 bytes: bytes_at(v, "RawSize", "Size"),
478 reclaimable: bytes_at(v, "RawReclaimable", "Reclaimable"),
479 kind,
480 })
481}
482
483fn count(v: &Value, key: &str) -> Option<u64> {
485 let field = v.get(key)?;
486 if let Some(n) = field.as_u64() {
487 return Some(n);
488 }
489 field.as_str()?.trim().parse().ok()
490}
491
492fn bytes_at(v: &Value, raw_key: &str, human_key: &str) -> Option<u64> {
494 if let Some(n) = v.get(raw_key).and_then(Value::as_u64) {
495 return Some(n);
496 }
497 parse_size(v.get(human_key)?.as_str()?)
498}
499
500fn parse_size(s: &str) -> Option<u64> {
505 let s = s.split('(').next()?.trim();
507 let split = s
508 .find(|c: char| !(c.is_ascii_digit() || c == '.'))
509 .unwrap_or(s.len());
510 let (number, unit) = s.split_at(split);
511 let value: f64 = number.parse().ok()?;
512 if !value.is_finite() || value < 0.0 {
513 return None;
514 }
515
516 let unit = unit.trim();
517 let mut chars = unit.chars();
518 let scale = chars.next();
519 let rest: String = chars.collect();
523 let base: f64 = if rest.eq_ignore_ascii_case("ib") {
524 1024.0
525 } else {
526 1000.0
527 };
528 let exponent = match scale.map(|c| c.to_ascii_lowercase()) {
529 None | Some('b') => 0,
530 Some('k') => 1,
531 Some('m') => 2,
532 Some('g') => 3,
533 Some('t') => 4,
534 Some('p') => 5,
535 _ => return None,
536 };
537
538 Some((value * base.powi(exponent)).round() as u64)
539}
540
541fn kube_contexts() -> Vec<String> {
547 if !adapters::binary_available("kubectl") {
548 return Vec::new();
549 }
550 let Ok(out) = adapters::capture_allowing_failure(
551 "kubectl",
552 &["config", "get-contexts", "-o", "name"],
553 &query_dir(),
554 std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
555 ) else {
556 return Vec::new();
557 };
558 if !out.ok {
559 return Vec::new();
560 }
561 out.stdout
562 .lines()
563 .map(str::trim)
564 .filter(|l| is_local_context(l))
565 .map(str::to_string)
566 .collect()
567}
568
569fn is_local_context(name: &str) -> bool {
576 const LOCAL_PREFIXES: [&str; 2] = ["kind-", "k3d-"];
577 const LOCAL_EXACT: [&str; 5] = [
578 "minikube",
579 "docker-desktop",
580 "rancher-desktop",
581 "colima",
582 "microk8s",
583 ];
584 LOCAL_PREFIXES.iter().any(|p| name.starts_with(p))
585 || LOCAL_EXACT.iter().any(|n| name.eq_ignore_ascii_case(n))
586}
587
588pub fn run(only: Option<&str>, json_output: bool) -> Result<()> {
590 if let Some(name) = only
591 && !ENGINES.iter().any(|e| e.name.eq_ignore_ascii_case(name))
592 {
593 return Err(anyhow::Error::new(crate::UsageError(format!(
594 "`{name}` is not a container engine dev-prune knows. Try one of: {}.",
595 known_engines().join(", ")
596 ))));
597 }
598
599 let pb = (!json_output).then(|| output::create_spinner("Asking the container engines..."));
600 let reports = collect(only);
601 let clusters = kube_contexts();
602 if let Some(pb) = pb {
603 pb.finish_and_clear();
604 }
605
606 if json_output {
607 return json::emit(&json::containers_document(&reports, &clusters));
608 }
609
610 print_report(&reports, &clusters, only);
611 Ok(())
612}
613
614pub struct StepOutcome {
616 pub command: String,
618 pub what: &'static str,
620 pub problem: Option<String>,
622}
623
624pub struct ClearOutcome {
626 pub engine: &'static str,
628 pub steps: Vec<StepOutcome>,
630 pub before: u64,
632 pub after: u64,
634}
635
636impl ClearOutcome {
637 pub fn freed(&self) -> u64 {
639 self.before.saturating_sub(self.after)
640 }
641}
642
643pub fn run_clear(name: &str, yes: bool, dry_run: bool, json_output: bool) -> Result<()> {
661 let Some(engine) = ENGINES.iter().find(|e| e.name.eq_ignore_ascii_case(name)) else {
662 return Err(anyhow::Error::new(crate::UsageError(format!(
663 "`{name}` is not a container engine dev-prune knows. Try one of: {}.",
664 known_engines().join(", ")
665 ))));
666 };
667
668 if json_output && !yes && !dry_run {
671 return Err(anyhow::Error::new(crate::UsageError(
672 "`--json` cannot ask for confirmation — pass `--yes` as well, or `--dry-run` \
673 to see what would go."
674 .to_string(),
675 )));
676 }
677
678 if !adapters::binary_available(engine.binary) {
679 return Err(anyhow::Error::new(crate::UsageError(format!(
680 "{} is not installed on this machine, so there is nothing of its to clear.",
681 engine.name
682 ))));
683 }
684
685 let before = probe(engine);
686 let rows = match &before.state {
687 EngineState::Ready(rows) => rows,
688 EngineState::Unavailable(why) => {
691 return Err(anyhow::Error::new(crate::UsageError(format!(
692 "{} did not answer, so dev-prune will not start deleting on a guess: {why}",
693 engine.name
694 ))));
695 }
696 };
697 let before_bytes: u64 = rows.iter().filter_map(|r| r.bytes).sum();
698
699 if !json_output {
700 print_clear_plan(engine, rows, dry_run);
701 }
702 if dry_run {
703 if json_output {
704 let planned = ClearOutcome {
705 engine: engine.name,
706 steps: planned_steps(engine),
707 before: before_bytes,
708 after: before_bytes,
709 };
710 return json::emit(&json::containers_clear_document(&planned, true));
711 }
712 return Ok(());
713 }
714 if !json_output && !crate::commands::caches::confirm_clear(yes) {
715 output::print_info("Nothing was cleared.");
716 return Ok(());
717 }
718
719 let steps: Vec<StepOutcome> = engine.reclaim.iter().map(|s| run_step(engine, s)).collect();
720
721 let after_bytes = probe(engine).total_bytes().unwrap_or(before_bytes);
726 let outcome = ClearOutcome {
727 engine: engine.name,
728 steps,
729 before: before_bytes,
730 after: after_bytes,
731 };
732 record_container_clear(outcome.freed());
733
734 if json_output {
735 json::emit(&json::containers_clear_document(&outcome, false))?;
736 } else {
737 print_clear_result(&outcome);
738 }
739
740 let failed = outcome.steps.iter().filter(|s| s.problem.is_some()).count();
743 if failed > 0 {
744 anyhow::bail!(
745 "{failed} of {}'s reclaim steps did not finish.",
746 outcome.engine
747 );
748 }
749 Ok(())
750}
751
752fn planned_steps(engine: &Engine) -> Vec<StepOutcome> {
754 engine
755 .reclaim
756 .iter()
757 .map(|s| StepOutcome {
758 command: step_command(engine, s),
759 what: s.what,
760 problem: None,
761 })
762 .collect()
763}
764
765fn step_command(engine: &Engine, step: &ReclaimStep) -> String {
767 format!("{} {}", engine.binary, step.args.join(" "))
768}
769
770fn run_step(engine: &Engine, step: &ReclaimStep) -> StepOutcome {
772 let captured = adapters::capture_allowing_failure(
773 engine.binary,
774 step.args,
775 &query_dir(),
776 std::time::Duration::from_secs(constants::CONTAINER_PRUNE_TIMEOUT_SECS),
777 );
778 let problem = match captured {
779 Ok(out) if out.ok => None,
780 Ok(out) => Some(first_line(&out.stderr).unwrap_or_else(|| {
781 format!("`{}` failed without saying why", step_command(engine, step))
782 })),
783 Err(e) => Some(
784 first_line(&e.to_string())
785 .unwrap_or_else(|| format!("`{}` could not be run", step_command(engine, step))),
786 ),
787 };
788 StepOutcome {
789 command: step_command(engine, step),
790 what: step.what,
791 problem,
792 }
793}
794
795fn record_container_clear(bytes: u64) {
797 if bytes == 0 {
798 return;
799 }
800 if let Ok(mut registry) = crate::config::Registry::load() {
801 registry.record_container_clear(bytes);
802 let _ = registry.save();
803 }
804}
805
806fn print_clear_plan(engine: &Engine, rows: &[Row], dry_run: bool) {
808 output::print_header(&format!("Clearing {}", engine.name));
809 println!();
810 for step in engine.reclaim {
811 println!(" {:<40} {}", step_command(engine, step).bold(), step.what);
812 }
813 println!();
814 let volumes: u64 = rows
818 .iter()
819 .filter(|r| r.kind.eq_ignore_ascii_case("Local Volumes"))
820 .filter_map(|r| r.reclaimable)
821 .sum();
822 let reclaimable: u64 = rows.iter().filter_map(|r| r.reclaimable).sum();
823 output::print_wrapped(
824 " ",
825 &format!(
826 "{} says about {} of this is reclaimable. Volumes are not touched by any of \
827 the commands above and never will be — a named volume is the one thing here \
828 that cannot be rebuilt from anywhere.",
829 engine.name,
830 output::format_bytes(reclaimable.saturating_sub(volumes))
831 ),
832 );
833 if volumes > 0 {
834 println!();
835 output::print_wrapped(
836 " ",
837 &format!(
838 "{} of unused volumes is being left alone. If you have read what is in \
839 them and want it gone, that one is yours to run: `{} volume prune`.",
840 output::format_bytes(volumes),
841 engine.binary
842 ),
843 );
844 }
845 if dry_run {
846 println!();
847 output::print_info("Dry run — nothing was deleted.");
848 }
849 println!();
850}
851
852fn print_clear_result(outcome: &ClearOutcome) {
854 println!();
855 for step in &outcome.steps {
856 match &step.problem {
857 None => println!(" {:<40} done", step.command),
858 Some(why) => println!(" {:<40} {why}", step.command),
859 }
860 }
861 println!();
862 output::print_success(&format!(
863 "{} freed — {} is now holding {}, down from {}.",
864 output::format_bytes(outcome.freed()),
865 outcome.engine,
866 output::format_bytes(outcome.after),
867 output::format_bytes(outcome.before)
868 ));
869}
870
871pub fn known_engines() -> Vec<&'static str> {
873 ENGINES.iter().map(|e| e.name).collect()
874}
875
876pub fn is_engine(name: &str) -> bool {
878 ENGINES.iter().any(|e| e.name.eq_ignore_ascii_case(name))
879}
880
881fn print_report(reports: &[EngineReport], clusters: &[String], only: Option<&str>) {
882 output::print_header("Container engines");
883
884 if reports.is_empty() {
885 println!();
886 output::print_info(&match only {
887 Some(name) => format!("{name} is not installed on this machine."),
888 None => format!(
889 "No container engine found. dev-prune looks for {}.",
890 known_engines().join(", ")
891 ),
892 });
893 return;
894 }
895
896 for report in reports {
897 println!();
898 match &report.state {
899 EngineState::Unavailable(why) => print_unavailable(report.name, why),
900 EngineState::Ready(rows) => print_engine(report.name, rows),
901 }
902 }
903
904 if !clusters.is_empty() {
905 print_clusters(clusters);
906 }
907
908 println!();
909 output::print_wrapped(
910 " ",
911 "Nothing above was deleted, and nothing dev-prune runs on a schedule will ever \
912 delete it. To have dev-prune run the narrow ones for you — build cache, unused \
913 images, stopped containers, and what that gave back counted on your stats — use \
914 `devp caches clear <engine>`. It asks first, and it never touches a volume: that \
915 is the one thing here that cannot be rebuilt at all, so it stays yours to run.",
916 );
917}
918
919fn print_unavailable(name: &str, why: &str) {
925 println!(" {name}");
926 println!();
927 output::print_wrapped(" ", why);
928 println!();
929 output::print_wrapped(
930 " ",
931 &format!(
932 "So dev-prune has no figures for {name} — a blank rather than a zero. Start \
933 it and run this again."
934 ),
935 );
936}
937
938const KIND_WIDTH: usize = 16;
942const SIZE_WIDTH: usize = 11;
943
944fn print_engine(name: &str, rows: &[Row]) {
946 println!(" {name}");
947 println!();
948 for row in rows {
949 println!(
950 " {:<KIND_WIDTH$}{:>SIZE_WIDTH$} {} {}",
951 row.kind,
952 row.bytes.map_or("—".to_string(), output::format_bytes),
953 reclaimable_cell(row.reclaimable),
954 counts(row),
955 );
956 }
957
958 let total: u64 = rows.iter().filter_map(|r| r.bytes).sum();
959 let reclaimable: u64 = rows.iter().filter_map(|r| r.reclaimable).sum();
960 println!();
961 println!(
962 " {:<KIND_WIDTH$}{:>SIZE_WIDTH$} {}",
963 "Total",
964 output::format_bytes(total),
965 reclaimable_cell(Some(reclaimable)),
966 );
967
968 let Some(engine) = ENGINES.iter().find(|e| e.name == name) else {
969 return;
970 };
971 println!();
972 println!(
973 " {:<COMMAND_WIDTH$}what it takes with it",
974 "Reclaim it yourself"
975 );
976 for (command, cost) in engine.prune {
977 println!(" {command:<COMMAND_WIDTH$}{cost}");
978 }
979 if !engine.prompts {
980 println!();
981 output::print_wrapped(
986 " ",
987 &format!(
988 "{} asks nothing first. Each of those runs the moment you press Return, including the last one.",
989 engine.name
990 ),
991 );
992 }
993}
994
995fn reclaimable_cell(bytes: Option<u64>) -> String {
1001 match bytes {
1002 Some(b) => format!("{:>SIZE_WIDTH$} reclaimable", output::format_bytes(b)),
1003 None => " ".repeat(SIZE_WIDTH + " reclaimable".len()),
1004 }
1005}
1006
1007fn counts(row: &Row) -> String {
1009 match (row.total, row.active) {
1010 (Some(total), Some(active)) => format!(
1011 "{total} {}, {active} in use",
1012 output::plural(total as usize, "item", "items")
1013 ),
1014 (Some(total), None) => format!(
1015 "{total} {}",
1016 output::plural(total as usize, "item", "items")
1017 ),
1018 _ => String::new(),
1019 }
1020}
1021
1022fn print_clusters(clusters: &[String]) {
1024 println!();
1025 println!(" kubernetes");
1026 println!();
1027 for name in clusters {
1028 println!(" {:<18} local cluster", name);
1029 }
1030 println!();
1031 output::print_wrapped(
1032 " ",
1033 "Named and not sized on purpose: kind, k3d and minikube run their nodes as \
1034 containers or as a VM disk belonging to an engine above, so their disk is \
1035 already in that engine's total. A figure here would be the same gigabytes \
1036 counted twice. Delete a cluster with its own tool — `kind delete cluster`, \
1037 `minikube delete`, `k3d cluster delete` — which is also what releases the \
1038 space.",
1039 );
1040}
1041
1042pub fn print_summary(reports: &[EngineReport]) {
1048 if reports.is_empty() {
1049 return;
1050 }
1051 println!();
1052 output::print_header("Container engines");
1053 println!();
1054 for report in reports {
1055 match &report.state {
1056 EngineState::Ready(_) => {
1057 let total = report.total_bytes().unwrap_or(0);
1058 let reclaimable = report.reclaimable_bytes().unwrap_or(0);
1059 println!(
1060 " {:<30} {:>10} {} reclaimable · devp caches {}",
1061 report.name,
1062 output::format_bytes(total),
1063 output::format_bytes(reclaimable),
1064 report.name,
1065 );
1066 }
1067 EngineState::Unavailable(_) => {
1068 println!(
1072 " {:<30} {:>10} did not answer · devp caches {}",
1073 report.name, "—", report.name,
1074 );
1075 }
1076 }
1077 }
1078 println!();
1079 output::print_wrapped(
1080 " ",
1081 "Container images, volumes and build cache are not package manager caches and are \
1082 not in the total above — dev-prune reports them and never deletes them.",
1083 );
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088 use super::*;
1089
1090 #[test]
1091 fn no_reclaim_step_can_touch_a_volume() {
1092 for engine in ENGINES {
1097 for step in engine.reclaim {
1098 for arg in step.args {
1099 assert!(
1100 !arg.to_ascii_lowercase().contains("volume"),
1101 "{} would run `{}`, which reaches a volume",
1102 engine.name,
1103 step_command(engine, step)
1104 );
1105 }
1106 }
1107 }
1108 }
1109
1110 #[test]
1111 fn every_engine_that_can_be_reported_can_be_cleared() {
1112 for engine in ENGINES {
1115 assert!(
1116 !engine.reclaim.is_empty(),
1117 "{} can be named to clear and has no steps",
1118 engine.name
1119 );
1120 }
1121 }
1122
1123 #[test]
1124 fn every_reclaim_step_answers_for_itself_without_a_prompt() {
1125 for engine in ENGINES {
1134 for step in engine.reclaim {
1135 let forced = step.args.contains(&"-f") || step.args.contains(&"--force");
1136 assert_eq!(
1137 forced,
1138 engine.prompts,
1139 "`{}` disagrees with what {} does about prompting",
1140 step_command(engine, step),
1141 engine.name
1142 );
1143 }
1144 }
1145 }
1146
1147 #[test]
1148 fn reads_apples_one_object_for_all_three() {
1149 let raw = r#"{
1154 "images" : { "total" : 12, "active" : 3, "sizeInBytes" : 4210000000,
1155 "reclaimable" : 3020000000 },
1156 "containers" : { "total" : 7, "active" : 1, "sizeInBytes" : 118400000,
1157 "reclaimable" : 118400000 },
1158 "volumes" : { "total" : 2, "active" : 0, "sizeInBytes" : 2048,
1159 "reclaimable" : 2048 }
1160 }"#;
1161 let rows = parse_rows(raw);
1162 assert_eq!(rows.len(), 3);
1163 assert_eq!(rows[0].kind, "Images");
1164 assert_eq!(rows[0].total, Some(12));
1165 assert_eq!(rows[0].active, Some(3));
1166 assert_eq!(rows[0].bytes, Some(4_210_000_000));
1167 assert_eq!(rows[0].reclaimable, Some(3_020_000_000));
1168 assert_eq!(rows[2].kind, "Local Volumes");
1171 assert_eq!(rows[2].bytes, Some(2_048));
1172 }
1173
1174 #[test]
1175 fn a_json_object_that_is_not_apples_is_not_read_as_apples() {
1176 let rows = parse_rows(
1179 r#"{"Active":"3","Reclaimable":"3.02GB (71%)","Size":"4.21GB","TotalCount":"12","Type":"Images"}"#,
1180 );
1181 assert_eq!(rows.len(), 1);
1182 assert_eq!(rows[0].kind, "Images");
1183 assert!(parse_rows(r#"{"images":{"total":1},"containers":{"total":1}}"#).is_empty());
1186 }
1187
1188 #[test]
1189 fn parses_docker_si_sizes() {
1190 assert_eq!(parse_size("0B"), Some(0));
1191 assert_eq!(parse_size("1.093GB"), Some(1_093_000_000));
1192 assert_eq!(parse_size("987.4MB"), Some(987_400_000));
1193 assert_eq!(parse_size("1.5kB"), Some(1_500));
1194 assert_eq!(parse_size("2TB"), Some(2_000_000_000_000));
1195 }
1196
1197 #[test]
1198 fn iec_suffix_is_base_1024() {
1199 assert_eq!(parse_size("1KiB"), Some(1_024));
1200 assert_eq!(parse_size("1GiB"), Some(1_073_741_824));
1201 assert_ne!(parse_size("1GiB"), parse_size("1GB"));
1204 }
1205
1206 #[test]
1207 fn reclaimable_percentage_is_dropped() {
1208 assert_eq!(parse_size("1.093GB (100%)"), Some(1_093_000_000));
1209 assert_eq!(parse_size("0B (0%)"), Some(0));
1210 }
1211
1212 #[test]
1213 fn rejects_what_is_not_a_size() {
1214 assert_eq!(parse_size(""), None);
1215 assert_eq!(parse_size("N/A"), None);
1216 assert_eq!(parse_size("GB"), None);
1217 assert_eq!(parse_size("12 apples"), None);
1218 }
1219
1220 #[test]
1221 fn reads_dockers_one_object_per_line() {
1222 let raw = concat!(
1223 r#"{"Active":"3","Reclaimable":"3.02GB (71%)","Size":"4.21GB","TotalCount":"12","Type":"Images"}"#,
1224 "\n",
1225 r#"{"Active":"1","Reclaimable":"118.4MB (100%)","Size":"118.4MB","TotalCount":"7","Type":"Containers"}"#,
1226 "\n",
1227 r#"{"Active":"0","Reclaimable":"6.75GB","Size":"6.75GB","TotalCount":"41","Type":"Build Cache"}"#,
1228 );
1229 let rows = parse_rows(raw);
1230 assert_eq!(rows.len(), 3);
1231 assert_eq!(rows[0].kind, "Images");
1232 assert_eq!(rows[0].total, Some(12));
1233 assert_eq!(rows[0].active, Some(3));
1234 assert_eq!(rows[0].bytes, Some(4_210_000_000));
1235 assert_eq!(rows[0].reclaimable, Some(3_020_000_000));
1236 assert_eq!(rows[2].kind, "Build Cache");
1237 assert_eq!(rows[2].active, Some(0));
1238 }
1239
1240 #[test]
1241 fn reads_podmans_single_array() {
1242 let raw = r#"[
1243 {"Type":"Images","Total":4,"Active":2,"Size":"1.5GB","Reclaimable":"500MB (33%)"},
1244 {"Type":"Local Volumes","Total":2,"Active":0,"RawSize":2048,"RawReclaimable":2048,
1245 "Size":"2.048kB","Reclaimable":"2.048kB (100%)"}
1246 ]"#;
1247 let rows = parse_rows(raw);
1248 assert_eq!(rows.len(), 2);
1249 assert_eq!(rows[0].total, Some(4));
1250 assert_eq!(rows[0].bytes, Some(1_500_000_000));
1251 assert_eq!(rows[1].bytes, Some(2_048));
1253 assert_eq!(rows[1].reclaimable, Some(2_048));
1254 }
1255
1256 #[test]
1257 fn unparseable_output_is_no_rows_rather_than_zero_bytes() {
1258 assert!(parse_rows("").is_empty());
1259 assert!(parse_rows("Cannot connect to the Docker daemon").is_empty());
1260 assert!(parse_rows(r#"{"Size":"4GB"}"#).is_empty());
1262 }
1263
1264 #[test]
1265 fn local_contexts_are_told_from_remote_ones() {
1266 assert!(is_local_context("kind-dev"));
1267 assert!(is_local_context("k3d-test"));
1268 assert!(is_local_context("minikube"));
1269 assert!(is_local_context("docker-desktop"));
1270 assert!(!is_local_context("arn:aws:eks:us-east-1:1234:cluster/prod"));
1271 assert!(!is_local_context("gke_project_us-central1_prod"));
1272 assert!(!is_local_context("kindly-prod"));
1276 }
1277
1278 #[test]
1279 fn every_engine_prints_at_least_one_reclaim_command() {
1280 for engine in ENGINES {
1281 assert!(
1282 !engine.prune.is_empty(),
1283 "{} has no reclaim command to print",
1284 engine.name
1285 );
1286 for (command, _) in engine.prune {
1287 assert!(
1288 command.starts_with(engine.binary),
1289 "{command} is not a {} command",
1290 engine.name
1291 );
1292 }
1293 }
1294 }
1295
1296 #[test]
1297 fn no_reclaim_command_is_ever_run_by_dev_prune() {
1298 for engine in ENGINES {
1303 for (command, _) in engine.prune {
1304 assert!(
1305 command.contains(' '),
1306 "{command} looks like a bare program name"
1307 );
1308 }
1309 }
1310 }
1311}