use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use indicatif::{ProgressBar, ProgressStyle};
use owo_colors::{OwoColorize, Stream};
use bv_core::cache::CacheLayout;
use bv_core::lockfile::Lockfile;
use bv_core::project::BvLock;
use crate::commands::add::format_size;
const TMP_TTL: Duration = Duration::from_secs(60 * 60); const INDEX_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
pub fn run(cmd: &crate::cli::CacheCommands) -> anyhow::Result<()> {
match cmd {
crate::cli::CacheCommands::Size => run_size(),
crate::cli::CacheCommands::List => run_list(),
crate::cli::CacheCommands::Prune {
dry_run,
yes,
all,
keep_recent,
} => run_prune(*dry_run, *yes, *all, *keep_recent),
}
}
pub fn run_size() -> anyhow::Result<()> {
let cache = CacheLayout::new();
let root = cache.root().clone();
let categories: Vec<(&str, PathBuf)> = vec![
("Tools manifests", root.join("tools")),
("Indexes", root.join("index")),
("SIFs", root.join("sif")),
("Datasets", root.join("data")),
("Tmp", root.join("tmp")),
];
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::with_template(" {spinner:.cyan} {msg}")
.unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
);
spinner.enable_steady_tick(Duration::from_millis(80));
spinner.set_message("scanning cache");
let mut sizes: Vec<(&str, u64, PathBuf)> = Vec::with_capacity(categories.len());
let mut total: u64 = 0;
for (label, path) in &categories {
spinner.set_message(format!("scanning {}", label.to_lowercase()));
let bytes = if path.exists() {
dir_size(path).unwrap_or(0)
} else {
0
};
total += bytes;
sizes.push((label, bytes, path.clone()));
}
spinner.finish_and_clear();
let w_label = sizes.iter().map(|(l, _, _)| l.len()).max().unwrap_or(8) + 2;
let w_size = sizes
.iter()
.map(|(_, b, _)| format_size(*b).len())
.max()
.unwrap_or(8)
.max(format_size(total).len())
+ 2;
println!(
" {:<w_label$} {:<w_size$} {}",
"Category".if_supports_color(Stream::Stdout, |t| t.bold().to_string()),
"Size".if_supports_color(Stream::Stdout, |t| t.bold().to_string()),
"Path".if_supports_color(Stream::Stdout, |t| t.bold().to_string()),
);
for (label, bytes, path) in &sizes {
println!(
" {:<w_label$} {:<w_size$} {}",
label,
format_size(*bytes),
display_path(path).if_supports_color(Stream::Stdout, |t| t.dimmed().to_string()),
);
}
println!(
" {:<w_label$} {:<w_size$}",
"Total".if_supports_color(Stream::Stdout, |t| t.bold().to_string()),
format_size(total).if_supports_color(Stream::Stdout, |t| t.bold().to_string()),
);
Ok(())
}
pub fn run_list() -> anyhow::Result<()> {
let cache = CacheLayout::new();
let root = cache.root();
let inv = inventory(root);
if inv.is_empty() {
println!(" cache is empty (root: {})", display_path(root));
return Ok(());
}
println!(
" {:<10} {:<40} {:<10} {}",
"Category".if_supports_color(Stream::Stdout, |t| t.bold().to_string()),
"Entry".if_supports_color(Stream::Stdout, |t| t.bold().to_string()),
"Size".if_supports_color(Stream::Stdout, |t| t.bold().to_string()),
"Path".if_supports_color(Stream::Stdout, |t| t.bold().to_string()),
);
for (cat, name, size, path) in &inv {
println!(
" {:<10} {:<40} {:<10} {}",
cat,
truncate(name, 40),
format_size(*size),
display_path(path).if_supports_color(Stream::Stdout, |t| t.dimmed().to_string()),
);
}
Ok(())
}
fn inventory(root: &Path) -> Vec<(&'static str, String, u64, PathBuf)> {
let mut out: Vec<(&'static str, String, u64, PathBuf)> = Vec::new();
let sif = root.join("sif");
if let Ok(entries) = fs::read_dir(&sif) {
for e in entries.flatten() {
let p = e.path();
if p.is_file() && p.extension().is_some_and(|ext| ext == "sif") {
let size = file_size(&p);
let name = p
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
out.push(("sif", name, size, p));
}
}
}
let tools = root.join("tools");
if let Ok(ids) = fs::read_dir(&tools) {
for id_entry in ids.flatten() {
let id_path = id_entry.path();
if !id_path.is_dir() {
continue;
}
let id = id_entry.file_name().to_string_lossy().into_owned();
if let Ok(versions) = fs::read_dir(&id_path) {
for v in versions.flatten() {
let vp = v.path();
if !vp.is_dir() {
continue;
}
let version = v.file_name().to_string_lossy().into_owned();
let size = dir_size(&vp).unwrap_or(0);
out.push(("manifest", format!("{id}@{version}"), size, vp));
}
}
}
}
let indexes = root.join("index");
if let Ok(entries) = fs::read_dir(&indexes) {
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
let name = e.file_name().to_string_lossy().into_owned();
let size = dir_size(&p).unwrap_or(0);
out.push(("index", name, size, p));
}
}
}
let data = root.join("data");
if let Ok(ids) = fs::read_dir(&data) {
for id_entry in ids.flatten() {
let id_path = id_entry.path();
if !id_path.is_dir() {
continue;
}
let id = id_entry.file_name().to_string_lossy().into_owned();
if let Ok(versions) = fs::read_dir(&id_path) {
for v in versions.flatten() {
let vp = v.path();
if !vp.is_dir() {
continue;
}
let version = v.file_name().to_string_lossy().into_owned();
let size = dir_size(&vp).unwrap_or(0);
out.push(("dataset", format!("{id}@{version}"), size, vp));
}
}
}
}
out.sort_by(|a, b| a.0.cmp(b.0).then_with(|| a.1.cmp(&b.1)));
out
}
#[derive(Debug, Default, Clone)]
pub struct PruneSummary {
pub sifs_removed: usize,
pub sif_bytes: u64,
pub manifests_removed: usize,
pub manifest_bytes: u64,
pub indexes_removed: usize,
pub index_bytes: u64,
pub tmp_removed: usize,
pub tmp_bytes: u64,
}
impl PruneSummary {
pub fn total_bytes(&self) -> u64 {
self.sif_bytes + self.manifest_bytes + self.index_bytes + self.tmp_bytes
}
}
#[derive(Debug, Default, Clone)]
pub struct Reachable {
pub digests: BTreeSet<String>,
pub tool_versions: BTreeSet<(String, String)>,
pub image_references: BTreeSet<String>,
}
impl Reachable {
pub fn merge(&mut self, lock: &Lockfile) {
for entry in lock.tools.values() {
self.digests.insert(entry.image_digest.clone());
self.tool_versions
.insert((entry.tool_id.clone(), entry.version.clone()));
self.image_references.insert(entry.image_reference.clone());
}
}
}
pub fn run_prune(
dry_run: bool,
yes: bool,
all: bool,
keep_recent: Option<usize>,
) -> anyhow::Result<()> {
let cache = CacheLayout::new();
let root = cache.root().clone();
if !root.exists() {
println!(" cache is empty (root: {})", display_path(&root));
return Ok(());
}
let cwd = std::env::current_dir().ok();
let reachable = gather_reachable(cwd.as_deref());
let plan = plan_prune(&root, &reachable, all, keep_recent)?;
let tmp_plan = plan_tmp(&root.join("tmp"));
let total_items = plan.items.len() + tmp_plan.items.len();
let total_bytes: u64 = plan.items.iter().map(|i| i.size).sum::<u64>()
+ tmp_plan.items.iter().map(|i| i.size).sum::<u64>();
let owned_path = cache.owned_images_path();
let owned = bv_core::owned_images::OwnedImages::load(&owned_path);
let docker_candidates = docker_unreferenced_images(&owned, &reachable, all);
if total_items == 0 && docker_candidates.is_empty() {
println!(" nothing to prune");
return Ok(());
}
if dry_run {
println!(" (dry run) would remove:");
print_plan(&plan, &tmp_plan);
if !docker_candidates.is_empty() {
println!(" Docker images (unreferenced by any bv.lock):");
for img in &docker_candidates {
println!(" {}", img.display_ref);
}
}
println!(
" Total: {} items, {} would be freed.",
total_items,
format_size(total_bytes),
);
return Ok(());
}
let confirm_msg = match (total_items, docker_candidates.len()) {
(0, d) => format!(
" Remove {d} Docker image{}? [y/N] ",
if d == 1 { "" } else { "s" }
),
(n, 0) => format!(
" Remove {n} cache item{}, free {}? [y/N] ",
if n == 1 { "" } else { "s" },
format_size(total_bytes)
),
(n, d) => format!(
" Remove {n} cache item{} and {d} Docker image{}, free {}? [y/N] ",
if n == 1 { "" } else { "s" },
if d == 1 { "" } else { "s" },
format_size(total_bytes)
),
};
if !yes {
eprint!("{confirm_msg}");
let _ = std::io::stderr().flush();
let mut buf = String::new();
std::io::stdin().read_line(&mut buf)?;
let answer = buf.trim().to_ascii_lowercase();
if answer != "y" && answer != "yes" {
eprintln!(" aborted");
return Ok(());
}
}
let summary = apply_plan(&plan, &tmp_plan)?;
print_summary(&summary);
for img in &docker_candidates {
let ok = std::process::Command::new("docker")
.args(["rmi", &img.rmi_ref])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
let _ = bv_core::owned_images::remove_by_digest(&owned_path, &img.digest);
println!(
" {} Docker image {}",
"Removed".if_supports_color(Stream::Stdout, |t| t.green().bold().to_string()),
img.display_ref,
);
} else {
let containers = containers_using_image(&img.digest);
let hint = if containers.is_empty() {
"may be in use or Docker unavailable".to_string()
} else {
format!("in use by: {}", containers.join(", "))
};
println!(
" {} could not remove Docker image {} ({})",
"warning:".if_supports_color(Stream::Stdout, |t| t.yellow().bold().to_string()),
img.display_ref,
hint,
);
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct PruneItem {
pub category: &'static str,
pub display: String,
pub path: PathBuf,
pub size: u64,
}
#[derive(Debug, Default, Clone)]
pub struct PrunePlan {
pub items: Vec<PruneItem>,
}
pub fn plan_prune(
root: &Path,
reachable: &Reachable,
all: bool,
keep_recent: Option<usize>,
) -> anyhow::Result<PrunePlan> {
let mut plan = PrunePlan::default();
let sif_dir = root.join("sif");
if let Ok(entries) = fs::read_dir(&sif_dir) {
for e in entries.flatten() {
let p = e.path();
if !p.is_file() || p.extension().is_none_or(|ext| ext != "sif") {
continue;
}
let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let is_reachable = !all && reachable.digests.iter().any(|d| sanitize_digest(d) == stem);
if !is_reachable {
let size = file_size(&p);
plan.items.push(PruneItem {
category: "sif",
display: p
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
path: p,
size,
});
}
}
}
let tools_dir = root.join("tools");
if let Ok(ids) = fs::read_dir(&tools_dir) {
let mut per_tool: BTreeMap<String, Vec<(String, PathBuf, SystemTime, u64)>> =
BTreeMap::new();
for id_entry in ids.flatten() {
let id_path = id_entry.path();
if !id_path.is_dir() {
continue;
}
let id = id_entry.file_name().to_string_lossy().into_owned();
if let Ok(versions) = fs::read_dir(&id_path) {
for v in versions.flatten() {
let vp = v.path();
if !vp.is_dir() {
continue;
}
let version = v.file_name().to_string_lossy().into_owned();
let mtime = v
.metadata()
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let size = dir_size(&vp).unwrap_or(0);
per_tool
.entry(id.clone())
.or_default()
.push((version, vp, mtime, size));
}
}
}
for (id, mut versions) in per_tool {
versions.sort_by(|a, b| b.2.cmp(&a.2));
let keep_count = keep_recent.unwrap_or(0);
let mut kept_extra: usize = 0;
for (version, vp, _mtime, size) in versions {
let is_reachable = !all
&& reachable
.tool_versions
.contains(&(id.clone(), version.clone()));
if is_reachable {
continue;
}
if !all && kept_extra < keep_count {
kept_extra += 1;
continue;
}
plan.items.push(PruneItem {
category: "manifest",
display: format!("{id}@{version}"),
path: vp,
size,
});
}
}
}
let index_dir = root.join("index");
if let Ok(entries) = fs::read_dir(&index_dir) {
let now = SystemTime::now();
for e in entries.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().into_owned();
if name == "default" {
continue;
}
let mtime = e
.metadata()
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let age = now.duration_since(mtime).unwrap_or(Duration::ZERO);
if all || age > INDEX_TTL {
let size = dir_size(&p).unwrap_or(0);
plan.items.push(PruneItem {
category: "index",
display: name,
path: p,
size,
});
}
}
}
Ok(plan)
}
pub fn plan_tmp(tmp_dir: &Path) -> PrunePlan {
let mut plan = PrunePlan::default();
let Ok(entries) = fs::read_dir(tmp_dir) else {
return plan;
};
let now = SystemTime::now();
for e in entries.flatten() {
let p = e.path();
let mtime = e
.metadata()
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let age = now.duration_since(mtime).unwrap_or(Duration::ZERO);
if age <= TMP_TTL {
continue;
}
let size = if p.is_dir() {
dir_size(&p).unwrap_or(0)
} else {
file_size(&p)
};
plan.items.push(PruneItem {
category: "tmp",
display: p
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
path: p,
size,
});
}
plan
}
fn print_plan(plan: &PrunePlan, tmp_plan: &PrunePlan) {
for item in plan.items.iter().chain(tmp_plan.items.iter()) {
println!(
" {:<10} {:<40} {}",
item.category,
truncate(&item.display, 40),
format_size(item.size),
);
}
}
pub fn apply_plan(plan: &PrunePlan, tmp_plan: &PrunePlan) -> anyhow::Result<PruneSummary> {
let mut summary = PruneSummary::default();
for item in plan.items.iter().chain(tmp_plan.items.iter()) {
let removed = remove_path(&item.path).is_ok();
if !removed {
continue;
}
match item.category {
"sif" => {
summary.sifs_removed += 1;
summary.sif_bytes += item.size;
}
"manifest" => {
summary.manifests_removed += 1;
summary.manifest_bytes += item.size;
}
"index" => {
summary.indexes_removed += 1;
summary.index_bytes += item.size;
}
"tmp" => {
summary.tmp_removed += 1;
summary.tmp_bytes += item.size;
}
_ => {}
}
}
Ok(summary)
}
fn print_summary(s: &PruneSummary) {
let removed = "Removed".if_supports_color(Stream::Stdout, |t| t.green().bold().to_string());
println!(
" {removed} {:>3} SIFs {}",
s.sifs_removed,
format_size(s.sif_bytes)
);
println!(
" {removed} {:>3} manifests {}",
s.manifests_removed,
format_size(s.manifest_bytes)
);
if s.indexes_removed > 0 {
println!(
" {removed} {:>3} indexes {}",
s.indexes_removed,
format_size(s.index_bytes)
);
}
if s.tmp_removed > 0 {
println!(
" {removed} {:>3} tmp entries {}",
s.tmp_removed,
format_size(s.tmp_bytes)
);
}
println!(
" {} {}",
"Total freed".if_supports_color(Stream::Stdout, |t| t.bold().to_string()),
format_size(s.total_bytes())
);
}
pub fn gather_reachable(cwd: Option<&Path>) -> Reachable {
let mut reach = Reachable::default();
if let Some(cwd) = cwd {
let p = cwd.join("bv.lock");
if p.exists()
&& let Ok(lock) = BvLock::from_path(&p)
{
reach.merge(&lock);
}
}
if let Ok(known) = std::env::var("BV_KNOWN_PROJECTS") {
for dir in known.split(':').filter(|s| !s.is_empty()) {
collect_locks_under(Path::new(dir), &mut reach);
}
}
reach
}
fn collect_locks_under(root: &Path, reach: &mut Reachable) {
fn walk(p: &Path, depth: usize, reach: &mut Reachable) {
if depth == 0 {
return;
}
let lock = p.join("bv.lock");
if lock.is_file()
&& let Ok(l) = BvLock::from_path(&lock)
{
reach.merge(&l);
}
let Ok(entries) = fs::read_dir(p) else {
return;
};
for e in entries.flatten() {
let path = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
if name.starts_with('.')
|| name == "node_modules"
|| name == "target"
|| name == "venv"
|| name == ".venv"
{
continue;
}
if path.is_dir() {
walk(&path, depth - 1, reach);
}
}
}
walk(root, 6, reach);
}
struct DockerImage {
display_ref: String,
rmi_ref: String,
digest: String,
}
fn docker_unreferenced_images(
owned: &bv_core::owned_images::OwnedImages,
reachable: &Reachable,
remove_all: bool,
) -> Vec<DockerImage> {
if owned.is_empty() && !remove_all {
return vec![];
}
let Ok(out) = std::process::Command::new("docker")
.args([
"images",
"--no-trunc",
"--format",
"{{.Repository}}:{{.Tag}}\t{{.Digest}}\t{{.ID}}",
])
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
else {
return vec![];
};
if !out.status.success() {
return vec![];
}
let mut candidates = vec![];
for line in String::from_utf8_lossy(&out.stdout).lines() {
let parts: Vec<&str> = line.splitn(3, '\t').collect();
if parts.len() < 3 {
continue;
}
let ref_tag = parts[0];
let digest = parts[1]; let id = parts[2];
let is_owned = owned.references.contains(ref_tag)
|| (!digest.is_empty() && digest != "<none>" && owned.digests.contains(digest))
|| (!id.is_empty() && owned.digests.contains(id));
if !is_owned {
continue;
}
if !remove_all {
let in_active_lockfile = reachable.image_references.contains(ref_tag)
|| (!digest.is_empty() && digest != "<none>" && reachable.digests.contains(digest));
if in_active_lockfile {
continue;
}
}
let tag = ref_tag.rsplit_once(':').map(|(_, t)| t).unwrap_or("");
let has_digest = !digest.is_empty() && digest != "<none>";
let display_ref = if tag == "<none>" {
if has_digest {
let repo = ref_tag.trim_end_matches(":<none>");
format!("{repo}@{digest}")
} else {
let short_id = id.get(7..19).unwrap_or(id); format!("{} (id {})", ref_tag.trim_end_matches(":<none>"), short_id)
}
} else if has_digest {
format!("{ref_tag}@{digest}")
} else {
ref_tag.to_string()
};
let rmi_ref = if tag == "<none>" {
id.to_string()
} else if has_digest {
format!("{ref_tag}@{digest}")
} else {
ref_tag.to_string()
};
candidates.push(DockerImage {
display_ref,
rmi_ref,
digest: id.to_string(),
});
}
candidates
}
fn containers_using_image(image_id: &str) -> Vec<String> {
let Ok(out) = std::process::Command::new("docker")
.args([
"ps",
"-a",
"--filter",
&format!("ancestor={image_id}"),
"--format",
"{{.Names}}",
])
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
else {
return vec![];
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
}
fn dir_size(path: &Path) -> std::io::Result<u64> {
let mut total: u64 = 0;
let mut stack = vec![path.to_path_buf()];
while let Some(p) = stack.pop() {
let entries = match fs::read_dir(&p) {
Ok(e) => e,
Err(_) => continue,
};
for e in entries.flatten() {
let path = e.path();
let meta = match e.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if meta.is_dir() {
stack.push(path);
} else if meta.is_file() {
total += meta.len();
}
}
}
Ok(total)
}
fn file_size(path: &Path) -> u64 {
fs::metadata(path).map(|m| m.len()).unwrap_or(0)
}
fn remove_path(path: &Path) -> std::io::Result<()> {
let meta = fs::symlink_metadata(path)?;
if meta.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
}
}
fn sanitize_digest(digest: &str) -> String {
digest
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect()
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut out = String::with_capacity(max);
out.push_str(&s[..max.saturating_sub(1)]);
out.push('…');
out
}
fn display_path(p: &Path) -> String {
if let Ok(home) = std::env::var("HOME")
&& let Ok(rest) = p.strip_prefix(&home)
{
return format!("~/{}", rest.display());
}
p.display().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use bv_core::lockfile::{Lockfile, LockfileEntry};
use chrono::Utc;
use std::collections::BTreeMap;
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, bytes).unwrap();
}
fn make_entry(id: &str, version: &str, digest: &str) -> LockfileEntry {
LockfileEntry {
tool_id: id.into(),
declared_version_req: String::new(),
version: version.into(),
spec_kind: bv_core::lockfile::SpecKind::LegacyImage,
image_reference: format!("registry/{id}:{version}"),
image_digest: digest.into(),
manifest_sha256: String::new(),
image_size_bytes: None,
layers: vec![],
resolved_at: Utc::now(),
reference_data_pins: BTreeMap::new(),
binaries: vec![],
}
}
#[test]
fn dir_size_sums_files_recursively() {
let tmp = tempfile::tempdir().unwrap();
write_file(&tmp.path().join("a.txt"), &[0u8; 100]);
write_file(&tmp.path().join("nested/b.txt"), &[0u8; 250]);
write_file(&tmp.path().join("nested/c/d.txt"), &[0u8; 50]);
let total = dir_size(tmp.path()).unwrap();
assert_eq!(total, 400);
}
#[test]
fn sanitize_digest_matches_apptainer_filename() {
let digest = "sha256:abc123";
let sanitized = sanitize_digest(digest);
assert_eq!(sanitized, "sha256_abc123");
}
#[test]
fn plan_prune_keeps_reachable_sifs_and_manifests() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let keep_digest = "sha256:keep";
let drop_digest = "sha256:drop";
let sif_keep = root
.join("sif")
.join(format!("{}.sif", sanitize_digest(keep_digest)));
let sif_drop = root
.join("sif")
.join(format!("{}.sif", sanitize_digest(drop_digest)));
write_file(&sif_keep, &[1u8; 1024]);
write_file(&sif_drop, &[2u8; 2048]);
write_file(&root.join("tools/foo/1.0/manifest.toml"), b"x");
write_file(&root.join("tools/foo/0.9/manifest.toml"), b"x");
write_file(&root.join("tools/bar/2.0/manifest.toml"), b"x");
write_file(&root.join("index/default/HEAD"), b"ref");
let custom = root.join("index/custom/HEAD");
write_file(&custom, b"ref");
let old = SystemTime::now() - Duration::from_secs(60 * 24 * 60 * 60);
filetime::set_file_mtime(
root.join("index/custom"),
filetime::FileTime::from_system_time(old),
)
.ok();
let mut reach = Reachable::default();
reach.digests.insert(keep_digest.to_string());
reach
.tool_versions
.insert(("foo".to_string(), "1.0".to_string()));
let plan = plan_prune(root, &reach, false, None).unwrap();
let categories: Vec<_> = plan
.items
.iter()
.map(|i| (i.category, i.display.clone()))
.collect();
assert!(
!categories
.iter()
.any(|(c, d)| *c == "sif" && d.contains("keep")),
"reachable SIF was scheduled for removal: {categories:?}"
);
assert!(
!categories
.iter()
.any(|(c, d)| *c == "manifest" && d == "foo@1.0"),
"reachable manifest was scheduled for removal: {categories:?}"
);
assert!(
categories
.iter()
.any(|(c, d)| *c == "sif" && d.contains("drop")),
"orphan SIF was not scheduled for removal: {categories:?}"
);
assert!(
categories
.iter()
.any(|(c, d)| *c == "manifest" && d == "foo@0.9")
);
assert!(
categories
.iter()
.any(|(c, d)| *c == "manifest" && d == "bar@2.0")
);
if filetime::FileTime::from_system_time(old) > filetime::FileTime::from_unix_time(0, 0) {
}
}
#[test]
fn plan_prune_all_drops_everything() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_file(&root.join("sif/whatever.sif"), &[0u8; 10]);
write_file(&root.join("tools/foo/1.0/manifest.toml"), b"x");
let plan = plan_prune(root, &Reachable::default(), true, None).unwrap();
assert!(plan.items.iter().any(|i| i.category == "sif"));
assert!(plan.items.iter().any(|i| i.category == "manifest"));
}
#[test]
fn keep_recent_keeps_newest_unreachable_versions() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for v in ["1.0", "1.1", "1.2"] {
write_file(&root.join(format!("tools/foo/{v}/manifest.toml")), b"x");
}
let now = SystemTime::now();
filetime::set_file_mtime(
root.join("tools/foo/1.0"),
filetime::FileTime::from_system_time(now - Duration::from_secs(300)),
)
.ok();
filetime::set_file_mtime(
root.join("tools/foo/1.1"),
filetime::FileTime::from_system_time(now - Duration::from_secs(200)),
)
.ok();
filetime::set_file_mtime(
root.join("tools/foo/1.2"),
filetime::FileTime::from_system_time(now - Duration::from_secs(100)),
)
.ok();
let plan = plan_prune(root, &Reachable::default(), false, Some(1)).unwrap();
let mans: Vec<&str> = plan
.items
.iter()
.filter(|i| i.category == "manifest")
.map(|i| i.display.as_str())
.collect();
assert_eq!(mans.len(), 2, "{mans:?}");
assert!(mans.contains(&"foo@1.0"));
assert!(mans.contains(&"foo@1.1"));
}
#[test]
fn run_size_walks_layout() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_file(&root.join("sif/x.sif"), &[0u8; 4096]);
write_file(&root.join("tools/foo/1.0/manifest.toml"), &[0u8; 100]);
write_file(&root.join("data/db/v1/file.bin"), &[0u8; 8192]);
let cache = CacheLayout::with_root(root.to_path_buf());
let sif = dir_size(&cache.sif_dir()).unwrap();
let tools = dir_size(&cache.root().join("tools")).unwrap();
let data = dir_size(&cache.root().join("data")).unwrap();
assert_eq!(sif, 4096);
assert_eq!(tools, 100);
assert_eq!(data, 8192);
}
#[test]
fn gather_reachable_reads_pwd_lockfile() {
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path();
let mut lock = Lockfile::new();
lock.tools
.insert("foo".into(), make_entry("foo", "1.0", "sha256:abc"));
let s = lock.to_toml_string().unwrap();
fs::write(cwd.join("bv.lock"), s).unwrap();
let reach = gather_reachable(Some(cwd));
assert!(reach.digests.contains("sha256:abc"));
assert!(reach.tool_versions.contains(&("foo".into(), "1.0".into())));
}
#[test]
fn plan_tmp_only_takes_old_entries() {
let tmp = tempfile::tempdir().unwrap();
let tmp_dir = tmp.path().join("tmp");
fs::create_dir_all(&tmp_dir).unwrap();
let young = tmp_dir.join("young.bin");
let old = tmp_dir.join("old.bin");
write_file(&young, &[0u8; 16]);
write_file(&old, &[0u8; 16]);
let two_hours_ago = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
filetime::set_file_mtime(&old, filetime::FileTime::from_system_time(two_hours_ago)).ok();
let plan = plan_tmp(&tmp_dir);
let names: Vec<_> = plan.items.iter().map(|i| i.display.clone()).collect();
assert!(names.iter().any(|n| n == "old.bin"));
assert!(!names.iter().any(|n| n == "young.bin"));
}
}