1use 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
55struct Engine {
57 name: &'static str,
59 binary: &'static str,
61 df_args: &'static [&'static str],
68 prune: &'static [(&'static str, &'static str)],
75 reclaim: &'static [ReclaimStep],
83 prompts: bool,
92 volume_candidates: Option<VolumeSurface>,
101}
102
103struct VolumeSurface {
110 ls_args: &'static [&'static str],
112 df_args: &'static [&'static str],
117 rm_args: &'static [&'static str],
119}
120
121struct ReclaimStep {
123 what: &'static str,
125 args: &'static [&'static str],
131}
132
133const COMMAND_WIDTH: usize = 32;
138
139const 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 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 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 reclaim: &[ReclaimStep {
252 what: "images, stopped containers and the build cache",
253 args: &["system", "prune", "-a", "-f"],
254 }],
255 volume_candidates: None,
256 },
257 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 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
325pub struct Row {
327 pub kind: String,
330 pub total: Option<u64>,
332 pub active: Option<u64>,
334 pub bytes: Option<u64>,
336 pub reclaimable: Option<u64>,
338}
339
340pub enum EngineState {
342 Ready(Vec<Row>),
344 Unavailable(String),
348}
349
350pub struct EngineReport {
352 pub name: &'static str,
354 pub state: EngineState,
356}
357
358impl EngineReport {
359 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 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
376pub 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
390fn 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 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
429fn first_line(raw: &str) -> Option<String> {
434 let line = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
435 Some(output::truncate_display(line, 400))
440}
441
442fn query_dir() -> PathBuf {
448 dirs::home_dir()
449 .or_else(|| std::env::current_dir().ok())
450 .unwrap_or_else(|| PathBuf::from("."))
451}
452
453fn 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 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
486fn 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
515fn 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 total: count(v, "TotalCount").or_else(|| count(v, "Total")),
524 active: count(v, "Active"),
525 bytes: bytes_at(v, "RawSize", "Size"),
528 reclaimable: bytes_at(v, "RawReclaimable", "Reclaimable"),
529 kind,
530 })
531}
532
533fn 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
542fn 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
550fn parse_size(s: &str) -> Option<u64> {
555 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 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
591fn 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
619fn 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
638pub 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
664pub struct StepOutcome {
666 pub command: String,
668 pub what: &'static str,
670 pub problem: Option<String>,
672}
673
674pub struct ClearOutcome {
676 pub engine: &'static str,
678 pub steps: Vec<StepOutcome>,
680 pub before: u64,
682 pub after: u64,
684}
685
686impl ClearOutcome {
687 pub fn freed(&self) -> u64 {
689 self.before.saturating_sub(self.after)
690 }
691}
692
693pub 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 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 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 let redirected = include_volumes && !dry_run && !volume_stamp_fresh(engine);
778 let dry_run = dry_run || redirected;
779
780 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 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 if include_volumes && let Some(surface) = &engine.volume_candidates {
857 run_volume_phase(engine, surface, &mut steps);
858 }
859
860 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 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
891fn 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
904fn step_command(engine: &Engine, step: &ReclaimStep) -> String {
906 format!("{} {}", engine.binary, step.args.join(" "))
907}
908
909fn 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
934fn 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
957fn 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
976fn 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
1020fn 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
1060fn 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
1119fn 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
1150fn 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
1166fn 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
1175fn 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
1193fn 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 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
1268fn 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
1279fn 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 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
1350fn 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
1369pub fn known_engines() -> Vec<&'static str> {
1371 ENGINES.iter().map(|e| e.name).collect()
1372}
1373
1374pub 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
1418fn 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
1437const KIND_WIDTH: usize = 16;
1441const SIZE_WIDTH: usize = 11;
1442
1443fn 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 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
1494fn 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
1506fn 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
1521fn 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
1541pub 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 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 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 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 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 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 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 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 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 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 let podman = r#"{"Volumes":[{"VolumeName":"data","Size":2048}]}"#;
1769 assert!(parse_volume_sizes(podman).contains_key("data"));
1770 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 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 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 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 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 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 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 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 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 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 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 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}