#![allow(dead_code)]
use crate::history::BuildHistory;
use rch_common::{BuildLocation, WorkerId};
use serde::Serialize;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, info};
#[derive(Debug, Clone)]
pub struct HeadroomConfig {
pub default_build_size_gb: f64,
pub safety_multiplier: f64,
pub max_sample_size: usize,
pub floor_free_gb: f64,
pub use_historical_data: bool,
}
impl Default for HeadroomConfig {
fn default() -> Self {
Self {
default_build_size_gb: 5.0,
safety_multiplier: 1.5,
max_sample_size: 20,
floor_free_gb: 10.0,
use_historical_data: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct HeadroomEstimate {
pub min_gb: f64,
pub expected_gb: f64,
pub max_gb: f64,
pub confidence: f64,
pub sample_count: usize,
pub is_fallback: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct HeadroomDecision {
pub worker_id: WorkerId,
pub sufficient: bool,
pub estimate: HeadroomEstimate,
pub disk_free_gb: f64,
pub reserved_gb: f64,
pub effective_free_gb: f64,
pub floor_gb: f64,
pub reason_code: String,
pub decision_path: String,
}
#[derive(Debug, Clone)]
struct Reservation {
build_id: u64,
worker_id: WorkerId,
reserved_gb: f64,
project_id: String,
}
pub struct HeadroomEstimator {
history: Arc<BuildHistory>,
config: HeadroomConfig,
reservations: Arc<RwLock<HashMap<u64, Reservation>>>,
}
impl HeadroomEstimator {
pub fn new(history: Arc<BuildHistory>, config: HeadroomConfig) -> Self {
Self {
history,
config,
reservations: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn estimate_build_headroom(&self, project_id: &str) -> HeadroomEstimate {
if !self.config.use_historical_data {
return self.fallback_estimate();
}
let builds = self
.history
.by_project(project_id, self.config.max_sample_size);
let transfer_sizes: Vec<f64> = builds
.iter()
.filter(|b| b.location == BuildLocation::Remote && b.exit_code == 0)
.filter_map(|b| b.bytes_transferred)
.map(|bytes| bytes as f64 / (1024.0 * 1024.0 * 1024.0)) .collect();
if transfer_sizes.is_empty() {
return self.fallback_estimate();
}
let n = transfer_sizes.len();
let mean = transfer_sizes.iter().sum::<f64>() / n as f64;
let variance = if n > 1 {
transfer_sizes
.iter()
.map(|x| (x - mean).powi(2))
.sum::<f64>()
/ (n - 1) as f64
} else {
0.0
};
let std_dev = variance.sqrt();
let expected = mean * self.config.safety_multiplier;
let min = (mean - std_dev).max(0.1) * self.config.safety_multiplier;
let max = (mean + 2.0 * std_dev) * self.config.safety_multiplier;
let confidence = 1.0 - (1.0 / (n as f64 + 1.0));
HeadroomEstimate {
min_gb: min,
expected_gb: expected,
max_gb: max,
confidence,
sample_count: n,
is_fallback: false,
}
}
fn fallback_estimate(&self) -> HeadroomEstimate {
let default = self.config.default_build_size_gb;
HeadroomEstimate {
min_gb: default * 0.5,
expected_gb: default * self.config.safety_multiplier,
max_gb: default * self.config.safety_multiplier * 2.0,
confidence: 0.2, sample_count: 0,
is_fallback: true,
}
}
pub async fn check_headroom(
&self,
worker_id: &WorkerId,
project_id: &str,
disk_free_gb: f64,
) -> HeadroomDecision {
let estimate = self.estimate_build_headroom(project_id);
let reserved = self.total_reserved_for_worker(worker_id).await;
let effective_free = disk_free_gb - reserved;
let required = estimate.expected_gb + self.config.floor_free_gb;
let sufficient = effective_free >= required;
let (reason_code, decision_path) = if sufficient {
(
"headroom_sufficient".to_string(),
format!(
"effective_free({:.2}GB) >= required({:.2}GB) [estimate={:.2}GB + floor={:.2}GB]",
effective_free, required, estimate.expected_gb, self.config.floor_free_gb
),
)
} else {
(
"headroom_insufficient".to_string(),
format!(
"effective_free({:.2}GB) < required({:.2}GB) [estimate={:.2}GB + floor={:.2}GB, reserved={:.2}GB]",
effective_free,
required,
estimate.expected_gb,
self.config.floor_free_gb,
reserved
),
)
};
debug!(
worker = %worker_id,
project = project_id,
sufficient,
disk_free_gb,
reserved_gb = reserved,
effective_free_gb = effective_free,
estimate_expected_gb = estimate.expected_gb,
estimate_confidence = estimate.confidence,
sample_count = estimate.sample_count,
is_fallback = estimate.is_fallback,
"Headroom check"
);
HeadroomDecision {
worker_id: worker_id.clone(),
sufficient,
estimate,
disk_free_gb,
reserved_gb: reserved,
effective_free_gb: effective_free,
floor_gb: self.config.floor_free_gb,
reason_code,
decision_path,
}
}
pub async fn reserve(&self, build_id: u64, worker_id: &WorkerId, project_id: &str) -> f64 {
let estimate = self.estimate_build_headroom(project_id);
let reserved_gb = estimate.expected_gb;
let reservation = Reservation {
build_id,
worker_id: worker_id.clone(),
reserved_gb,
project_id: project_id.to_string(),
};
info!(
build_id,
worker = %worker_id,
project = project_id,
reserved_gb,
"Created headroom reservation"
);
self.reservations
.write()
.await
.insert(build_id, reservation);
reserved_gb
}
pub async fn release(&self, build_id: u64) -> Option<f64> {
let reservation = self.reservations.write().await.remove(&build_id)?;
debug!(
build_id,
worker = %reservation.worker_id,
released_gb = reservation.reserved_gb,
"Released headroom reservation"
);
Some(reservation.reserved_gb)
}
pub async fn total_reserved_for_worker(&self, worker_id: &WorkerId) -> f64 {
self.reservations
.read()
.await
.values()
.filter(|r| &r.worker_id == worker_id)
.map(|r| r.reserved_gb)
.sum()
}
pub async fn reservation_summary(&self) -> Vec<(u64, WorkerId, f64, String)> {
self.reservations
.read()
.await
.values()
.map(|r| {
(
r.build_id,
r.worker_id.clone(),
r.reserved_gb,
r.project_id.clone(),
)
})
.collect()
}
pub async fn reservation_count(&self) -> usize {
self.reservations.read().await.len()
}
pub async fn headroom_score(
&self,
worker_id: &WorkerId,
project_id: &str,
disk_free_gb: f64,
) -> f64 {
let estimate = self.estimate_build_headroom(project_id);
let reserved = self.total_reserved_for_worker(worker_id).await;
let effective_free = disk_free_gb - reserved;
let required = estimate.expected_gb + self.config.floor_free_gb;
if required <= 0.0 {
return 1.0;
}
let ratio = effective_free / required;
(ratio / 2.0).clamp(0.0, 1.0)
}
}
const FOOTPRINT_SAMPLES: usize = 8;
const FOOTPRINT_MAX_AGE_SECS: i64 = 30 * 24 * 60 * 60;
const FOOTPRINT_MIN_GROWTH_GIB: f64 = 1.0;
pub fn footprint_key(project_id: &str, command: &str) -> String {
let kind = rch_common::classify_command(command)
.kind
.map_or_else(|| "other".to_string(), |kind| format!("{kind:?}"));
format!("{project_id}\u{1f}{kind}")
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, serde::Deserialize)]
struct FootprintSample {
growth_gib: f64,
observed_unix: i64,
}
#[derive(Debug, Default, Serialize, serde::Deserialize)]
pub struct FootprintBook {
#[serde(default)]
entries: HashMap<String, VecDeque<FootprintSample>>,
}
impl FootprintBook {
pub fn load(path: &std::path::Path) -> Self {
match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|error| {
tracing::warn!(path = %path.display(), %error, "ignoring unreadable footprint book");
Self::default()
}),
Err(_) => Self::default(),
}
}
pub fn persist(&self, path: &std::path::Path) -> std::io::Result<()> {
let staged = path.with_extension(format!("tmp.{}", std::process::id()));
std::fs::write(&staged, serde_json::to_vec(self)?)?;
std::fs::rename(&staged, path)
}
pub fn record(&mut self, key: &str, growth_gib: f64, now_unix: i64) -> bool {
if !growth_gib.is_finite() || growth_gib < FOOTPRINT_MIN_GROWTH_GIB {
return false;
}
let samples = self.entries.entry(key.to_string()).or_default();
if samples.len() >= FOOTPRINT_SAMPLES {
samples.pop_front();
}
samples.push_back(FootprintSample {
growth_gib,
observed_unix: now_unix,
});
true
}
pub fn estimate_gib(&self, key: &str, now_unix: i64) -> Option<f64> {
self.entries
.get(key)?
.iter()
.filter(|sample| {
now_unix.saturating_sub(sample.observed_unix) <= FOOTPRINT_MAX_AGE_SECS
})
.map(|sample| sample.growth_gib)
.reduce(f64::max)
}
pub fn prune(&mut self, now_unix: i64) {
for samples in self.entries.values_mut() {
samples.retain(|sample| {
now_unix.saturating_sub(sample.observed_unix) <= FOOTPRINT_MAX_AGE_SECS
});
}
self.entries.retain(|_, samples| !samples.is_empty());
}
}
pub fn footprint_requirement_gib(footprint_gib: f64) -> f64 {
footprint_gib + (footprint_gib * 0.1).max(5.0)
}
#[cfg(test)]
mod tests {
use super::*;
use rch_common::{BuildRecord, test_guard};
fn make_remote_build(id: u64, project_id: &str, bytes_transferred: u64) -> BuildRecord {
BuildRecord {
id,
started_at: "2026-01-01T00:00:00Z".to_string(),
completed_at: "2026-01-01T00:01:00Z".to_string(),
project_id: project_id.to_string(),
worker_id: Some("worker-1".to_string()),
command: "cargo build".to_string(),
exit_code: 0,
duration_ms: 60000,
location: BuildLocation::Remote,
bytes_transferred: Some(bytes_transferred),
timing: None,
cancellation: None,
}
}
fn make_local_build(id: u64, project_id: &str) -> BuildRecord {
BuildRecord {
id,
started_at: "2026-01-01T00:00:00Z".to_string(),
completed_at: "2026-01-01T00:01:00Z".to_string(),
project_id: project_id.to_string(),
worker_id: None,
command: "cargo build".to_string(),
exit_code: 0,
duration_ms: 120000,
location: BuildLocation::Local,
bytes_transferred: None,
timing: None,
cancellation: None,
}
}
#[test]
fn config_defaults() {
let _guard = test_guard!();
let config = HeadroomConfig::default();
assert!((config.default_build_size_gb - 5.0).abs() < f64::EPSILON);
assert!((config.safety_multiplier - 1.5).abs() < f64::EPSILON);
assert_eq!(config.max_sample_size, 20);
assert!((config.floor_free_gb - 10.0).abs() < f64::EPSILON);
assert!(config.use_historical_data);
}
#[test]
fn estimate_fallback_when_no_history() {
let _guard = test_guard!();
let history = Arc::new(BuildHistory::new(10));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
let estimate = estimator.estimate_build_headroom("unknown-project");
assert!(estimate.is_fallback);
assert_eq!(estimate.sample_count, 0);
assert!(estimate.confidence < 0.3);
assert!((estimate.min_gb - 2.5).abs() < f64::EPSILON);
assert!((estimate.expected_gb - 7.5).abs() < f64::EPSILON);
}
#[test]
fn estimate_fallback_when_historical_disabled() {
let _guard = test_guard!();
let history = Arc::new(BuildHistory::new(10));
history.record(make_remote_build(1, "proj-a", 1024 * 1024 * 1024));
let config = HeadroomConfig {
use_historical_data: false,
..Default::default()
};
let estimator = HeadroomEstimator::new(history, config);
let estimate = estimator.estimate_build_headroom("proj-a");
assert!(estimate.is_fallback);
assert_eq!(estimate.sample_count, 0);
}
#[test]
fn estimate_from_single_build() {
let _guard = test_guard!();
let history = Arc::new(BuildHistory::new(10));
let two_gb = 2 * 1024 * 1024 * 1024_u64;
history.record(make_remote_build(1, "proj-a", two_gb));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
let estimate = estimator.estimate_build_headroom("proj-a");
assert!(!estimate.is_fallback);
assert_eq!(estimate.sample_count, 1);
assert!((estimate.expected_gb - 3.0).abs() < 0.01);
assert!((estimate.confidence - 0.5).abs() < f64::EPSILON);
}
#[test]
fn estimate_from_multiple_builds() {
let _guard = test_guard!();
let history = Arc::new(BuildHistory::new(10));
let one_gb = 1024 * 1024 * 1024_u64;
history.record(make_remote_build(1, "proj-b", one_gb));
history.record(make_remote_build(2, "proj-b", 2 * one_gb));
history.record(make_remote_build(3, "proj-b", 3 * one_gb));
history.record(make_remote_build(4, "proj-b", 2 * one_gb));
history.record(make_remote_build(5, "proj-b", 2 * one_gb));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
let estimate = estimator.estimate_build_headroom("proj-b");
assert!(!estimate.is_fallback);
assert_eq!(estimate.sample_count, 5);
assert!((estimate.expected_gb - 3.0).abs() < 0.01);
assert!(estimate.min_gb < estimate.expected_gb);
assert!(estimate.max_gb > estimate.expected_gb);
assert!(estimate.confidence > 0.8);
}
#[test]
fn estimate_ignores_local_builds() {
let _guard = test_guard!();
let history = Arc::new(BuildHistory::new(10));
history.record(make_local_build(1, "proj-c"));
history.record(make_local_build(2, "proj-c"));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
let estimate = estimator.estimate_build_headroom("proj-c");
assert!(estimate.is_fallback);
assert_eq!(estimate.sample_count, 0);
}
#[test]
fn estimate_ignores_failed_builds() {
let _guard = test_guard!();
let history = Arc::new(BuildHistory::new(10));
let one_gb = 1024 * 1024 * 1024_u64;
history.record(make_remote_build(1, "proj-d", one_gb));
let mut failed = make_remote_build(2, "proj-d", 10 * one_gb);
failed.exit_code = 1;
history.record(failed);
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
let estimate = estimator.estimate_build_headroom("proj-d");
assert_eq!(estimate.sample_count, 1);
assert!((estimate.expected_gb - 1.5).abs() < 0.01);
}
#[test]
fn estimate_isolates_projects() {
let _guard = test_guard!();
let history = Arc::new(BuildHistory::new(10));
let one_gb = 1024 * 1024 * 1024_u64;
history.record(make_remote_build(1, "small-proj", one_gb));
history.record(make_remote_build(2, "big-proj", 10 * one_gb));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
let small_est = estimator.estimate_build_headroom("small-proj");
let big_est = estimator.estimate_build_headroom("big-proj");
assert!((small_est.expected_gb - 1.5).abs() < 0.01);
assert!((big_est.expected_gb - 15.0).abs() < 0.01);
}
#[tokio::test]
async fn check_headroom_sufficient() {
let history = Arc::new(BuildHistory::new(10));
let one_gb = 1024 * 1024 * 1024_u64;
history.record(make_remote_build(1, "proj-e", 2 * one_gb));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
let decision = estimator
.check_headroom(&WorkerId::new("w1"), "proj-e", 50.0)
.await;
assert!(decision.sufficient);
assert_eq!(decision.reason_code, "headroom_sufficient");
assert!((decision.disk_free_gb - 50.0).abs() < f64::EPSILON);
}
#[tokio::test]
async fn check_headroom_insufficient() {
let history = Arc::new(BuildHistory::new(10));
let ten_gb = 10 * 1024 * 1024 * 1024_u64;
history.record(make_remote_build(1, "big-proj", ten_gb));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
let decision = estimator
.check_headroom(&WorkerId::new("w1"), "big-proj", 5.0)
.await;
assert!(!decision.sufficient);
assert_eq!(decision.reason_code, "headroom_insufficient");
}
#[tokio::test]
async fn reserve_and_release() {
let history = Arc::new(BuildHistory::new(10));
let one_gb = 1024 * 1024 * 1024_u64;
history.record(make_remote_build(1, "proj-f", 2 * one_gb));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
let reserved = estimator.reserve(100, &WorkerId::new("w1"), "proj-f").await;
assert!(reserved > 0.0);
assert_eq!(estimator.reservation_count().await, 1);
let total = estimator
.total_reserved_for_worker(&WorkerId::new("w1"))
.await;
assert!((total - reserved).abs() < f64::EPSILON);
let released = estimator.release(100).await;
assert_eq!(released, Some(reserved));
assert_eq!(estimator.reservation_count().await, 0);
}
#[tokio::test]
async fn reservations_reduce_effective_headroom() {
let history = Arc::new(BuildHistory::new(10));
let one_gb = 1024 * 1024 * 1024_u64;
history.record(make_remote_build(1, "proj-g", 2 * one_gb));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
let decision1 = estimator
.check_headroom(&WorkerId::new("w1"), "proj-g", 50.0)
.await;
assert!(decision1.sufficient);
estimator.reserve(200, &WorkerId::new("w1"), "proj-g").await;
estimator.reserve(201, &WorkerId::new("w1"), "proj-g").await;
estimator.reserve(202, &WorkerId::new("w1"), "proj-g").await;
estimator.reserve(203, &WorkerId::new("w1"), "proj-g").await;
estimator.reserve(204, &WorkerId::new("w1"), "proj-g").await;
estimator.reserve(205, &WorkerId::new("w1"), "proj-g").await;
estimator.reserve(206, &WorkerId::new("w1"), "proj-g").await;
estimator.reserve(207, &WorkerId::new("w1"), "proj-g").await;
estimator.reserve(208, &WorkerId::new("w1"), "proj-g").await;
estimator.reserve(209, &WorkerId::new("w1"), "proj-g").await;
let decision2 = estimator
.check_headroom(&WorkerId::new("w1"), "proj-g", 50.0)
.await;
assert!(decision2.sufficient);
assert!(decision2.reserved_gb > 25.0);
for i in 210..220 {
estimator.reserve(i, &WorkerId::new("w1"), "proj-g").await;
}
let decision3 = estimator
.check_headroom(&WorkerId::new("w1"), "proj-g", 50.0)
.await;
assert!(!decision3.sufficient);
}
#[tokio::test]
async fn reservations_are_per_worker() {
let history = Arc::new(BuildHistory::new(10));
let one_gb = 1024 * 1024 * 1024_u64;
history.record(make_remote_build(1, "proj-h", 2 * one_gb));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
estimator.reserve(300, &WorkerId::new("w1"), "proj-h").await;
let w2_reserved = estimator
.total_reserved_for_worker(&WorkerId::new("w2"))
.await;
assert!((w2_reserved - 0.0).abs() < f64::EPSILON);
let w1_reserved = estimator
.total_reserved_for_worker(&WorkerId::new("w1"))
.await;
assert!(w1_reserved > 0.0);
}
#[tokio::test]
async fn release_nonexistent_returns_none() {
let history = Arc::new(BuildHistory::new(10));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
let result = estimator.release(999).await;
assert!(result.is_none());
}
#[tokio::test]
async fn headroom_score_ample_space() {
let history = Arc::new(BuildHistory::new(10));
let one_gb = 1024 * 1024 * 1024_u64;
history.record(make_remote_build(1, "proj-i", one_gb));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
let score = estimator
.headroom_score(&WorkerId::new("w1"), "proj-i", 100.0)
.await;
assert!((score - 1.0).abs() < f64::EPSILON);
}
#[tokio::test]
async fn headroom_score_tight_space() {
let history = Arc::new(BuildHistory::new(10));
let five_gb = 5 * 1024 * 1024 * 1024_u64;
history.record(make_remote_build(1, "proj-j", five_gb));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
let score = estimator
.headroom_score(&WorkerId::new("w1"), "proj-j", 20.0)
.await;
assert!(score > 0.4);
assert!(score < 0.7);
}
#[tokio::test]
async fn headroom_score_zero_when_insufficient() {
let history = Arc::new(BuildHistory::new(10));
let ten_gb = 10 * 1024 * 1024 * 1024_u64;
history.record(make_remote_build(1, "proj-k", ten_gb));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
let score = estimator
.headroom_score(&WorkerId::new("w1"), "proj-k", 5.0)
.await;
assert!(score < 0.15);
}
#[tokio::test]
async fn reservation_summary_empty() {
let history = Arc::new(BuildHistory::new(10));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
let summary = estimator.reservation_summary().await;
assert!(summary.is_empty());
}
#[tokio::test]
async fn reservation_summary_with_entries() {
let history = Arc::new(BuildHistory::new(10));
let config = HeadroomConfig::default();
let estimator = HeadroomEstimator::new(history, config);
estimator.reserve(400, &WorkerId::new("w1"), "proj-a").await;
estimator.reserve(401, &WorkerId::new("w2"), "proj-b").await;
let summary = estimator.reservation_summary().await;
assert_eq!(summary.len(), 2);
}
#[test]
fn footprint_key_separates_command_classes_and_projects() {
let test = footprint_key("fgdb", "cargo test --all-features");
assert_eq!(test, footprint_key("fgdb", "cargo test -p core"));
assert_ne!(test, footprint_key("fgdb", "cargo check --workspace"));
assert_ne!(test, footprint_key("other", "cargo test --all-features"));
assert_eq!(
footprint_key("fgdb", "echo hi"),
footprint_key("fgdb", "ls -la")
);
}
#[test]
fn footprint_estimate_is_the_largest_unexpired_sample() {
let mut book = FootprintBook::default();
let now = 1_800_000_000;
assert_eq!(book.estimate_gib("k", now), None);
assert!(!book.record("k", 0.5, now));
assert!(!book.record("k", f64::NAN, now));
assert!(!book.record("k", f64::INFINITY, now));
assert_eq!(book.estimate_gib("k", now), None);
assert!(book.record("k", 64.0, now - FOOTPRINT_MAX_AGE_SECS - 1));
assert!(book.record("k", 3.0, now));
assert!(book.record("k", 12.0, now));
assert_eq!(book.estimate_gib("k", now), Some(12.0));
assert_eq!(
book.estimate_gib("k", now - FOOTPRINT_MAX_AGE_SECS),
Some(64.0)
);
book.prune(now);
assert_eq!(book.entries["k"].len(), 2);
book.prune(now + 2 * FOOTPRINT_MAX_AGE_SECS);
assert!(book.entries.is_empty());
}
#[test]
fn footprint_book_keeps_a_bounded_window() {
let mut book = FootprintBook::default();
let now = 1_800_000_000;
book.record("k", 100.0, now);
for growth in 0..FOOTPRINT_SAMPLES {
book.record("k", 2.0 + growth as f64, now);
}
assert_eq!(book.entries["k"].len(), FOOTPRINT_SAMPLES);
assert_eq!(
book.estimate_gib("k", now),
Some(1.0 + FOOTPRINT_SAMPLES as f64)
);
}
#[test]
fn footprint_book_round_trips_and_tolerates_corruption() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("history.footprints.json");
assert!(FootprintBook::load(&path).entries.is_empty());
let mut book = FootprintBook::default();
book.record("k", 40.0, 1_800_000_000);
book.persist(&path).unwrap();
let loaded = FootprintBook::load(&path);
assert_eq!(loaded.estimate_gib("k", 1_800_000_000), Some(40.0));
std::fs::write(&path, b"{not json").unwrap();
assert!(FootprintBook::load(&path).entries.is_empty());
}
#[test]
fn footprint_requirement_adds_a_margin() {
assert!((footprint_requirement_gib(64.0) - 70.4).abs() < 1e-9);
assert!((footprint_requirement_gib(10.0) - 15.0).abs() < 1e-9);
}
}