use std::path::PathBuf;
use anyhow::Result;
use colored::Colorize;
use serde_json::Value;
use crate::adapters;
use crate::constants;
use crate::json;
use crate::output;
struct Engine {
name: &'static str,
binary: &'static str,
df_args: &'static [&'static str],
prune: &'static [(&'static str, &'static str)],
reclaim: &'static [ReclaimStep],
prompts: bool,
volume_candidates: Option<VolumeSurface>,
}
struct VolumeSurface {
ls_args: &'static [&'static str],
df_args: &'static [&'static str],
rm_args: &'static [&'static str],
}
struct ReclaimStep {
what: &'static str,
args: &'static [&'static str],
}
const COMMAND_WIDTH: usize = 32;
const ENGINES: &[Engine] = &[
Engine {
name: "docker",
binary: "docker",
prompts: true,
df_args: &["system", "df", "--format", "{{json .}}"],
prune: &[
(
"docker builder prune",
"the build cache; costs a slower next build",
),
(
"docker image prune",
"dangling images no tag points at any more",
),
(
"docker container prune",
"stopped containers and each writable layer",
),
(
"docker system prune",
"the three above at once; volumes untouched",
),
(
"docker system prune --volumes",
"adds unused volumes — the one that deletes data",
),
],
reclaim: &[
ReclaimStep {
what: "the build cache",
args: &["builder", "prune", "-a", "-f"],
},
ReclaimStep {
what: "images no container uses",
args: &["image", "prune", "-a", "-f"],
},
ReclaimStep {
what: "stopped containers and their writable layers",
args: &["container", "prune", "-f"],
},
],
volume_candidates: Some(VolumeSurface {
ls_args: &["volume", "ls", "-q", "--filter", "dangling=true"],
df_args: &["system", "df", "-v", "--format", "{{json .}}"],
rm_args: &["volume", "rm"],
}),
},
Engine {
name: "podman",
binary: "podman",
prompts: true,
df_args: &["system", "df", "--format", "json"],
prune: &[
(
"podman system prune",
"stopped containers, networks, dangling images",
),
(
"podman image prune -a",
"every image no container uses, tagged or not",
),
(
"podman system prune --volumes",
"adds unused volumes — the one that deletes data",
),
],
reclaim: &[
ReclaimStep {
what: "the build cache",
args: &["builder", "prune", "-a", "-f"],
},
ReclaimStep {
what: "images no container uses",
args: &["image", "prune", "-a", "-f"],
},
ReclaimStep {
what: "stopped containers and their writable layers",
args: &["container", "prune", "-f"],
},
],
volume_candidates: Some(VolumeSurface {
ls_args: &["volume", "ls", "-q", "--filter", "dangling=true"],
df_args: &["system", "df", "-v", "--format", "json"],
rm_args: &["volume", "rm"],
}),
},
Engine {
name: "nerdctl",
binary: "nerdctl",
prompts: true,
df_args: &["system", "df", "--format", "{{json .}}"],
prune: &[
(
"nerdctl system prune",
"stopped containers, networks, dangling images",
),
(
"nerdctl system prune --volumes",
"adds unused volumes — the one that deletes data",
),
],
reclaim: &[ReclaimStep {
what: "images, stopped containers and the build cache",
args: &["system", "prune", "-a", "-f"],
}],
volume_candidates: None,
},
Engine {
name: "finch",
binary: "finch",
prompts: true,
df_args: &["system", "df", "--format", "{{json .}}"],
prune: &[
(
"finch system prune",
"stopped containers, networks, dangling images",
),
(
"finch system prune --volumes",
"adds unused volumes — the one that deletes data",
),
],
reclaim: &[ReclaimStep {
what: "images, stopped containers and the build cache",
args: &["system", "prune", "-a", "-f"],
}],
volume_candidates: None,
},
Engine {
name: "container",
binary: "container",
prompts: false,
df_args: &["system", "df", "--format", "json"],
prune: &[
(
"container image prune -a",
"every image no container uses, tagged or not",
),
(
"container prune",
"stopped containers and their writable layers",
),
(
"container volume prune",
"unused volumes — the one that deletes data",
),
],
reclaim: &[
ReclaimStep {
what: "images no container uses",
args: &["image", "prune", "-a"],
},
ReclaimStep {
what: "stopped containers and their writable layers",
args: &["prune"],
},
],
volume_candidates: None,
},
];
pub struct Row {
pub kind: String,
pub total: Option<u64>,
pub active: Option<u64>,
pub bytes: Option<u64>,
pub reclaimable: Option<u64>,
}
pub enum EngineState {
Ready(Vec<Row>),
Unavailable(String),
}
pub struct EngineReport {
pub name: &'static str,
pub state: EngineState,
}
impl EngineReport {
pub fn total_bytes(&self) -> Option<u64> {
match &self.state {
EngineState::Ready(rows) => Some(rows.iter().filter_map(|r| r.bytes).sum()),
EngineState::Unavailable(_) => None,
}
}
pub fn reclaimable_bytes(&self) -> Option<u64> {
match &self.state {
EngineState::Ready(rows) => Some(rows.iter().filter_map(|r| r.reclaimable).sum()),
EngineState::Unavailable(_) => None,
}
}
}
pub fn collect(only: Option<&str>) -> Vec<EngineReport> {
ENGINES
.iter()
.filter(|e| only.is_none_or(|name| e.name.eq_ignore_ascii_case(name)))
.filter(|e| adapters::binary_available(e.binary))
.map(probe)
.collect()
}
fn probe(engine: &Engine) -> EngineReport {
let captured = adapters::capture_allowing_failure(
engine.binary,
engine.df_args,
&query_dir(),
std::time::Duration::from_secs(constants::CONTAINER_QUERY_TIMEOUT_SECS),
);
let state =
match captured {
Ok(out) if out.ok => {
let rows = parse_rows(&out.stdout);
if rows.is_empty() {
EngineState::Unavailable(format!(
"{} answered `system df` in a format dev-prune could not read",
engine.name
))
} else {
EngineState::Ready(rows)
}
}
Ok(out) => EngineState::Unavailable(first_line(&out.stderr).unwrap_or_else(|| {
format!("`{} system df` failed without saying why", engine.name)
})),
Err(e) => EngineState::Unavailable(
first_line(&e.to_string())
.unwrap_or_else(|| format!("`{} system df` could not be run", engine.name)),
),
};
EngineReport {
name: engine.name,
state,
}
}
fn first_line(raw: &str) -> Option<String> {
let line = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
Some(output::truncate_display(line, 400))
}
fn query_dir() -> PathBuf {
dirs::home_dir()
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."))
}
fn parse_rows(raw: &str) -> Vec<Row> {
let trimmed = raw.trim();
if trimmed.starts_with('[') {
return match serde_json::from_str::<Value>(trimmed) {
Ok(Value::Array(items)) => items.iter().filter_map(row_from).collect(),
_ => Vec::new(),
};
}
if let Ok(v) = serde_json::from_str::<Value>(trimmed)
&& let Some(rows) = apple_rows(&v)
{
return rows;
}
trimmed
.lines()
.filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
.filter_map(|v| row_from(&v))
.collect()
}
fn apple_rows(v: &Value) -> Option<Vec<Row>> {
let mut rows = Vec::new();
for (key, kind) in [
("images", "Images"),
("containers", "Containers"),
("volumes", "Local Volumes"),
] {
let usage = v.get(key)?.as_object()?;
rows.push(Row {
kind: kind.to_string(),
total: usage.get("total").and_then(Value::as_u64),
active: usage.get("active").and_then(Value::as_u64),
bytes: usage.get("sizeInBytes").and_then(Value::as_u64),
reclaimable: usage.get("reclaimable").and_then(Value::as_u64),
});
}
Some(rows)
}
fn row_from(v: &Value) -> Option<Row> {
let kind = v.get("Type")?.as_str()?.trim().to_string();
if kind.is_empty() {
return None;
}
Some(Row {
total: count(v, "TotalCount").or_else(|| count(v, "Total")),
active: count(v, "Active"),
bytes: bytes_at(v, "RawSize", "Size"),
reclaimable: bytes_at(v, "RawReclaimable", "Reclaimable"),
kind,
})
}
fn count(v: &Value, key: &str) -> Option<u64> {
let field = v.get(key)?;
if let Some(n) = field.as_u64() {
return Some(n);
}
field.as_str()?.trim().parse().ok()
}
fn bytes_at(v: &Value, raw_key: &str, human_key: &str) -> Option<u64> {
if let Some(n) = v.get(raw_key).and_then(Value::as_u64) {
return Some(n);
}
parse_size(v.get(human_key)?.as_str()?)
}
fn parse_size(s: &str) -> Option<u64> {
let s = s.split('(').next()?.trim();
let split = s
.find(|c: char| !(c.is_ascii_digit() || c == '.'))
.unwrap_or(s.len());
let (number, unit) = s.split_at(split);
let value: f64 = number.parse().ok()?;
if !value.is_finite() || value < 0.0 {
return None;
}
let unit = unit.trim();
let mut chars = unit.chars();
let scale = chars.next();
let rest: String = chars.collect();
let base: f64 = if rest.eq_ignore_ascii_case("ib") {
1024.0
} else {
1000.0
};
let exponent = match scale.map(|c| c.to_ascii_lowercase()) {
None | Some('b') => 0,
Some('k') => 1,
Some('m') => 2,
Some('g') => 3,
Some('t') => 4,
Some('p') => 5,
_ => return None,
};
Some((value * base.powi(exponent)).round() as u64)
}
fn kube_contexts() -> Vec<String> {
if !adapters::binary_available("kubectl") {
return Vec::new();
}
let Ok(out) = adapters::capture_allowing_failure(
"kubectl",
&["config", "get-contexts", "-o", "name"],
&query_dir(),
std::time::Duration::from_secs(constants::CACHE_QUERY_TIMEOUT_SECS),
) else {
return Vec::new();
};
if !out.ok {
return Vec::new();
}
out.stdout
.lines()
.map(str::trim)
.filter(|l| is_local_context(l))
.map(str::to_string)
.collect()
}
fn is_local_context(name: &str) -> bool {
const LOCAL_PREFIXES: [&str; 2] = ["kind-", "k3d-"];
const LOCAL_EXACT: [&str; 5] = [
"minikube",
"docker-desktop",
"rancher-desktop",
"colima",
"microk8s",
];
LOCAL_PREFIXES.iter().any(|p| name.starts_with(p))
|| LOCAL_EXACT.iter().any(|n| name.eq_ignore_ascii_case(n))
}
pub fn run(only: Option<&str>, json_output: bool) -> Result<()> {
if let Some(name) = only
&& !ENGINES.iter().any(|e| e.name.eq_ignore_ascii_case(name))
{
return Err(anyhow::Error::new(crate::UsageError(format!(
"`{name}` is not a container engine dev-prune knows. Try one of: {}.",
known_engines().join(", ")
))));
}
let pb = (!json_output).then(|| output::create_spinner("Asking the container engines..."));
let reports = collect(only);
let clusters = kube_contexts();
if let Some(pb) = pb {
pb.finish_and_clear();
}
if json_output {
return json::emit(&json::containers_document(&reports, &clusters));
}
print_report(&reports, &clusters, only);
Ok(())
}
pub struct StepOutcome {
pub command: String,
pub what: &'static str,
pub problem: Option<String>,
}
pub struct ClearOutcome {
pub engine: &'static str,
pub steps: Vec<StepOutcome>,
pub before: u64,
pub after: u64,
}
impl ClearOutcome {
pub fn freed(&self) -> u64 {
self.before.saturating_sub(self.after)
}
}
pub fn run_clear(
name: &str,
include_volumes: bool,
yes: bool,
dry_run: bool,
json_output: bool,
) -> Result<()> {
let Some(engine) = ENGINES.iter().find(|e| e.name.eq_ignore_ascii_case(name)) else {
return Err(anyhow::Error::new(crate::UsageError(format!(
"`{name}` is not a container engine dev-prune knows. Try one of: {}.",
known_engines().join(", ")
))));
};
if include_volumes {
if engine.volume_candidates.is_none() {
return Err(anyhow::Error::new(crate::UsageError(format!(
"{} has no way to name only its unused volumes, so dev-prune cannot put \
an honest pick list in front of you. Run `{} volume ls` and decide by \
name yourself.",
engine.name, engine.binary
))));
}
if json_output {
return Err(anyhow::Error::new(crate::UsageError(
"`--include-volumes` is a hand-picked deletion at a terminal, and \
`--json` is for a program reading the answer. They do not combine; \
drop one."
.to_string(),
)));
}
if yes {
return Err(anyhow::Error::new(crate::UsageError(
"`--include-volumes` has no pre-answered form: the picking is the \
point, and a volume goes only when its number is typed at the list. \
Drop `--yes`."
.to_string(),
)));
}
if !dry_run && !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return Err(anyhow::Error::new(crate::UsageError(
"`--include-volumes` needs a terminal, because someone has to pick each \
volume off the list, and that is deliberate. From a script or an \
agent, add `--dry-run`: it lists the unused volumes by name and \
prints the command a person runs to do the picking themselves."
.to_string(),
)));
}
}
let redirected = include_volumes && !dry_run && !volume_stamp_fresh(engine);
let dry_run = dry_run || redirected;
if json_output && !yes && !dry_run {
return Err(anyhow::Error::new(crate::UsageError(
"`--json` cannot ask for confirmation — pass `--yes` as well, or `--dry-run` \
to see what would go."
.to_string(),
)));
}
if !adapters::binary_available(engine.binary) {
return Err(anyhow::Error::new(crate::UsageError(format!(
"{} is not installed on this machine, so there is nothing of its to clear.",
engine.name
))));
}
let before = probe(engine);
let rows = match &before.state {
EngineState::Ready(rows) => rows,
EngineState::Unavailable(why) => {
return Err(anyhow::Error::new(crate::UsageError(format!(
"{} did not answer, so dev-prune will not start deleting on a guess: {why}",
engine.name
))));
}
};
let before_bytes: u64 = rows.iter().filter_map(|r| r.bytes).sum();
if redirected {
let minutes = constants::VOLUME_PICK_WINDOW_SECS / 60;
output::print_header("This run is the dry run");
println!();
output::print_wrapped(
" ",
&format!(
"No volume dry run for {} has finished in the last {minutes} minutes, \
so nothing below is deleted: the pick list only arms right after the \
list has been read. Run the exact same line again within {minutes} \
minutes to do the picking.",
engine.name
),
);
println!();
}
if !json_output {
print_clear_plan(engine, rows, include_volumes, dry_run);
}
if dry_run {
if json_output {
let planned = ClearOutcome {
engine: engine.name,
steps: planned_steps(engine),
before: before_bytes,
after: before_bytes,
};
return json::emit(&json::containers_clear_document(&planned, true));
}
if include_volumes && let Some(surface) = &engine.volume_candidates {
print_volume_dry_run(engine, surface);
write_volume_stamp(engine);
}
return Ok(());
}
if !json_output && !crate::commands::caches::confirm_clear(yes) {
output::print_info("Nothing was cleared.");
return Ok(());
}
let mut steps: Vec<StepOutcome> = engine.reclaim.iter().map(|s| run_step(engine, s)).collect();
if include_volumes && let Some(surface) = &engine.volume_candidates {
run_volume_phase(engine, surface, &mut steps);
}
let after_bytes = probe(engine).total_bytes().unwrap_or(before_bytes);
let outcome = ClearOutcome {
engine: engine.name,
steps,
before: before_bytes,
after: after_bytes,
};
record_container_clear(outcome.freed());
if json_output {
json::emit(&json::containers_clear_document(&outcome, false))?;
} else {
print_clear_result(&outcome);
}
let failed = outcome.steps.iter().filter(|s| s.problem.is_some()).count();
if failed > 0 {
anyhow::bail!(
"{failed} of {}'s reclaim steps did not finish.",
outcome.engine
);
}
Ok(())
}
fn planned_steps(engine: &Engine) -> Vec<StepOutcome> {
engine
.reclaim
.iter()
.map(|s| StepOutcome {
command: step_command(engine, s),
what: s.what,
problem: None,
})
.collect()
}
fn step_command(engine: &Engine, step: &ReclaimStep) -> String {
format!("{} {}", engine.binary, step.args.join(" "))
}
fn run_step(engine: &Engine, step: &ReclaimStep) -> StepOutcome {
let captured = adapters::capture_allowing_failure(
engine.binary,
step.args,
&query_dir(),
std::time::Duration::from_secs(constants::CONTAINER_PRUNE_TIMEOUT_SECS),
);
let problem = match captured {
Ok(out) if out.ok => None,
Ok(out) => Some(first_line(&out.stderr).unwrap_or_else(|| {
format!("`{}` failed without saying why", step_command(engine, step))
})),
Err(e) => Some(
first_line(&e.to_string())
.unwrap_or_else(|| format!("`{}` could not be run", step_command(engine, step))),
),
};
StepOutcome {
command: step_command(engine, step),
what: step.what,
problem,
}
}
fn unused_volumes(engine: &Engine, surface: &VolumeSurface) -> Result<Vec<String>, String> {
let captured = adapters::capture_allowing_failure(
engine.binary,
surface.ls_args,
&query_dir(),
std::time::Duration::from_secs(constants::CONTAINER_QUERY_TIMEOUT_SECS),
);
match captured {
Ok(out) if out.ok => Ok(out
.stdout
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()),
Ok(out) => Err(first_line(&out.stderr)
.unwrap_or_else(|| format!("`{} volume ls` failed without saying why", engine.binary))),
Err(e) => Err(first_line(&e.to_string())
.unwrap_or_else(|| format!("`{} volume ls` could not be run", engine.binary))),
}
}
fn volume_sizes(
engine: &Engine,
surface: &VolumeSurface,
) -> std::collections::HashMap<String, String> {
let Ok(out) = adapters::capture_allowing_failure(
engine.binary,
surface.df_args,
&query_dir(),
std::time::Duration::from_secs(constants::CONTAINER_QUERY_TIMEOUT_SECS),
) else {
return std::collections::HashMap::new();
};
if !out.ok {
return std::collections::HashMap::new();
}
parse_volume_sizes(&out.stdout)
}
fn parse_volume_sizes(raw: &str) -> std::collections::HashMap<String, String> {
let mut sizes = std::collections::HashMap::new();
for candidate in std::iter::once(raw.trim()).chain(raw.lines().map(str::trim)) {
let Ok(v) = serde_json::from_str::<Value>(candidate) else {
continue;
};
let Some(volumes) = ["Volumes", "volumes"]
.iter()
.find_map(|k| v.get(k))
.and_then(Value::as_array)
else {
continue;
};
for entry in volumes {
let Some(name) = ["Name", "VolumeName", "Names"]
.iter()
.find_map(|k| entry.get(k))
.and_then(Value::as_str)
else {
continue;
};
let size = match ["Size", "size"].iter().find_map(|k| entry.get(k)) {
Some(Value::String(s)) => s.trim().to_string(),
Some(Value::Number(n)) => n.as_u64().map(output::format_bytes).unwrap_or_default(),
_ => String::new(),
};
if !size.is_empty() {
sizes.insert(name.to_string(), size);
}
}
if !sizes.is_empty() {
break;
}
}
sizes
}
fn parse_selection(input: &str, count: usize) -> Option<Vec<usize>> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Some(Vec::new());
}
if trimmed.eq_ignore_ascii_case("all") {
return Some((0..count).collect());
}
let mut picked = Vec::new();
let mut push = |index: usize| {
if !picked.contains(&index) {
picked.push(index);
}
};
for token in trimmed.split([',', ' ']).filter(|t| !t.is_empty()) {
if let Some((low, high)) = token.split_once('-') {
let low: usize = low.trim().parse().ok()?;
let high: usize = high.trim().parse().ok()?;
if low == 0 || high < low || high > count {
return None;
}
(low..=high).for_each(|n| push(n - 1));
} else {
let n: usize = token.parse().ok()?;
if n == 0 || n > count {
return None;
}
push(n - 1);
}
}
Some(picked)
}
fn run_volume_phase(engine: &Engine, surface: &VolumeSurface, steps: &mut Vec<StepOutcome>) {
use std::io::Write;
let names = match unused_volumes(engine, surface) {
Ok(names) => names,
Err(why) => {
output::print_info(&format!(
"{} could not name its unused volumes, so none was offered: {why}",
engine.name
));
return;
}
};
if names.is_empty() {
output::print_info(&format!(
"{} reports no unused volumes, so there is nothing to pick from.",
engine.name
));
return;
}
let sizes = volume_sizes(engine, surface);
eprintln!();
eprintln!(" Unused volumes. A volume holds the only copy of what is in it; anything");
eprintln!(" picked here is gone for good.");
eprintln!();
for (i, name) in names.iter().enumerate() {
let size = sizes.get(name).map_or("size unknown", String::as_str);
eprintln!(" {:>3}. {:<44} {}", i + 1, name, size);
}
eprintln!();
eprint!("Delete which? [numbers or ranges like `1 3-5`, `all`, Enter for none]: ");
if std::io::stderr().flush().is_err() {
return;
}
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
output::print_info("No answer could be read, so no volume was deleted.");
return;
}
let Some(picked) = parse_selection(&input, names.len()) else {
output::print_info(
"That did not read as numbers from the list, so no volume was deleted. Run \
the command again to see the list once more.",
);
return;
};
if picked.is_empty() {
output::print_info("No volume picked; all of them stay.");
return;
}
for index in picked {
steps.push(remove_volume(engine, surface, &names[index]));
}
}
fn remove_volume(engine: &Engine, surface: &VolumeSurface, name: &str) -> StepOutcome {
let mut args: Vec<&str> = surface.rm_args.to_vec();
args.push(name);
let command = format!("{} {}", engine.binary, args.join(" "));
let captured = adapters::capture_allowing_failure(
engine.binary,
&args,
&query_dir(),
std::time::Duration::from_secs(constants::CONTAINER_PRUNE_TIMEOUT_SECS),
);
let problem = match captured {
Ok(out) if out.ok => None,
Ok(out) => Some(
first_line(&out.stderr)
.unwrap_or_else(|| format!("`{command}` failed without saying why")),
),
Err(e) => Some(
first_line(&e.to_string()).unwrap_or_else(|| format!("`{command}` could not be run")),
),
};
StepOutcome {
command,
what: "a volume picked by name",
problem,
}
}
fn volume_stamp_path(engine: &Engine) -> Option<PathBuf> {
crate::config::Registry::config_dir().ok().map(|dir| {
dir.join(format!(
"{}{}{}",
constants::VOLUME_PICK_STAMP_PREFIX,
engine.name.to_ascii_lowercase(),
constants::VOLUME_PICK_STAMP_SUFFIX
))
})
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn volume_stamp_fresh_at(path: &std::path::Path, now: u64) -> bool {
let Ok(contents) = std::fs::read_to_string(path) else {
return false;
};
let Ok(stamped) = contents.trim().parse::<u64>() else {
return false;
};
stamped <= now && now - stamped <= constants::VOLUME_PICK_WINDOW_SECS
}
fn volume_stamp_fresh(engine: &Engine) -> bool {
volume_stamp_path(engine).is_some_and(|path| volume_stamp_fresh_at(&path, unix_now()))
}
fn write_volume_stamp_at(path: &std::path::Path, now: u64) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp_path = path.with_extension(format!("stamp.{}.tmp", std::process::id()));
std::fs::write(&tmp_path, now.to_string())?;
std::fs::rename(&tmp_path, path)
}
fn write_volume_stamp(engine: &Engine) {
let Some(path) = volume_stamp_path(engine) else {
return;
};
if let Err(why) = write_volume_stamp_at(&path, unix_now()) {
output::print_info(&format!(
"Could not record this dry run at {}: {why}. The real `--include-volumes` \
run will redirect here again until it can be recorded.",
path.display()
));
}
}
fn print_volume_dry_run(engine: &Engine, surface: &VolumeSurface) {
match unused_volumes(engine, surface) {
Err(why) => output::print_info(&format!(
"{} could not name its unused volumes: {why}",
engine.name
)),
Ok(names) if names.is_empty() => output::print_info(&format!(
"{} reports no unused volumes right now.",
engine.name
)),
Ok(names) => {
let sizes = volume_sizes(engine, surface);
output::print_wrapped(
" ",
"These volumes are unused right now and would be offered on the pick \
list. The real list can be longer, because it is drawn after the \
containers are pruned and a stopped container keeps its anonymous \
volumes counted as in use until it is gone.",
);
println!();
for (i, name) in names.iter().enumerate() {
let size = sizes.get(name).map_or("size unknown", String::as_str);
println!(" {:>3}. {:<44} {}", i + 1, name, size);
}
println!();
output::print_wrapped(
" ",
&format!(
"Nothing was deleted. To delete any of them, a person runs the \
line below at their own terminal within the next {} minutes and \
types the picks at the list; each pick is one unforced `volume \
rm`, and what it frees is measured and counted on `devp stats`, \
which a raw `{} volume rm` typed by hand would not be. After \
that the line shows this list again first.",
constants::VOLUME_PICK_WINDOW_SECS / 60,
engine.binary
),
);
println!();
println!(" devp caches clear {} --include-volumes", engine.name);
println!();
}
}
}
fn record_container_clear(bytes: u64) {
if bytes == 0 {
return;
}
if let Ok(mut registry) = crate::config::Registry::load() {
registry.record_container_clear(bytes);
let _ = registry.save();
}
}
fn print_clear_plan(engine: &Engine, rows: &[Row], include_volumes: bool, dry_run: bool) {
output::print_header(&format!("Clearing {}", engine.name));
println!();
for step in engine.reclaim {
println!(" {:<40} {}", step_command(engine, step).bold(), step.what);
}
println!();
let volumes: u64 = rows
.iter()
.filter(|r| r.kind.eq_ignore_ascii_case("Local Volumes"))
.filter_map(|r| r.reclaimable)
.sum();
let reclaimable: u64 = rows.iter().filter_map(|r| r.reclaimable).sum();
output::print_wrapped(
" ",
&format!(
"{} says about {} of this is reclaimable. None of the commands above touches \
a volume, and dev-prune never runs `{} volume prune`: a volume holds the one \
copy of what is in it, so a volume goes only when someone names it.",
engine.name,
output::format_bytes(reclaimable.saturating_sub(volumes)),
engine.binary
),
);
if volumes > 0 {
println!();
if include_volumes {
output::print_wrapped(
" ",
&format!(
"{} of unused volumes will be offered after these steps run, listed \
by name for you to pick from. After, because pruning the stopped \
containers is what frees their anonymous volumes onto the list. \
Each pick is one `{} volume rm`, never forced.",
output::format_bytes(volumes),
engine.binary
),
);
} else if engine.volume_candidates.is_some() {
output::print_wrapped(
" ",
&format!(
"{} of unused volumes is being left alone. To pick which of them go, \
by name and one at a time, add `--include-volumes`.",
output::format_bytes(volumes)
),
);
} else {
output::print_wrapped(
" ",
&format!(
"{} of unused volumes is being left alone. If you have read what is \
in them and want it gone, that one is yours to run: `{} volume \
prune`.",
output::format_bytes(volumes),
engine.binary
),
);
}
}
if dry_run {
println!();
output::print_info("Dry run — nothing was deleted.");
}
println!();
}
fn print_clear_result(outcome: &ClearOutcome) {
println!();
for step in &outcome.steps {
match &step.problem {
None => println!(" {:<40} done", step.command),
Some(why) => println!(" {:<40} {why}", step.command),
}
}
println!();
output::print_success(&format!(
"{} freed — {} is now holding {}, down from {}.",
output::format_bytes(outcome.freed()),
outcome.engine,
output::format_bytes(outcome.after),
output::format_bytes(outcome.before)
));
}
pub fn known_engines() -> Vec<&'static str> {
ENGINES.iter().map(|e| e.name).collect()
}
pub fn is_engine(name: &str) -> bool {
ENGINES.iter().any(|e| e.name.eq_ignore_ascii_case(name))
}
fn print_report(reports: &[EngineReport], clusters: &[String], only: Option<&str>) {
output::print_header("Container engines");
if reports.is_empty() {
println!();
output::print_info(&match only {
Some(name) => format!("{name} is not installed on this machine."),
None => format!(
"No container engine found. dev-prune looks for {}.",
known_engines().join(", ")
),
});
return;
}
for report in reports {
println!();
match &report.state {
EngineState::Unavailable(why) => print_unavailable(report.name, why),
EngineState::Ready(rows) => print_engine(report.name, rows),
}
}
if !clusters.is_empty() {
print_clusters(clusters);
}
println!();
output::print_wrapped(
" ",
"Nothing above was deleted, and nothing dev-prune runs on a schedule will ever \
delete it. To have dev-prune run the narrow ones for you — build cache, unused \
images, stopped containers, and what that gave back counted on your stats — use \
`devp caches clear <engine>`. It asks first, and it touches no volume on its \
own: `--include-volumes` lists the unused ones by name for you to pick from, \
one at a time, at a terminal.",
);
}
fn print_unavailable(name: &str, why: &str) {
println!(" {name}");
println!();
output::print_wrapped(" ", why);
println!();
output::print_wrapped(
" ",
&format!(
"So dev-prune has no figures for {name} — a blank rather than a zero. Start \
it and run this again."
),
);
}
const KIND_WIDTH: usize = 16;
const SIZE_WIDTH: usize = 11;
fn print_engine(name: &str, rows: &[Row]) {
println!(" {name}");
println!();
for row in rows {
println!(
" {:<KIND_WIDTH$}{:>SIZE_WIDTH$} {} {}",
row.kind,
row.bytes.map_or("—".to_string(), output::format_bytes),
reclaimable_cell(row.reclaimable),
counts(row),
);
}
let total: u64 = rows.iter().filter_map(|r| r.bytes).sum();
let reclaimable: u64 = rows.iter().filter_map(|r| r.reclaimable).sum();
println!();
println!(
" {:<KIND_WIDTH$}{:>SIZE_WIDTH$} {}",
"Total",
output::format_bytes(total),
reclaimable_cell(Some(reclaimable)),
);
let Some(engine) = ENGINES.iter().find(|e| e.name == name) else {
return;
};
println!();
println!(
" {:<COMMAND_WIDTH$}what it takes with it",
"Reclaim it yourself"
);
for (command, cost) in engine.prune {
println!(" {command:<COMMAND_WIDTH$}{cost}");
}
if !engine.prompts {
println!();
output::print_wrapped(
" ",
&format!(
"{} asks nothing first. Each of those runs the moment you press Return, including the last one.",
engine.name
),
);
}
}
fn reclaimable_cell(bytes: Option<u64>) -> String {
match bytes {
Some(b) => format!("{:>SIZE_WIDTH$} reclaimable", output::format_bytes(b)),
None => " ".repeat(SIZE_WIDTH + " reclaimable".len()),
}
}
fn counts(row: &Row) -> String {
match (row.total, row.active) {
(Some(total), Some(active)) => format!(
"{total} {}, {active} in use",
output::plural(total as usize, "item", "items")
),
(Some(total), None) => format!(
"{total} {}",
output::plural(total as usize, "item", "items")
),
_ => String::new(),
}
}
fn print_clusters(clusters: &[String]) {
println!();
println!(" kubernetes");
println!();
for name in clusters {
println!(" {:<18} local cluster", name);
}
println!();
output::print_wrapped(
" ",
"Named and not sized on purpose: kind, k3d and minikube run their nodes as \
containers or as a VM disk belonging to an engine above, so their disk is \
already in that engine's total. A figure here would be the same gigabytes \
counted twice. Delete a cluster with its own tool — `kind delete cluster`, \
`minikube delete`, `k3d cluster delete` — which is also what releases the \
space.",
);
}
pub fn print_summary(reports: &[EngineReport]) {
if reports.is_empty() {
return;
}
println!();
output::print_header("Container engines");
println!();
for report in reports {
match &report.state {
EngineState::Ready(_) => {
let total = report.total_bytes().unwrap_or(0);
let reclaimable = report.reclaimable_bytes().unwrap_or(0);
println!(
" {:<30} {:>10} {} reclaimable · devp caches {}",
report.name,
output::format_bytes(total),
output::format_bytes(reclaimable),
report.name,
);
}
EngineState::Unavailable(_) => {
println!(
" {:<30} {:>10} did not answer · devp caches {}",
report.name, "—", report.name,
);
}
}
}
println!();
output::print_wrapped(
" ",
"Container images, volumes and build cache are not package manager caches and are \
not in the total above — dev-prune reports them, and deletes nothing of them \
except through `devp caches clear <engine>`, which asks first.",
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_reclaim_step_can_touch_a_volume() {
for engine in ENGINES {
for step in engine.reclaim {
for arg in step.args {
assert!(
!arg.to_ascii_lowercase().contains("volume"),
"{} would run `{}`, which reaches a volume",
engine.name,
step_command(engine, step)
);
}
}
}
}
#[test]
fn the_volume_surface_deletes_one_named_volume_and_never_forces_it() {
for engine in ENGINES {
let Some(surface) = &engine.volume_candidates else {
continue;
};
assert_eq!(
surface.rm_args,
["volume", "rm"],
"{} would delete with `{}`, not a single unforced rm",
engine.name,
surface.rm_args.join(" ")
);
assert!(
surface.ls_args.contains(&"dangling=true") && surface.ls_args.contains(&"-q"),
"{} would list volumes without narrowing to unused names",
engine.name
);
for arg in surface
.ls_args
.iter()
.chain(surface.df_args)
.chain(surface.rm_args)
{
let lower = arg.to_ascii_lowercase();
assert!(
lower != "-f"
&& lower != "--force"
&& !lower.contains("prune")
&& lower != "--volumes",
"{} carries `{arg}` on its volume surface",
engine.name
);
}
}
}
#[test]
fn only_engines_that_can_name_unused_volumes_offer_them() {
for engine in ENGINES {
let can = matches!(engine.name, "docker" | "podman");
assert_eq!(
engine.volume_candidates.is_some(),
can,
"{} disagrees about offering volumes",
engine.name
);
}
}
#[test]
fn a_missing_stamp_never_arms_the_pick_list() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("volume-pick-docker.stamp");
assert!(!volume_stamp_fresh_at(&path, 1_000_000));
}
#[test]
fn a_fresh_stamp_arms_it_and_an_expired_one_does_not() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("volume-pick-docker.stamp");
let now = 1_000_000;
write_volume_stamp_at(&path, now).unwrap();
assert!(volume_stamp_fresh_at(&path, now));
assert!(volume_stamp_fresh_at(
&path,
now + constants::VOLUME_PICK_WINDOW_SECS
));
assert!(!volume_stamp_fresh_at(
&path,
now + constants::VOLUME_PICK_WINDOW_SECS + 1
));
}
#[test]
fn a_stamp_from_the_future_reads_as_stale() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("volume-pick-docker.stamp");
write_volume_stamp_at(&path, 2_000_000).unwrap();
assert!(!volume_stamp_fresh_at(&path, 1_000_000));
}
#[test]
fn a_garbled_stamp_reads_as_stale() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("volume-pick-docker.stamp");
std::fs::write(&path, "not a number").unwrap();
assert!(!volume_stamp_fresh_at(&path, 1_000_000));
}
#[test]
fn each_engine_stamps_its_own_file() {
let docker = ENGINES.iter().find(|e| e.name == "docker").unwrap();
let podman = ENGINES.iter().find(|e| e.name == "podman").unwrap();
let (Some(a), Some(b)) = (volume_stamp_path(docker), volume_stamp_path(podman)) else {
return;
};
assert_ne!(a, b);
}
#[test]
fn an_empty_answer_keeps_every_volume() {
assert_eq!(parse_selection("", 5), Some(vec![]));
assert_eq!(parse_selection(" \n", 5), Some(vec![]));
}
#[test]
fn all_is_every_row_once() {
assert_eq!(parse_selection("all", 3), Some(vec![0, 1, 2]));
assert_eq!(parse_selection("ALL", 3), Some(vec![0, 1, 2]));
}
#[test]
fn numbers_and_ranges_read_one_based_in_the_order_given() {
assert_eq!(parse_selection("1 3-5", 5), Some(vec![0, 2, 3, 4]));
assert_eq!(parse_selection("2,2,1", 3), Some(vec![1, 0]));
}
#[test]
fn anything_that_is_not_a_row_number_deletes_nothing() {
assert_eq!(parse_selection("0", 3), None);
assert_eq!(parse_selection("4", 3), None);
assert_eq!(parse_selection("2-1", 3), None);
assert_eq!(parse_selection("yes please", 3), None);
assert_eq!(parse_selection("1; rm -rf /", 3), None);
}
#[test]
fn reads_dockers_volume_sizes_and_podmans() {
let docker = r#"{"Volumes":[
{"Name":"chronos_cache_store","Size":"89B","Links":"0"},
{"Name":"pgdata","Size":"1.2GB","Links":"0"}
]}"#;
let sizes = parse_volume_sizes(docker);
assert_eq!(
sizes.get("chronos_cache_store").map(String::as_str),
Some("89B")
);
assert_eq!(sizes.get("pgdata").map(String::as_str), Some("1.2GB"));
let podman = r#"{"Volumes":[{"VolumeName":"data","Size":2048}]}"#;
assert!(parse_volume_sizes(podman).contains_key("data"));
assert!(parse_volume_sizes("TYPE TOTAL ACTIVE").is_empty());
}
#[test]
fn every_engine_that_can_be_reported_can_be_cleared() {
for engine in ENGINES {
assert!(
!engine.reclaim.is_empty(),
"{} can be named to clear and has no steps",
engine.name
);
}
}
#[test]
fn every_reclaim_step_answers_for_itself_without_a_prompt() {
for engine in ENGINES {
for step in engine.reclaim {
let forced = step.args.contains(&"-f") || step.args.contains(&"--force");
assert_eq!(
forced,
engine.prompts,
"`{}` disagrees with what {} does about prompting",
step_command(engine, step),
engine.name
);
}
}
}
#[test]
fn reads_apples_one_object_for_all_three() {
let raw = r#"{
"images" : { "total" : 12, "active" : 3, "sizeInBytes" : 4210000000,
"reclaimable" : 3020000000 },
"containers" : { "total" : 7, "active" : 1, "sizeInBytes" : 118400000,
"reclaimable" : 118400000 },
"volumes" : { "total" : 2, "active" : 0, "sizeInBytes" : 2048,
"reclaimable" : 2048 }
}"#;
let rows = parse_rows(raw);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].kind, "Images");
assert_eq!(rows[0].total, Some(12));
assert_eq!(rows[0].active, Some(3));
assert_eq!(rows[0].bytes, Some(4_210_000_000));
assert_eq!(rows[0].reclaimable, Some(3_020_000_000));
assert_eq!(rows[2].kind, "Local Volumes");
assert_eq!(rows[2].bytes, Some(2_048));
}
#[test]
fn a_json_object_that_is_not_apples_is_not_read_as_apples() {
let rows = parse_rows(
r#"{"Active":"3","Reclaimable":"3.02GB (71%)","Size":"4.21GB","TotalCount":"12","Type":"Images"}"#,
);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].kind, "Images");
assert!(parse_rows(r#"{"images":{"total":1},"containers":{"total":1}}"#).is_empty());
}
#[test]
fn parses_docker_si_sizes() {
assert_eq!(parse_size("0B"), Some(0));
assert_eq!(parse_size("1.093GB"), Some(1_093_000_000));
assert_eq!(parse_size("987.4MB"), Some(987_400_000));
assert_eq!(parse_size("1.5kB"), Some(1_500));
assert_eq!(parse_size("2TB"), Some(2_000_000_000_000));
}
#[test]
fn iec_suffix_is_base_1024() {
assert_eq!(parse_size("1KiB"), Some(1_024));
assert_eq!(parse_size("1GiB"), Some(1_073_741_824));
assert_ne!(parse_size("1GiB"), parse_size("1GB"));
}
#[test]
fn reclaimable_percentage_is_dropped() {
assert_eq!(parse_size("1.093GB (100%)"), Some(1_093_000_000));
assert_eq!(parse_size("0B (0%)"), Some(0));
}
#[test]
fn rejects_what_is_not_a_size() {
assert_eq!(parse_size(""), None);
assert_eq!(parse_size("N/A"), None);
assert_eq!(parse_size("GB"), None);
assert_eq!(parse_size("12 apples"), None);
}
#[test]
fn reads_dockers_one_object_per_line() {
let raw = concat!(
r#"{"Active":"3","Reclaimable":"3.02GB (71%)","Size":"4.21GB","TotalCount":"12","Type":"Images"}"#,
"\n",
r#"{"Active":"1","Reclaimable":"118.4MB (100%)","Size":"118.4MB","TotalCount":"7","Type":"Containers"}"#,
"\n",
r#"{"Active":"0","Reclaimable":"6.75GB","Size":"6.75GB","TotalCount":"41","Type":"Build Cache"}"#,
);
let rows = parse_rows(raw);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].kind, "Images");
assert_eq!(rows[0].total, Some(12));
assert_eq!(rows[0].active, Some(3));
assert_eq!(rows[0].bytes, Some(4_210_000_000));
assert_eq!(rows[0].reclaimable, Some(3_020_000_000));
assert_eq!(rows[2].kind, "Build Cache");
assert_eq!(rows[2].active, Some(0));
}
#[test]
fn reads_podmans_single_array() {
let raw = r#"[
{"Type":"Images","Total":4,"Active":2,"Size":"1.5GB","Reclaimable":"500MB (33%)"},
{"Type":"Local Volumes","Total":2,"Active":0,"RawSize":2048,"RawReclaimable":2048,
"Size":"2.048kB","Reclaimable":"2.048kB (100%)"}
]"#;
let rows = parse_rows(raw);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].total, Some(4));
assert_eq!(rows[0].bytes, Some(1_500_000_000));
assert_eq!(rows[1].bytes, Some(2_048));
assert_eq!(rows[1].reclaimable, Some(2_048));
}
#[test]
fn unparseable_output_is_no_rows_rather_than_zero_bytes() {
assert!(parse_rows("").is_empty());
assert!(parse_rows("Cannot connect to the Docker daemon").is_empty());
assert!(parse_rows(r#"{"Size":"4GB"}"#).is_empty());
}
#[test]
fn local_contexts_are_told_from_remote_ones() {
assert!(is_local_context("kind-dev"));
assert!(is_local_context("k3d-test"));
assert!(is_local_context("minikube"));
assert!(is_local_context("docker-desktop"));
assert!(!is_local_context("arn:aws:eks:us-east-1:1234:cluster/prod"));
assert!(!is_local_context("gke_project_us-central1_prod"));
assert!(!is_local_context("kindly-prod"));
}
#[test]
fn every_engine_prints_at_least_one_reclaim_command() {
for engine in ENGINES {
assert!(
!engine.prune.is_empty(),
"{} has no reclaim command to print",
engine.name
);
for (command, _) in engine.prune {
assert!(
command.starts_with(engine.binary),
"{command} is not a {} command",
engine.name
);
}
}
}
#[test]
fn no_reclaim_command_is_ever_run_by_dev_prune() {
for engine in ENGINES {
for (command, _) in engine.prune {
assert!(
command.contains(' '),
"{command} looks like a bare program name"
);
}
}
}
}