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}: {source}")]
43 Io {
44 path: String,
45 #[source]
46 source: std::io::Error,
47 },
48 #[error(
49 "{path}: high-water-mark state is corrupt: {reason} — refusing to guess; repair or remove the file"
50 )]
51 Corrupt { path: String, reason: String },
52}
53
54impl HighWaterMarks {
55 pub fn load(root: &Path) -> Result<Self, RollbackError> {
57 let path = root.join("state").join("high-water-marks.json");
58 let marks = match std::fs::read(&path) {
59 Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| RollbackError::Corrupt {
60 path: path.display().to_string(),
61 reason: e.to_string(),
62 })?,
63 Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
64 Err(source) => {
65 return Err(RollbackError::Io {
66 path: path.display().to_string(),
67 source,
68 });
69 }
70 };
71 Ok(HighWaterMarks { path, marks })
72 }
73
74 pub fn mark(&self, line: &Line) -> Option<u64> {
76 self.marks.get(&line.to_string()).copied()
77 }
78
79 pub fn check(&self, manifest: &LayerManifest) -> RollbackVerdict {
83 let line = manifest.layer.line().to_string();
84 match self.marks.get(&line) {
85 Some(&high_water) if manifest.counter < high_water => RollbackVerdict::Rollback {
86 line,
87 presented: manifest.counter,
88 high_water,
89 },
90 _ => RollbackVerdict::Accept,
91 }
92 }
93
94 pub fn advance(&mut self, manifest: &LayerManifest) -> Result<(), RollbackError> {
97 let line = manifest.layer.line().to_string();
98 let mark = self.marks.entry(line).or_insert(0);
99 *mark = (*mark).max(manifest.counter);
100 self.persist()
101 }
102
103 fn persist(&self) -> Result<(), RollbackError> {
104 let io = |path: &Path, source: std::io::Error| RollbackError::Io {
105 path: path.display().to_string(),
106 source,
107 };
108 let dir = self.path.parent().expect("state file has a parent");
109 std::fs::create_dir_all(dir).map_err(|e| io(dir, e))?;
110 let bytes = serde_json::to_vec_pretty(&self.marks).expect("marks serialize");
111 std::fs::write(&self.path, bytes).map_err(|e| io(&self.path, e))?;
112 Ok(())
113 }
114}
115
116pub fn staleness_warning(issued_at: &str, now: &str, threshold_days: u32) -> Option<i64> {
122 let age = epoch_days(now)? - epoch_days(issued_at)?;
123 (age > i64::from(threshold_days)).then_some(age)
124}
125
126pub fn epoch_days(rfc3339: &str) -> Option<i64> {
133 let date = rfc3339.split_once('T').map_or(rfc3339, |(d, _)| d);
137 let b = date.as_bytes();
138 if date.len() != 10 || b[4] != b'-' || b[7] != b'-' {
139 return None;
140 }
141 let y: i64 = date[0..4].parse().ok()?;
142 let m: i64 = date[5..7].parse().ok()?;
143 let d: i64 = date[8..10].parse().ok()?;
144 if !(1..=12).contains(&m) {
145 return None;
146 }
147 let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
150 let dim = [
151 31,
152 if leap { 29 } else { 28 },
153 31,
154 30,
155 31,
156 30,
157 31,
158 31,
159 30,
160 31,
161 30,
162 31,
163 ];
164 if d < 1 || d > dim[(m - 1) as usize] {
165 return None;
166 }
167 let y = y - i64::from(m <= 2);
169 let era = if y >= 0 { y } else { y - 399 } / 400;
170 let yoe = y - era * 400;
171 let mp = (m + 9) % 12;
172 let doy = (153 * mp + 2) / 5 + d - 1;
173 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
174 Some(era * 146_097 + doe - 719_468)
175}
176
177#[cfg(test)]
178mod tests {
179
180 #[test]
182 fn the_leap_rule_holds_at_the_year_the_solver_found() {
183 assert!(
191 epoch_days("8192-02-29").is_some(),
192 "8192 is a leap year: 8192-02-29 must be a real date"
193 );
194 assert!(
196 epoch_days("8100-02-29").is_none(),
197 "8100 %% 100 == 0, %% 400 != 0"
198 );
199 assert!(epoch_days("8000-02-29").is_some(), "8000 %% 400 == 0");
200 assert!(
201 epoch_days("8193-02-29").is_none(),
202 "8193 is not divisible by 4"
203 );
204 }
205 use super::*;
206 use crate::manifest::{LayerManifest, fixtures};
207
208 fn manifest(layer: &str, counter: u64) -> LayerManifest {
209 LayerManifest::parse(&fixtures::manifest(
210 layer,
211 "qualified",
212 counter,
213 "2026-07-31T09:14:00Z",
214 ))
215 .unwrap()
216 }
217
218 #[test]
220 fn first_contact_accepts_and_advance_records_the_mark() {
221 let tmp = tempfile::tempdir().unwrap();
222 let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
223 let m = manifest("2026.07.0", 3);
224 assert_eq!(hwm.check(&m), RollbackVerdict::Accept);
225 assert_eq!(hwm.mark(m.layer.line()), None, "check must not advance");
226 hwm.advance(&m).unwrap();
227 assert_eq!(hwm.mark(m.layer.line()), Some(3));
228 }
229
230 #[test]
232 fn a_counter_below_the_mark_is_rejected() {
233 let tmp = tempfile::tempdir().unwrap();
234 let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
235 hwm.advance(&manifest("2026.07.1", 4)).unwrap();
236 let verdict = hwm.check(&manifest("2026.07.0", 3));
237 assert_eq!(
238 verdict,
239 RollbackVerdict::Rollback {
240 line: "2026.07".into(),
241 presented: 3,
242 high_water: 4
243 }
244 );
245 }
246
247 #[test]
249 fn an_equal_counter_reinstalls_cleanly() {
250 let tmp = tempfile::tempdir().unwrap();
251 let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
252 hwm.advance(&manifest("2026.07.0", 3)).unwrap();
253 assert_eq!(
254 hwm.check(&manifest("2026.07.0", 3)),
255 RollbackVerdict::Accept
256 );
257 }
258
259 #[test]
261 fn counters_are_scoped_per_line() {
262 let tmp = tempfile::tempdir().unwrap();
263 let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
264 hwm.advance(&manifest("2026.08.0", 9)).unwrap();
265 assert_eq!(
268 hwm.check(&manifest("2026.07.0", 1)),
269 RollbackVerdict::Accept
270 );
271 }
272
273 #[test]
275 fn marks_survive_a_new_session() {
276 let tmp = tempfile::tempdir().unwrap();
277 {
278 let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
279 hwm.advance(&manifest("2026.07.1", 5)).unwrap();
280 }
281 let hwm = HighWaterMarks::load(tmp.path()).unwrap();
282 assert_eq!(
283 hwm.check(&manifest("2026.07.0", 2)),
284 RollbackVerdict::Rollback {
285 line: "2026.07".into(),
286 presented: 2,
287 high_water: 5
288 }
289 );
290 }
291
292 #[test]
294 fn advance_never_lowers_a_mark() {
295 let tmp = tempfile::tempdir().unwrap();
296 let mut hwm = HighWaterMarks::load(tmp.path()).unwrap();
297 hwm.advance(&manifest("2026.07.1", 5)).unwrap();
298 hwm.advance(&manifest("2026.07.0", 2)).unwrap();
299 assert_eq!(hwm.mark(manifest("2026.07.0", 2).layer.line()), Some(5));
300 }
301
302 #[test]
304 fn corrupt_state_is_an_error_not_a_reset() {
305 let tmp = tempfile::tempdir().unwrap();
306 let state_dir = tmp.path().join("state");
307 std::fs::create_dir_all(&state_dir).unwrap();
308 std::fs::write(state_dir.join("high-water-marks.json"), b"{ nope").unwrap();
309 assert!(matches!(
311 HighWaterMarks::load(tmp.path()),
312 Err(RollbackError::Corrupt { .. })
313 ));
314 }
315
316 #[test]
318 fn epoch_day_arithmetic_matches_the_civil_calendar() {
319 for (ts, days) in [
324 ("1970-01-01T00:00:00Z", 0i64),
325 ("1970-01-02T00:00:00Z", 1),
326 ("1969-12-31T00:00:00Z", -1),
327 ("2000-02-29T00:00:00Z", 11016),
328 ("2026-08-07T00:00:00Z", 20672),
329 ("2026-03-01T00:00:00Z", 20513),
330 ("2024-02-29T00:00:00Z", 19782),
331 ("2100-01-01T00:00:00Z", 47482),
332 ("1900-03-01T00:00:00Z", -25508),
333 ("2026-12-31T00:00:00Z", 20818),
334 ("0000-03-01T00:00:00Z", -719468),
335 ("0000-01-01T00:00:00Z", -719528),
336 ("0000-02-29T00:00:00Z", -719469),
337 ] {
338 assert_eq!(epoch_days(ts), Some(days), "epoch_days({ts})");
339 }
340 for bad in [
342 "2026-13-01T00:00:00Z",
343 "2026-00-01T00:00:00Z",
344 "2026-01-32T00:00:00Z",
345 "2026-01-00T00:00:00Z",
346 ] {
347 assert_eq!(epoch_days(bad), None, "{bad}");
348 }
349 }
350
351 #[test]
353 fn epoch_days_enforces_the_exact_yyyy_mm_dd_t_shape() {
354 assert_eq!(epoch_days("2026-08-07"), Some(20672));
356 assert_eq!(epoch_days("2026-08-07 00:00:00Z"), None, "space, not T");
357 assert_eq!(epoch_days("2026-08-07X"), None, "non-T separator");
358 for bad in [
360 "2026-08-7T00:00:00Z", "2026X08-07T00:00:00Z", "2026-08X07T00:00:00Z", "202608-07T00:00:00Z", ] {
365 assert_eq!(epoch_days(bad), None, "{bad}");
366 }
367 }
368
369 #[test]
371 fn epoch_days_applies_the_full_gregorian_leap_rule() {
372 assert!(epoch_days("2024-02-29T00:00:00Z").is_some(), "2024 %4 leap");
375 assert_eq!(epoch_days("2023-02-29T00:00:00Z"), None, "2023 non-leap");
376 assert_eq!(
377 epoch_days("1900-02-29T00:00:00Z"),
378 None,
379 "1900 %100 non-leap"
380 );
381 assert!(
382 epoch_days("2000-02-29T00:00:00Z").is_some(),
383 "2000 %400 leap"
384 );
385 assert!(epoch_days("2023-02-28T00:00:00Z").is_some());
387 assert_eq!(epoch_days("2024-02-30T00:00:00Z"), None);
388 assert!(epoch_days("2026-04-30T00:00:00Z").is_some());
390 assert_eq!(epoch_days("2026-04-31T00:00:00Z"), None, "April has 30");
391 }
392
393 #[test]
395 fn staleness_threshold_boundary_is_strictly_greater_than() {
396 assert_eq!(
398 staleness_warning("2026-07-01T00:00:00Z", "2026-07-31T00:00:00Z", 30),
399 None
400 );
401 assert_eq!(
402 staleness_warning("2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z", 30),
403 Some(31)
404 );
405 }
406
407 #[test]
409 fn an_unreadable_state_file_is_an_io_error_not_first_contact() {
410 let tmp = tempfile::tempdir().unwrap();
414 std::fs::create_dir_all(tmp.path().join("state/high-water-marks.json")).unwrap();
415 assert!(matches!(
416 HighWaterMarks::load(tmp.path()),
417 Err(RollbackError::Io { .. })
418 ));
419 }
420
421 #[test]
423 fn staleness_is_a_pure_function_of_issued_at_now_and_threshold() {
424 assert_eq!(
426 staleness_warning("2026-04-01T00:00:00Z", "2026-07-10T00:00:00Z", 90),
427 Some(100)
428 );
429 assert_eq!(
431 staleness_warning("2026-06-30T00:00:00Z", "2026-07-10T00:00:00Z", 90),
432 None
433 );
434 assert_eq!(
438 staleness_warning("2026-08-01T00:00:00Z", "2026-07-10T00:00:00Z", 90),
439 None
440 );
441 }
442}