use crate::env_source::{EnvSource, ProcessEnvSource};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread::JoinHandle;
use std::time::Duration;
const GIB: u64 = 1024 * 1024 * 1024;
pub const DEFAULT_ABS_FLOOR_BYTES: u64 = 2 * GIB;
pub const DEFAULT_SAFETY_FACTOR: f64 = 1.3;
pub const DEFAULT_SAMPLE_INTERVAL: Duration = Duration::from_millis(500);
pub const ABS_FLOOR_ENV: &str = "ANODIZER_DET_DISK_FLOOR_GIB";
pub const SAFETY_FACTOR_ENV: &str = "ANODIZER_DET_DISK_SAFETY_FACTOR";
pub fn abs_floor_bytes_from_env() -> u64 {
abs_floor_bytes_from_env_with(&ProcessEnvSource)
}
pub fn abs_floor_bytes_from_env_with(env: &dyn EnvSource) -> u64 {
env.var(ABS_FLOOR_ENV)
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|&gib| gib > 0)
.and_then(|gib| gib.checked_mul(GIB))
.unwrap_or(DEFAULT_ABS_FLOOR_BYTES)
}
pub fn safety_factor_from_env() -> f64 {
safety_factor_from_env_with(&ProcessEnvSource)
}
pub fn safety_factor_from_env_with(env: &dyn EnvSource) -> f64 {
env.var(SAFETY_FACTOR_ENV)
.and_then(|v| v.trim().parse::<f64>().ok())
.filter(|f| f.is_finite() && *f > 0.0)
.unwrap_or(DEFAULT_SAFETY_FACTOR)
}
pub fn format_gib(bytes: u64) -> String {
format!("{:.1} GiB", bytes as f64 / GIB as f64)
}
pub fn format_gib_exact(bytes: u64) -> String {
format!("{:.2} GiB ({bytes} bytes)", bytes as f64 / GIB as f64)
}
pub fn available_bytes(path: &Path) -> Option<u64> {
if !path.try_exists().unwrap_or(false) {
return None;
}
fs4::available_space(path).ok()
}
pub fn dir_size_bytes(dir: &Path) -> u64 {
let mut total = 0u64;
let mut stack = vec![dir.to_path_buf()];
while let Some(cur) = stack.pop() {
let entries = match std::fs::read_dir(&cur) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let ft = meta.file_type();
if ft.is_symlink() {
continue;
}
if ft.is_dir() {
stack.push(entry.path());
} else if ft.is_file() {
total = total.saturating_add(meta.len());
}
}
}
total
}
#[cfg(target_os = "macos")]
pub fn mounted_volumes() -> Vec<String> {
let mut names: Vec<String> = match std::fs::read_dir("/Volumes") {
Ok(entries) => entries
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect(),
Err(_) => Vec::new(),
};
names.sort();
names
}
#[cfg(not(target_os = "macos"))]
pub fn mounted_volumes() -> Vec<String> {
Vec::new()
}
pub struct FreeSpaceSampler {
stop_tx: Option<std::sync::mpsc::Sender<()>>,
min_free: Arc<AtomicU64>,
handle: Option<JoinHandle<()>>,
}
impl FreeSpaceSampler {
const NO_SAMPLE: u64 = u64::MAX;
pub fn start(vol: &Path, interval: Duration) -> Self {
let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
let min_free = Arc::new(AtomicU64::new(Self::NO_SAMPLE));
let min_t = Arc::clone(&min_free);
let vol: PathBuf = vol.to_path_buf();
let handle = std::thread::spawn(move || {
let sample = || {
if let Some(free) = available_bytes(&vol) {
min_t.fetch_min(free, Ordering::Relaxed);
}
};
sample();
crate::progress::run_ticker(&stop_rx, interval, sample);
});
Self {
stop_tx: Some(stop_tx),
min_free,
handle: Some(handle),
}
}
pub fn stop(mut self) -> Option<u64> {
self.signal_and_join();
match self.min_free.load(Ordering::Relaxed) {
Self::NO_SAMPLE => None,
v => Some(v),
}
}
fn signal_and_join(&mut self) {
self.stop_tx = None;
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
impl Drop for FreeSpaceSampler {
fn drop(&mut self) {
self.signal_and_join();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RunPeak {
pub free_before: u64,
pub min_free_during: u64,
}
impl RunPeak {
pub fn consumed_bytes(&self) -> u64 {
self.free_before.saturating_sub(self.min_free_during)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HeadroomDecision {
Proceed,
Abort(HeadroomShortfall),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadroomShortfall {
pub run_idx: u32,
pub required_bytes: u64,
pub available_bytes: u64,
pub volume: String,
pub peak_gated: bool,
}
impl HeadroomShortfall {
pub fn message(&self) -> String {
let basis = if self.peak_gated {
"the measured peak of a prior run's universal rebuild + dmg staging"
} else {
"the absolute floor for the first run's universal rebuild + dmg staging"
};
format!(
"determinism run {} needs ~{} free ({}); only {} available on {}. \
Strengthen reclaim-disk (raise the macOS shard's reclaim target) or use a \
larger runner. Override the floor with {}=<GiB> / the safety factor with \
{}=<f> if this estimate is wrong for your project.",
self.run_idx + 1,
format_gib_exact(self.required_bytes),
basis,
format_gib_exact(self.available_bytes),
self.volume,
ABS_FLOOR_ENV,
SAFETY_FACTOR_ENV,
)
}
}
fn project_peak(peak: u64, safety_factor: f64) -> u64 {
let scaled = peak as f64 * safety_factor;
if scaled >= u64::MAX as f64 {
u64::MAX
} else {
scaled as u64
}
}
pub fn evaluate_headroom(
run_idx: u32,
available: u64,
abs_floor: u64,
prior_peak: Option<u64>,
safety_factor: f64,
volume: &str,
) -> HeadroomDecision {
let projected = prior_peak.map(|p| project_peak(p, safety_factor));
let peak_gated = projected.is_some_and(|p| p >= abs_floor);
let required = abs_floor.max(projected.unwrap_or(0));
if available >= required {
HeadroomDecision::Proceed
} else {
HeadroomDecision::Abort(HeadroomShortfall {
run_idx,
required_bytes: required,
available_bytes: available,
volume: volume.to_string(),
peak_gated,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::env_source::MapEnvSource;
use std::fs;
use tempfile::tempdir;
#[test]
fn format_gib_one_decimal() {
assert_eq!(format_gib(0), "0.0 GiB");
assert_eq!(format_gib(GIB), "1.0 GiB");
assert_eq!(format_gib(GIB + GIB / 2), "1.5 GiB");
assert_eq!(format_gib(101 * GIB + GIB / 5), "101.2 GiB");
}
#[test]
fn format_gib_exact_carries_raw_bytes() {
let s = format_gib_exact(12 * GIB);
assert!(s.contains("12.00 GiB"), "two decimals: {s}");
assert!(s.contains(&format!("{}", 12 * GIB)), "raw bytes: {s}");
}
#[test]
fn available_bytes_probes_real_volume() {
let dir = tempdir().unwrap();
assert!(
available_bytes(dir.path()).is_some(),
"available_bytes must resolve on a real dir"
);
}
#[test]
fn available_bytes_missing_path_is_none_not_panic() {
let dir = tempdir().unwrap();
let missing = dir.path().join("does-not-exist");
assert_eq!(available_bytes(&missing), None);
}
#[test]
fn dir_size_sums_regular_files_recursively() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("a"), vec![0u8; 100]).unwrap();
fs::create_dir(dir.path().join("sub")).unwrap();
fs::write(dir.path().join("sub").join("b"), vec![0u8; 250]).unwrap();
assert_eq!(dir_size_bytes(dir.path()), 350);
}
#[test]
fn dir_size_missing_dir_is_zero() {
let dir = tempdir().unwrap();
assert_eq!(dir_size_bytes(&dir.path().join("nope")), 0);
}
#[test]
fn dir_size_does_not_follow_symlinks() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("real"), vec![0u8; 500]).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink(dir.path().join("real"), dir.path().join("link")).unwrap();
assert_eq!(dir_size_bytes(dir.path()), 500);
}
}
#[test]
fn sampler_records_a_minimum_on_a_real_volume() {
let dir = tempdir().unwrap();
let s = FreeSpaceSampler::start(dir.path(), Duration::from_millis(5));
std::thread::sleep(Duration::from_millis(20));
assert!(
s.stop().is_some(),
"sampler must record a reading on a real volume"
);
}
#[test]
fn sampler_probe_gap_yields_none() {
let dir = tempdir().unwrap();
let missing = dir.path().join("nope");
let s = FreeSpaceSampler::start(&missing, Duration::from_millis(5));
std::thread::sleep(Duration::from_millis(20));
assert_eq!(s.stop(), None);
}
#[test]
fn run_peak_consumed_is_the_drop_to_the_minimum() {
let p = RunPeak {
free_before: 100 * GIB,
min_free_during: 30 * GIB,
};
assert_eq!(p.consumed_bytes(), 70 * GIB);
}
#[test]
fn run_peak_clamps_when_free_space_grew_mid_run() {
let p = RunPeak {
free_before: 80 * GIB,
min_free_during: 90 * GIB,
};
assert_eq!(p.consumed_bytes(), 0);
}
#[test]
fn headroom_run0_gated_by_absolute_floor_only_not_peak() {
let ok = evaluate_headroom(0, 50 * GIB, 45 * GIB, None, 1.3, "/");
assert_eq!(ok, HeadroomDecision::Proceed);
let bad = evaluate_headroom(0, 30 * GIB, 45 * GIB, None, 1.3, "/");
match bad {
HeadroomDecision::Abort(s) => {
assert_eq!(s.run_idx, 0);
assert_eq!(s.required_bytes, 45 * GIB);
assert_eq!(s.available_bytes, 30 * GIB);
assert!(!s.peak_gated, "run-0 is floor-gated, not peak-gated");
assert!(
s.message().contains("absolute floor"),
"run-0 message must state the floor basis: {}",
s.message()
);
}
other => panic!("expected Abort, got {other:?}"),
}
}
#[test]
fn headroom_later_run_uses_measured_peak_when_above_floor() {
let prior_peak = Some(70 * GIB);
let decision = evaluate_headroom(1, 71 * GIB, 45 * GIB, prior_peak, 1.3, "/Volumes/x");
match decision {
HeadroomDecision::Abort(s) => {
assert_eq!(s.run_idx, 1);
assert_eq!(s.required_bytes, 91 * GIB);
assert_eq!(s.available_bytes, 71 * GIB);
assert!(s.peak_gated, "run-1 must be peak-gated");
assert!(
s.message().contains("measured peak"),
"message must state the peak basis: {}",
s.message()
);
}
other => panic!("expected Abort (71 < 91), got {other:?}"),
}
assert_eq!(
evaluate_headroom(1, 95 * GIB, 45 * GIB, prior_peak, 1.3, "/Volumes/x"),
HeadroomDecision::Proceed
);
}
#[test]
fn headroom_floor_backstops_a_tiny_measured_peak() {
let prior_peak = Some(GIB);
let bad = evaluate_headroom(2, 30 * GIB, 45 * GIB, prior_peak, 1.3, "/");
match bad {
HeadroomDecision::Abort(s) => {
assert_eq!(s.required_bytes, 45 * GIB);
assert!(!s.peak_gated, "floor won, so not peak-gated");
}
other => panic!("expected Abort, got {other:?}"),
}
}
#[test]
fn project_peak_saturates_at_extremes_instead_of_wrapping() {
assert_eq!(project_peak(u64::MAX, 2.0), u64::MAX);
assert_eq!(
project_peak(10 * GIB, 1.3),
(10.0 * GIB as f64 * 1.3) as u64
);
}
#[test]
fn shortfall_message_carries_exact_numbers_and_remedies() {
let s = HeadroomShortfall {
run_idx: 1,
required_bytes: 91 * GIB,
available_bytes: 71 * GIB,
volume: "/Volumes/scratch".into(),
peak_gated: true,
};
let msg = s.message();
assert!(msg.contains("determinism run 2"), "1-based run: {msg}");
assert!(
msg.contains(&format!("{}", 91 * GIB)),
"exact required: {msg}"
);
assert!(
msg.contains(&format!("{}", 71 * GIB)),
"exact available: {msg}"
);
assert!(msg.contains("/Volumes/scratch"), "volume: {msg}");
assert!(msg.contains("reclaim-disk"), "remedy: {msg}");
assert!(msg.contains(ABS_FLOOR_ENV), "floor override hint: {msg}");
assert!(
msg.contains(SAFETY_FACTOR_ENV),
"factor override hint: {msg}"
);
}
#[test]
fn abs_floor_env_rejects_zero_malformed_overflow_accepts_valid() {
let cases: &[(&str, u64)] = &[
("0", DEFAULT_ABS_FLOOR_BYTES), ("abc", DEFAULT_ABS_FLOOR_BYTES), ("18446744073", DEFAULT_ABS_FLOOR_BYTES), ("60", 60 * GIB), ("", DEFAULT_ABS_FLOOR_BYTES), ];
for (val, want) in cases {
let env = MapEnvSource::new().with(ABS_FLOOR_ENV, *val);
assert_eq!(
abs_floor_bytes_from_env_with(&env),
*want,
"ABS_FLOOR_ENV={val:?} should resolve to {want}"
);
}
assert_eq!(
abs_floor_bytes_from_env_with(&MapEnvSource::new()),
DEFAULT_ABS_FLOOR_BYTES,
"unset → default"
);
}
#[test]
fn safety_factor_env_rejects_nonpositive_accepts_valid() {
let cases: &[(&str, f64)] = &[
("0", DEFAULT_SAFETY_FACTOR),
("-1.0", DEFAULT_SAFETY_FACTOR),
("nan", DEFAULT_SAFETY_FACTOR),
("xyz", DEFAULT_SAFETY_FACTOR),
("2.0", 2.0),
];
for (val, want) in cases {
let env = MapEnvSource::new().with(SAFETY_FACTOR_ENV, *val);
assert!(
(safety_factor_from_env_with(&env) - want).abs() < f64::EPSILON,
"SAFETY_FACTOR_ENV={val:?} should resolve to {want}"
);
}
assert!(
(safety_factor_from_env_with(&MapEnvSource::new()) - DEFAULT_SAFETY_FACTOR).abs()
< f64::EPSILON
);
}
#[test]
fn defaults_are_the_documented_values() {
assert_eq!(DEFAULT_ABS_FLOOR_BYTES, 2 * GIB);
assert!((DEFAULT_SAFETY_FACTOR - 1.3).abs() < f64::EPSILON);
}
}