use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::layer::Line;
use crate::manifest::LayerManifest;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RollbackVerdict {
Accept,
Rollback {
line: String,
presented: u64,
high_water: u64,
},
}
#[derive(Debug)]
pub struct HighWaterMarks {
path: PathBuf,
marks: BTreeMap<String, u64>,
}
#[derive(Debug, thiserror::Error)]
pub enum RollbackError {
#[error("io error at {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error(
"{path}: high-water-mark state is corrupt: {reason} — refusing to guess; repair or remove the file"
)]
Corrupt { path: String, reason: String },
}
impl HighWaterMarks {
pub fn load(root: &Path) -> Result<Self, RollbackError> {
let path = root.join("state").join("high-water-marks.json");
let marks = match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| RollbackError::Corrupt {
path: path.display().to_string(),
reason: e.to_string(),
})?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
Err(source) => {
return Err(RollbackError::Io {
path: path.display().to_string(),
source,
});
}
};
Ok(HighWaterMarks { path, marks })
}
pub fn mark(&self, line: &Line) -> Option<u64> {
self.marks.get(&line.to_string()).copied()
}
pub fn check(&self, manifest: &LayerManifest) -> RollbackVerdict {
let line = manifest.layer.line().to_string();
match self.marks.get(&line) {
Some(&high_water) if manifest.counter < high_water => RollbackVerdict::Rollback {
line,
presented: manifest.counter,
high_water,
},
_ => RollbackVerdict::Accept,
}
}
pub fn advance(&mut self, manifest: &LayerManifest) -> Result<(), RollbackError> {
let line = manifest.layer.line().to_string();
let mark = self.marks.entry(line).or_insert(0);
*mark = (*mark).max(manifest.counter);
self.persist()
}
fn persist(&self) -> Result<(), RollbackError> {
let io = |path: &Path, source: std::io::Error| RollbackError::Io {
path: path.display().to_string(),
source,
};
let dir = self.path.parent().expect("state file has a parent");
std::fs::create_dir_all(dir).map_err(|e| io(dir, e))?;
let bytes = serde_json::to_vec_pretty(&self.marks).expect("marks serialize");
std::fs::write(&self.path, bytes).map_err(|e| io(&self.path, e))?;
Ok(())
}
}
pub fn staleness_warning(issued_at: &str, now: &str, threshold_days: u32) -> Option<i64> {
let age = epoch_days(now)? - epoch_days(issued_at)?;
(age > i64::from(threshold_days)).then_some(age)
}
pub fn epoch_days(rfc3339: &str) -> Option<i64> {
let date = rfc3339.split_once('T').map_or(rfc3339, |(d, _)| d);
let b = date.as_bytes();
if date.len() != 10 || b[4] != b'-' || b[7] != b'-' {
return None;
}
let y: i64 = date[0..4].parse().ok()?;
let m: i64 = date[5..7].parse().ok()?;
let d: i64 = date[8..10].parse().ok()?;
if !(1..=12).contains(&m) {
return None;
}
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
let dim = [
31,
if leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
if d < 1 || d > dim[(m - 1) as usize] {
return None;
}
let y = y - i64::from(m <= 2);
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
Some(era * 146_097 + doe - 719_468)
}
#[cfg(test)]
mod tests {
#[test]
fn the_leap_rule_holds_at_the_year_the_solver_found() {
assert!(
epoch_days("8192-02-29").is_some(),
"8192 is a leap year: 8192-02-29 must be a real date"
);
assert!(
epoch_days("8100-02-29").is_none(),
"8100 %% 100 == 0, %% 400 != 0"
);
assert!(epoch_days("8000-02-29").is_some(), "8000 %% 400 == 0");
assert!(
epoch_days("8193-02-29").is_none(),
"8193 is not divisible by 4"
);
}
use super::*;
use crate::manifest::{LayerManifest, fixtures};
fn manifest(layer: &str, counter: u64) -> LayerManifest {
LayerManifest::parse(&fixtures::manifest(
layer,
"qualified",
counter,
"2026-07-31T09:14:00Z",
))
.unwrap()
}
#[test]
fn first_contact_accepts_and_advance_records_the_mark() {
let tmp = tempfile::tempdir().unwrap();
let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
let m = manifest("2026.07.0", 3);
assert_eq!(hwm.check(&m), RollbackVerdict::Accept);
assert_eq!(hwm.mark(m.layer.line()), None, "check must not advance");
hwm.advance(&m).unwrap();
assert_eq!(hwm.mark(m.layer.line()), Some(3));
}
#[test]
fn a_counter_below_the_mark_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
hwm.advance(&manifest("2026.07.1", 4)).unwrap();
let verdict = hwm.check(&manifest("2026.07.0", 3));
assert_eq!(
verdict,
RollbackVerdict::Rollback {
line: "2026.07".into(),
presented: 3,
high_water: 4
}
);
}
#[test]
fn an_equal_counter_reinstalls_cleanly() {
let tmp = tempfile::tempdir().unwrap();
let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
hwm.advance(&manifest("2026.07.0", 3)).unwrap();
assert_eq!(
hwm.check(&manifest("2026.07.0", 3)),
RollbackVerdict::Accept
);
}
#[test]
fn counters_are_scoped_per_line() {
let tmp = tempfile::tempdir().unwrap();
let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
hwm.advance(&manifest("2026.08.0", 9)).unwrap();
assert_eq!(
hwm.check(&manifest("2026.07.0", 1)),
RollbackVerdict::Accept
);
}
#[test]
fn marks_survive_a_new_session() {
let tmp = tempfile::tempdir().unwrap();
{
let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
hwm.advance(&manifest("2026.07.1", 5)).unwrap();
}
let hwm = HighWaterMarks::load(tmp.path()).unwrap();
assert_eq!(
hwm.check(&manifest("2026.07.0", 2)),
RollbackVerdict::Rollback {
line: "2026.07".into(),
presented: 2,
high_water: 5
}
);
}
#[test]
fn advance_never_lowers_a_mark() {
let tmp = tempfile::tempdir().unwrap();
let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
hwm.advance(&manifest("2026.07.1", 5)).unwrap();
hwm.advance(&manifest("2026.07.0", 2)).unwrap();
assert_eq!(hwm.mark(manifest("2026.07.0", 2).layer.line()), Some(5));
}
#[test]
fn corrupt_state_is_an_error_not_a_reset() {
let tmp = tempfile::tempdir().unwrap();
let state_dir = tmp.path().join("state");
std::fs::create_dir_all(&state_dir).unwrap();
std::fs::write(state_dir.join("high-water-marks.json"), b"{ nope").unwrap();
assert!(matches!(
HighWaterMarks::load(tmp.path()),
Err(RollbackError::Corrupt { .. })
));
}
#[test]
fn epoch_day_arithmetic_matches_the_civil_calendar() {
for (ts, days) in [
("1970-01-01T00:00:00Z", 0i64),
("1970-01-02T00:00:00Z", 1),
("1969-12-31T00:00:00Z", -1),
("2000-02-29T00:00:00Z", 11016),
("2026-08-07T00:00:00Z", 20672),
("2026-03-01T00:00:00Z", 20513),
("2024-02-29T00:00:00Z", 19782),
("2100-01-01T00:00:00Z", 47482),
("1900-03-01T00:00:00Z", -25508),
("2026-12-31T00:00:00Z", 20818),
("0000-03-01T00:00:00Z", -719468),
("0000-01-01T00:00:00Z", -719528),
("0000-02-29T00:00:00Z", -719469),
] {
assert_eq!(epoch_days(ts), Some(days), "epoch_days({ts})");
}
for bad in [
"2026-13-01T00:00:00Z",
"2026-00-01T00:00:00Z",
"2026-01-32T00:00:00Z",
"2026-01-00T00:00:00Z",
] {
assert_eq!(epoch_days(bad), None, "{bad}");
}
}
#[test]
fn epoch_days_enforces_the_exact_yyyy_mm_dd_t_shape() {
assert_eq!(epoch_days("2026-08-07"), Some(20672));
assert_eq!(epoch_days("2026-08-07 00:00:00Z"), None, "space, not T");
assert_eq!(epoch_days("2026-08-07X"), None, "non-T separator");
for bad in [
"2026-08-7T00:00:00Z", "2026X08-07T00:00:00Z", "2026-08X07T00:00:00Z", "202608-07T00:00:00Z", ] {
assert_eq!(epoch_days(bad), None, "{bad}");
}
}
#[test]
fn epoch_days_applies_the_full_gregorian_leap_rule() {
assert!(epoch_days("2024-02-29T00:00:00Z").is_some(), "2024 %4 leap");
assert_eq!(epoch_days("2023-02-29T00:00:00Z"), None, "2023 non-leap");
assert_eq!(
epoch_days("1900-02-29T00:00:00Z"),
None,
"1900 %100 non-leap"
);
assert!(
epoch_days("2000-02-29T00:00:00Z").is_some(),
"2000 %400 leap"
);
assert!(epoch_days("2023-02-28T00:00:00Z").is_some());
assert_eq!(epoch_days("2024-02-30T00:00:00Z"), None);
assert!(epoch_days("2026-04-30T00:00:00Z").is_some());
assert_eq!(epoch_days("2026-04-31T00:00:00Z"), None, "April has 30");
}
#[test]
fn staleness_threshold_boundary_is_strictly_greater_than() {
assert_eq!(
staleness_warning("2026-07-01T00:00:00Z", "2026-07-31T00:00:00Z", 30),
None
);
assert_eq!(
staleness_warning("2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z", 30),
Some(31)
);
}
#[test]
fn an_unreadable_state_file_is_an_io_error_not_first_contact() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("state/high-water-marks.json")).unwrap();
assert!(matches!(
HighWaterMarks::load(tmp.path()),
Err(RollbackError::Io { .. })
));
}
#[test]
fn staleness_is_a_pure_function_of_issued_at_now_and_threshold() {
assert_eq!(
staleness_warning("2026-04-01T00:00:00Z", "2026-07-10T00:00:00Z", 90),
Some(100)
);
assert_eq!(
staleness_warning("2026-06-30T00:00:00Z", "2026-07-10T00:00:00Z", 90),
None
);
assert_eq!(
staleness_warning("2026-08-01T00:00:00Z", "2026-07-10T00:00:00Z", 90),
None
);
}
}