1use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use crate::layer::Line;
17use crate::manifest::LayerManifest;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum RollbackVerdict {
22 Accept,
24 Rollback {
26 line: String,
27 presented: u64,
28 high_water: u64,
29 },
30}
31
32#[derive(Debug)]
35pub struct HighWaterMarks {
36 path: PathBuf,
37 marks: BTreeMap<String, u64>,
38}
39
40#[derive(Debug, thiserror::Error)]
41pub enum RollbackError {
42 #[error("io error at {path}")]
46 Io {
47 path: String,
48 #[source]
49 source: std::io::Error,
50 },
51 #[error(
52 "{path}: high-water-mark state is corrupt: {reason} — refusing to guess; repair or remove the file"
53 )]
54 Corrupt { path: String, reason: String },
55}
56
57impl HighWaterMarks {
58 pub fn load(root: &Path) -> Result<Self, RollbackError> {
60 let path = root.join("state").join("high-water-marks.json");
61 let marks = match std::fs::read(&path) {
62 Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| RollbackError::Corrupt {
63 path: path.display().to_string(),
64 reason: e.to_string(),
65 })?,
66 Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
67 Err(source) => {
68 return Err(RollbackError::Io {
69 path: path.display().to_string(),
70 source,
71 });
72 }
73 };
74 Ok(HighWaterMarks { path, marks })
75 }
76
77 pub fn mark(&self, line: &Line) -> Option<u64> {
79 self.marks.get(&line.to_string()).copied()
80 }
81
82 pub fn check(&self, manifest: &LayerManifest) -> RollbackVerdict {
86 let line = manifest.layer.line().to_string();
87 match self.marks.get(&line) {
88 Some(&high_water) if manifest.counter < high_water => RollbackVerdict::Rollback {
89 line,
90 presented: manifest.counter,
91 high_water,
92 },
93 _ => RollbackVerdict::Accept,
94 }
95 }
96
97 pub fn advance(&mut self, manifest: &LayerManifest) -> Result<(), RollbackError> {
100 let line = manifest.layer.line().to_string();
101 let mark = self.marks.entry(line).or_insert(0);
102 *mark = (*mark).max(manifest.counter);
103 self.persist()
104 }
105
106 fn persist(&self) -> Result<(), RollbackError> {
107 let io = |path: &Path, source: std::io::Error| RollbackError::Io {
108 path: path.display().to_string(),
109 source,
110 };
111 let dir = self.path.parent().expect("state file has a parent");
112 std::fs::create_dir_all(dir).map_err(|e| io(dir, e))?;
113 let bytes = serde_json::to_vec_pretty(&self.marks).expect("marks serialize");
114 std::fs::write(&self.path, bytes).map_err(|e| io(&self.path, e))?;
115 Ok(())
116 }
117}
118
119pub fn staleness_warning(issued_at: &str, now: &str, threshold_days: u32) -> Option<i64> {
125 let age = epoch_days(now)? - epoch_days(issued_at)?;
126 (age > i64::from(threshold_days)).then_some(age)
127}
128
129pub fn epoch_days(rfc3339: &str) -> Option<i64> {
136 let date = rfc3339.split_once('T').map_or(rfc3339, |(d, _)| d);
140 let b = date.as_bytes();
141 if date.len() != 10 || b[4] != b'-' || b[7] != b'-' {
142 return None;
143 }
144 let y: i64 = date[0..4].parse().ok()?;
145 let m: i64 = date[5..7].parse().ok()?;
146 let d: i64 = date[8..10].parse().ok()?;
147 if !(1..=12).contains(&m) {
148 return None;
149 }
150 let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
153 let dim = [
154 31,
155 if leap { 29 } else { 28 },
156 31,
157 30,
158 31,
159 30,
160 31,
161 31,
162 30,
163 31,
164 30,
165 31,
166 ];
167 if d < 1 || d > dim[(m - 1) as usize] {
168 return None;
169 }
170 let y = y - i64::from(m <= 2);
172 let era = if y >= 0 { y } else { y - 399 } / 400;
173 let yoe = y - era * 400;
174 let mp = (m + 9) % 12;
175 let doy = (153 * mp + 2) / 5 + d - 1;
176 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
177 Some(era * 146_097 + doe - 719_468)
178}
179
180#[cfg(test)]
181mod tests {
182
183 #[test]
185 fn the_leap_rule_holds_at_the_year_the_solver_found() {
186 assert!(
194 epoch_days("8192-02-29").is_some(),
195 "8192 is a leap year: 8192-02-29 must be a real date"
196 );
197 assert!(
199 epoch_days("8100-02-29").is_none(),
200 "8100 %% 100 == 0, %% 400 != 0"
201 );
202 assert!(epoch_days("8000-02-29").is_some(), "8000 %% 400 == 0");
203 assert!(
204 epoch_days("8193-02-29").is_none(),
205 "8193 is not divisible by 4"
206 );
207 }
208 use super::*;
209 use crate::manifest::{LayerManifest, fixtures};
210
211 fn manifest(layer: &str, counter: u64) -> LayerManifest {
212 LayerManifest::parse(&fixtures::manifest(
213 layer,
214 "qualified",
215 counter,
216 "2026-07-31T09:14:00Z",
217 ))
218 .unwrap()
219 }
220
221 #[test]
223 fn first_contact_accepts_and_advance_records_the_mark() {
224 let tmp = tempfile::tempdir().unwrap();
225 let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
226 let m = manifest("2026.07.0", 3);
227 assert_eq!(hwm.check(&m), RollbackVerdict::Accept);
228 assert_eq!(hwm.mark(m.layer.line()), None, "check must not advance");
229 hwm.advance(&m).unwrap();
230 assert_eq!(hwm.mark(m.layer.line()), Some(3));
231 }
232
233 #[test]
235 fn a_counter_below_the_mark_is_rejected() {
236 let tmp = tempfile::tempdir().unwrap();
237 let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
238 hwm.advance(&manifest("2026.07.1", 4)).unwrap();
239 let verdict = hwm.check(&manifest("2026.07.0", 3));
240 assert_eq!(
241 verdict,
242 RollbackVerdict::Rollback {
243 line: "2026.07".into(),
244 presented: 3,
245 high_water: 4
246 }
247 );
248 }
249
250 #[test]
252 fn an_equal_counter_reinstalls_cleanly() {
253 let tmp = tempfile::tempdir().unwrap();
254 let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
255 hwm.advance(&manifest("2026.07.0", 3)).unwrap();
256 assert_eq!(
257 hwm.check(&manifest("2026.07.0", 3)),
258 RollbackVerdict::Accept
259 );
260 }
261
262 #[test]
264 fn counters_are_scoped_per_line() {
265 let tmp = tempfile::tempdir().unwrap();
266 let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
267 hwm.advance(&manifest("2026.08.0", 9)).unwrap();
268 assert_eq!(
271 hwm.check(&manifest("2026.07.0", 1)),
272 RollbackVerdict::Accept
273 );
274 }
275
276 #[test]
278 fn marks_survive_a_new_session() {
279 let tmp = tempfile::tempdir().unwrap();
280 {
281 let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
282 hwm.advance(&manifest("2026.07.1", 5)).unwrap();
283 }
284 let hwm = HighWaterMarks::load(tmp.path()).unwrap();
285 assert_eq!(
286 hwm.check(&manifest("2026.07.0", 2)),
287 RollbackVerdict::Rollback {
288 line: "2026.07".into(),
289 presented: 2,
290 high_water: 5
291 }
292 );
293 }
294
295 #[test]
297 fn advance_never_lowers_a_mark() {
298 let tmp = tempfile::tempdir().unwrap();
299 let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
300 hwm.advance(&manifest("2026.07.1", 5)).unwrap();
301 hwm.advance(&manifest("2026.07.0", 2)).unwrap();
302 assert_eq!(hwm.mark(manifest("2026.07.0", 2).layer.line()), Some(5));
303 }
304
305 #[test]
307 fn corrupt_state_is_an_error_not_a_reset() {
308 let tmp = tempfile::tempdir().unwrap();
309 let state_dir = tmp.path().join("state");
310 std::fs::create_dir_all(&state_dir).unwrap();
311 std::fs::write(state_dir.join("high-water-marks.json"), b"{ nope").unwrap();
312 assert!(matches!(
314 HighWaterMarks::load(tmp.path()),
315 Err(RollbackError::Corrupt { .. })
316 ));
317 }
318
319 #[test]
321 fn epoch_day_arithmetic_matches_the_civil_calendar() {
322 for (ts, days) in [
327 ("1970-01-01T00:00:00Z", 0i64),
328 ("1970-01-02T00:00:00Z", 1),
329 ("1969-12-31T00:00:00Z", -1),
330 ("2000-02-29T00:00:00Z", 11016),
331 ("2026-08-07T00:00:00Z", 20672),
332 ("2026-03-01T00:00:00Z", 20513),
333 ("2024-02-29T00:00:00Z", 19782),
334 ("2100-01-01T00:00:00Z", 47482),
335 ("1900-03-01T00:00:00Z", -25508),
336 ("2026-12-31T00:00:00Z", 20818),
337 ("0000-03-01T00:00:00Z", -719468),
338 ("0000-01-01T00:00:00Z", -719528),
339 ("0000-02-29T00:00:00Z", -719469),
340 ] {
341 assert_eq!(epoch_days(ts), Some(days), "epoch_days({ts})");
342 }
343 for bad in [
345 "2026-13-01T00:00:00Z",
346 "2026-00-01T00:00:00Z",
347 "2026-01-32T00:00:00Z",
348 "2026-01-00T00:00:00Z",
349 ] {
350 assert_eq!(epoch_days(bad), None, "{bad}");
351 }
352 }
353
354 #[test]
356 fn epoch_days_enforces_the_exact_yyyy_mm_dd_t_shape() {
357 assert_eq!(epoch_days("2026-08-07"), Some(20672));
359 assert_eq!(epoch_days("2026-08-07 00:00:00Z"), None, "space, not T");
360 assert_eq!(epoch_days("2026-08-07X"), None, "non-T separator");
361 for bad in [
363 "2026-08-7T00:00:00Z", "2026X08-07T00:00:00Z", "2026-08X07T00:00:00Z", "202608-07T00:00:00Z", ] {
368 assert_eq!(epoch_days(bad), None, "{bad}");
369 }
370 }
371
372 #[test]
374 fn epoch_days_applies_the_full_gregorian_leap_rule() {
375 assert!(epoch_days("2024-02-29T00:00:00Z").is_some(), "2024 %4 leap");
378 assert_eq!(epoch_days("2023-02-29T00:00:00Z"), None, "2023 non-leap");
379 assert_eq!(
380 epoch_days("1900-02-29T00:00:00Z"),
381 None,
382 "1900 %100 non-leap"
383 );
384 assert!(
385 epoch_days("2000-02-29T00:00:00Z").is_some(),
386 "2000 %400 leap"
387 );
388 assert!(epoch_days("2023-02-28T00:00:00Z").is_some());
390 assert_eq!(epoch_days("2024-02-30T00:00:00Z"), None);
391 assert!(epoch_days("2026-04-30T00:00:00Z").is_some());
393 assert_eq!(epoch_days("2026-04-31T00:00:00Z"), None, "April has 30");
394 }
395
396 #[test]
398 fn staleness_threshold_boundary_is_strictly_greater_than() {
399 assert_eq!(
401 staleness_warning("2026-07-01T00:00:00Z", "2026-07-31T00:00:00Z", 30),
402 None
403 );
404 assert_eq!(
405 staleness_warning("2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z", 30),
406 Some(31)
407 );
408 }
409
410 #[test]
412 fn an_unreadable_state_file_is_an_io_error_not_first_contact() {
413 let tmp = tempfile::tempdir().unwrap();
417 std::fs::create_dir_all(tmp.path().join("state/high-water-marks.json")).unwrap();
418 assert!(matches!(
419 HighWaterMarks::load(tmp.path()),
420 Err(RollbackError::Io { .. })
421 ));
422 }
423
424 #[test]
426 fn staleness_is_a_pure_function_of_issued_at_now_and_threshold() {
427 assert_eq!(
429 staleness_warning("2026-04-01T00:00:00Z", "2026-07-10T00:00:00Z", 90),
430 Some(100)
431 );
432 assert_eq!(
434 staleness_warning("2026-06-30T00:00:00Z", "2026-07-10T00:00:00Z", 90),
435 None
436 );
437 assert_eq!(
441 staleness_warning("2026-08-01T00:00:00Z", "2026-07-10T00:00:00Z", 90),
442 None
443 );
444 }
445}