use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use crate::proc::Quiet as _;
pub fn enough_space(free: u64, min_free: u64) -> bool {
free >= min_free
}
pub fn gate(free: u64, min_free: u64) -> Option<String> {
if enough_space(free, min_free) {
None
} else {
Some(format!(
"not enough free space to start a run: {free} bytes free, \
{min_free} required by `[disk] min_free_bytes`"
))
}
}
pub fn over_limit(size: u64, limit: u64) -> bool {
size > limit
}
pub fn extract_cargo_target_dir(command: &str) -> Option<PathBuf> {
const KEY: &str = "CARGO_TARGET_DIR=";
let rest = command.split_once(KEY)?.1.trim_start();
let value = if let Some(s) = rest.strip_prefix('\'') {
s.split('\'').next().unwrap_or("")
} else if let Some(s) = rest.strip_prefix('"') {
s.split('"').next().unwrap_or("")
} else {
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
&rest[..end]
};
if value.is_empty() {
None
} else {
Some(PathBuf::from(value))
}
}
pub fn free_bytes(path: &Path) -> Result<u64> {
free_bytes_by_os(path)
}
#[cfg(unix)]
fn free_bytes_by_os(path: &Path) -> Result<u64> {
let out = std::process::Command::new("df")
.args(["-k", "-P"])
.arg(path)
.quiet()
.output()
.with_context(|| format!("run `df` for {}", path.display()))?;
if !out.status.success() {
bail!(
"`df` failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.skip(1)
.find_map(parse_df_available)
.with_context(|| format!("parse `df` output for {}", path.display()))
}
#[cfg(windows)]
fn free_bytes_by_os(path: &Path) -> Result<u64> {
let abs = std::path::absolute(path)
.with_context(|| format!("absolute path for {}", path.display()))?;
let root = volume_root(&abs)
.with_context(|| format!("no volume root in {} to measure", abs.display()))?;
let quoted = root.replace('\'', "''");
let script = format!("[System.IO.DriveInfo]::new('{quoted}').AvailableFreeSpace");
let out = std::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.quiet()
.output()
.with_context(|| format!("run PowerShell for {}", abs.display()))?;
if !out.status.success() {
bail!(
"PowerShell failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
parse_u64(&String::from_utf8_lossy(&out.stdout))
.with_context(|| format!("parse PowerShell bytes for {}", abs.display()))
}
pub fn parse_df_available(line: &str) -> Option<u64> {
let mut fields = line.split_whitespace();
fields.next()?; fields.next()?; fields.next()?; let blocks: u64 = fields.next()?.parse().ok()?;
Some(blocks.saturating_mul(1024))
}
pub fn volume_root(path: &Path) -> Option<String> {
let text = path.to_str()?;
let bare = text
.strip_prefix(r"\\?\")
.or_else(|| text.strip_prefix("//?/"))
.unwrap_or(text);
let mut chars = bare.chars();
let letter = chars.next()?;
if !letter.is_ascii_alphabetic() || chars.next()? != ':' {
return None;
}
Some(format!(r"{letter}:\"))
}
pub fn parse_u64(text: &str) -> Option<u64> {
text.trim().parse().ok()
}
pub fn dir_size(path: &Path) -> u64 {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return 0;
};
if meta.is_file() {
return meta.len();
}
if !meta.is_dir() {
return 0;
}
let mut total = 0u64;
let mut stack = vec![path.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for entry in rd.flatten() {
let Ok(meta) = entry.metadata() else {
continue;
};
if meta.is_dir() {
stack.push(entry.path());
} else if meta.is_file() {
total += meta.len();
}
}
}
total
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Prune {
pub freed: u64,
pub files: usize,
pub remaining: u64,
}
pub fn prune_dir(dir: &Path, limit: u64) -> Result<Prune> {
let Some(tree) = Tree::of(dir) else {
return Ok(Prune {
freed: 0,
files: 0,
remaining: 0,
});
};
let mut total = tree.total;
if !over_limit(total, limit) {
return Ok(Prune {
freed: 0,
files: 0,
remaining: total,
});
}
let mut freed = 0u64;
let mut removed = 0usize;
for (_, size, path) in tree.files {
if !over_limit(total, limit) {
break;
}
if std::fs::remove_file(&path).is_ok() {
total = total.saturating_sub(size);
freed += size;
removed += 1;
}
}
strip_empty_dirs(&tree.dirs);
Ok(Prune {
freed,
files: removed,
remaining: total,
})
}
struct Tree {
total: u64,
files: Vec<(u128, u64, PathBuf)>,
dirs: Vec<(usize, PathBuf)>,
}
impl Tree {
fn of(dir: &Path) -> Option<Tree> {
if dir.symlink_metadata().ok()?.is_dir() {
Some(Tree::from_dir(dir))
} else {
None
}
}
fn from_dir(dir: &Path) -> Tree {
let mut total = 0u64;
let mut files = Vec::new();
let mut dirs = Vec::new();
let mut stack: Vec<(usize, PathBuf)> = vec![(0, dir.to_path_buf())];
while let Some((depth, d)) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else {
continue;
};
for entry in rd.flatten() {
let Ok(meta) = entry.metadata() else {
continue;
};
let path = entry.path();
if meta.is_dir() {
dirs.push((depth + 1, path.clone()));
stack.push((depth + 1, path));
} else if meta.is_file() {
let size = meta.len();
total += size;
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos())
.unwrap_or(0);
files.push((mtime, size, path));
}
}
}
files.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
Tree { total, files, dirs }
}
}
fn strip_empty_dirs(dirs: &[(usize, PathBuf)]) {
let mut by_depth: Vec<&PathBuf> = dirs.iter().map(|(_, d)| d).collect();
by_depth.sort_unstable_by_key(|d| std::cmp::Reverse(d.iter().count()));
for d in by_depth {
let _ = std::fs::remove_dir(d);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn the_free_space_predicate_is_the_boundary() {
assert!(enough_space(100, 100));
assert!(enough_space(101, 100));
assert!(!enough_space(99, 100));
assert!(enough_space(0, 0));
}
#[test]
fn the_gate_text_conveys_both_numbers_and_opens_with_room() {
assert_eq!(
gate(9, 10).expect("closed"),
"not enough free space to start a run: 9 bytes free, 10 required by `[disk] min_free_bytes`"
);
assert_eq!(gate(10, 10), None, "exactly at the floor is open");
assert_eq!(gate(10_000, 0), None, "a zero floor is an opt-out");
}
#[test]
fn over_limit_uses_strict_greater_than() {
assert!(over_limit(11, 10));
assert!(!over_limit(10, 10));
assert!(!over_limit(9, 10));
}
#[test]
fn df_row_parses_1024_blocks_into_bytes() {
let row = "/dev/sda1 976762584 808522388 168240196 83% /home";
assert_eq!(parse_df_available(row), Some(168_240_196 * 1024));
assert_eq!(parse_df_available("garbage"), None);
assert_eq!(parse_df_available("a b c x"), None);
}
#[test]
fn a_powershell_number_is_one_unsigned_integer() {
assert_eq!(parse_u64(" 82072211456\r\n"), Some(82_072_211_456));
assert_eq!(parse_u64("nah"), None);
}
#[test]
fn the_volume_root_is_a_drive_not_the_path_it_came_from() {
assert_eq!(
volume_root(Path::new(
r"\\?\C:\Users\yukimemi\src\github.com\yukimemi\magi"
)),
Some(r"C:\".to_owned())
);
assert_eq!(
volume_root(Path::new(r"C:\Users\yukimemi")),
Some(r"C:\".to_owned())
);
assert_eq!(volume_root(Path::new(r"D:\")), Some(r"D:\".to_owned()));
assert_eq!(
volume_root(Path::new("C:/Users/yukimemi/src")),
Some(r"C:\".to_owned())
);
assert_eq!(volume_root(Path::new(r"\\server\share\dir")), None);
assert_eq!(volume_root(Path::new(r"\\?\UNC\server\share")), None);
assert_eq!(volume_root(Path::new("/home/yukimemi")), None);
}
#[test]
fn the_cache_dir_is_read_back_out_of_a_rendered_command() {
let cmd = r"CARGO_TARGET_DIR=C:\Users\me\Temp\magi-target cargo make check";
assert_eq!(
extract_cargo_target_dir(cmd),
Some(PathBuf::from(r"C:\Users\me\Temp\magi-target"))
);
assert_eq!(
extract_cargo_target_dir(r"CARGO_TARGET_DIR='/tmp/a b' cargo test"),
Some(PathBuf::from("/tmp/a b"))
);
assert_eq!(
extract_cargo_target_dir(r#"CARGO_TARGET_DIR="/tmp/qq" cargo test"#),
Some(PathBuf::from("/tmp/qq"))
);
assert_eq!(extract_cargo_target_dir("cargo make check"), None);
assert_eq!(extract_cargo_target_dir("CARGO_TARGET_DIR="), None);
let two = "CARGO_TARGET_DIR=/first and CARGO_TARGET_DIR=/second cargo x";
assert_eq!(extract_cargo_target_dir(two), Some(PathBuf::from("/first")));
}
#[test]
fn dir_size_is_zero_for_missing_and_counts_files_without_following_links() {
let t = tempfile::TempDir::new().expect("temp");
assert_eq!(dir_size(&t.path().join("nope")), 0);
fs::write(t.path().join("a"), b"12345").expect("write");
fs::create_dir(t.path().join("sub")).expect("dir");
fs::write(t.path().join("sub").join("b"), b"678").expect("write");
assert_eq!(dir_size(t.path()), 8);
#[cfg(unix)]
{
std::os::unix::fs::symlink(t.path().join("sub"), t.path().join("link"))
.expect("symlink");
assert_eq!(dir_size(t.path()), 8, "a link is counted as a link");
}
}
#[test]
fn prune_deletes_oldest_first_until_the_cap_is_met() {
let t = tempfile::TempDir::new().expect("temp");
let old = t.path().join("old");
fs::write(&old, b"yyyy").expect("write");
std::thread::sleep(std::time::Duration::from_millis(1_200));
fs::write(t.path().join("new"), b"xxxxx").expect("write");
let keep = prune_dir(t.path(), 9).expect("prune");
assert_eq!(
keep,
Prune {
freed: 0,
files: 0,
remaining: 9
}
);
let pruned = prune_dir(t.path(), 6).expect("prune");
assert!(pruned.freed > 0);
assert_eq!(pruned.files, 1);
assert_eq!(pruned.remaining, 5);
assert!(!old.exists(), "the older file is the one shed");
assert!(t.path().join("new").exists());
}
#[test]
fn prune_leaves_a_missing_dir_alone() {
let t = tempfile::TempDir::new().expect("temp");
let out = prune_dir(&t.path().join("absent"), 1).expect("prune");
assert_eq!(out, Prune::default());
}
#[test]
fn prune_sweeps_directories_the_files_leave_empty() {
let t = tempfile::TempDir::new().expect("temp");
let deep = t.path().join("a").join("b").join("c");
fs::create_dir_all(&deep).expect("dirs");
fs::write(deep.join("f"), b"1234").expect("write");
let out = prune_dir(t.path(), 0).expect("prune");
assert_eq!(out.files, 1);
assert_eq!(out.remaining, 0);
assert!(!t.path().join("a").exists(), "empty chain swept");
}
}