1use std::sync::{Arc, Mutex, PoisonError};
22
23use kernel::consts_policy::CLOCK_ZONES_MAX;
24use kernel::{AxCode, AxError, ClockStampGranularity, ClockZone, Temporal, TimeMs};
25
26pub fn iso(at: TimeMs) -> String {
30 format_at(at, 0)
31}
32
33pub fn parse_iso(raw: &str) -> Result<TimeMs, AxError> {
44 let refused = || {
45 AxError::failure(AxCode::InvalidArgs, "read a UTC moment", raw).with_recovery(
46 "write the moment in UTC to the second, ending in Z, the way a clock line \
47 does: 2026-05-14T09:31:07Z; turn a local time or an offset into UTC first",
48 )
49 };
50 let millis = civil_fields(raw.as_bytes())
51 .and_then(millis_of)
52 .ok_or_else(refused)?;
53 u64::try_from(millis)
54 .map(TimeMs::new)
55 .map_err(|_before_1970| refused())
56}
57
58const ISO_SEPARATORS: [(usize, u8); 6] = [
61 (4, b'-'),
62 (7, b'-'),
63 (10, b'T'),
64 (13, b':'),
65 (16, b':'),
66 (19, b'Z'),
67];
68const ISO_FIELDS: [(usize, usize); 6] = [(0, 4), (5, 7), (8, 10), (11, 13), (14, 16), (17, 19)];
69
70fn civil_fields(bytes: &[u8]) -> Option<[i128; 6]> {
73 let shaped = bytes.len() == 20
74 && ISO_SEPARATORS
75 .iter()
76 .all(|(at, separator)| bytes.get(*at) == Some(separator));
77 if !shaped {
78 return None;
79 }
80 let [year, month, day, hour, minute, second] = ISO_FIELDS.map(|(from, to)| {
81 bytes.get(from..to)?.iter().try_fold(0i128, |number, byte| {
82 let digit = char::from(*byte).to_digit(10)?;
83 number.checked_mul(10)?.checked_add(i128::from(digit))
84 })
85 });
86 Some([year?, month?, day?, hour?, minute?, second?])
87}
88
89#[expect(
93 clippy::arithmetic_side_effects,
94 reason = "every field is at most four decimal digits, so the day count and the \
95 milliseconds stay far inside i128; the divisors are positive constants"
96)]
97fn millis_of([year, month, day, hour, minute, second]: [i128; 6]) -> Option<i128> {
98 if !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 {
99 return None;
100 }
101 let y = if month <= 2 { year - 1 } else { year };
102 let era = y.div_euclid(400);
103 let yoe = y - era * 400;
104 let mp = if month > 2 { month - 3 } else { month + 9 };
105 let doy = (153 * mp + 2) / 5 + day - 1;
106 let days = era * 146_097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719_468;
107 (civil_from_days(days) == (year, month, day))
108 .then_some((days * 86_400 + hour * 3_600 + minute * 60 + second) * 1_000)
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub struct UtcSpan {
119 since: Option<TimeMs>,
120 until: Option<TimeMs>,
121}
122
123impl UtcSpan {
124 pub fn new(since: Option<TimeMs>, until: Option<TimeMs>) -> Result<UtcSpan, AxError> {
129 if let (Some(start), Some(end)) = (since, until)
130 && end <= start
131 {
132 return Err(AxError::failure(
133 AxCode::InvalidArgs,
134 "select a span of time",
135 format!("{} to {}", iso(start), iso(end)),
136 )
137 .with_recovery(
138 "give an end after the start; the end itself is left out, so a span that \
139 ends where it starts holds no moment",
140 ));
141 }
142 Ok(UtcSpan { since, until })
143 }
144
145 #[must_use]
146 pub fn since(&self) -> Option<TimeMs> {
147 self.since
148 }
149
150 #[must_use]
151 pub fn until(&self) -> Option<TimeMs> {
152 self.until
153 }
154
155 #[must_use]
157 pub fn contains(&self, at: TimeMs) -> bool {
158 self.since.is_none_or(|since| since <= at) && self.until.is_none_or(|until| at < until)
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct ZoneEntry {
166 pub id: String,
167 pub offset_min: i32,
168 pub local: String,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct ClockStamp {
176 pub utc_ms: TimeMs,
177 pub zones: Vec<ZoneEntry>,
178}
179
180impl ClockStamp {
181 pub fn render(&self) -> String {
184 let mut out = format!("clock: {};", iso(self.utc_ms));
185 for zone in &self.zones {
186 out.push(' ');
187 out.push_str(&zone.id);
188 out.push(' ');
189 out.push_str(&zone.local);
190 out.push(';');
191 }
192 out
193 }
194}
195
196fn bucket_ms(granularity: ClockStampGranularity) -> Option<u64> {
197 match granularity {
198 ClockStampGranularity::Off => None,
199 ClockStampGranularity::Minute => Some(60_000),
200 ClockStampGranularity::FiveMinute => Some(300_000),
201 ClockStampGranularity::Hour => Some(3_600_000),
202 }
203}
204
205#[expect(
207 clippy::arithmetic_side_effects,
208 reason = "era arithmetic on i128: the input is u64 milliseconds plus an i32 offset in \
209 minutes, divided down to days, far inside i128; every divisor is a positive \
210 constant, so neither overflow nor division by zero is reachable"
211)]
212fn civil_from_days(days: i128) -> (i128, i128, i128) {
213 let z = days + 719_468;
214 let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
215 let doe = z - era * 146_097;
216 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
217 let y = yoe + era * 400;
218 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
219 let mp = (5 * doy + 2) / 153;
220 let d = doy - (153 * mp + 2) / 5 + 1;
221 let m = if mp < 10 { mp + 3 } else { mp - 9 };
222 let year = if m <= 2 { y + 1 } else { y };
223 (year, m, d)
224}
225
226#[expect(
229 clippy::arithmetic_side_effects,
230 reason = "i128 values bounded by u64 milliseconds plus an i32 offset in minutes times \
231 60_000; div_euclid and rem_euclid by positive constants, so i128::MIN and \
232 division by zero are unreachable"
233)]
234fn format_at(at: TimeMs, offset_min: i32) -> String {
235 let shifted = i128::from(at.value()) + i128::from(offset_min) * 60_000;
236 let seconds = shifted.div_euclid(1_000);
237 let (year, month, day) = civil_from_days(seconds.div_euclid(86_400));
238 let second_of_day = seconds.rem_euclid(86_400);
239 let (hour, minute, second) = (
240 second_of_day / 3_600,
241 second_of_day % 3_600 / 60,
242 second_of_day % 60,
243 );
244 format!(
245 "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}{}",
246 offset_suffix(offset_min)
247 )
248}
249
250fn offset_suffix(offset_min: i32) -> String {
251 if offset_min == 0 {
252 return "Z".to_owned();
253 }
254 let sign = if offset_min < 0 { '-' } else { '+' };
255 let whole = offset_min.unsigned_abs();
256 format!("{sign}{:02}:{:02}", whole / 60, whole % 60)
257}
258
259pub fn stamp(now: TimeMs, zones: &[ClockZone]) -> Result<ClockStamp, AxError> {
262 CLOCK_ZONES_MAX.admit(zones.len())?;
263 Ok(ClockStamp {
264 utc_ms: now,
265 zones: zones
266 .iter()
267 .map(|zone| ZoneEntry {
268 id: zone.id.clone(),
269 offset_min: zone.offset_min,
270 local: format_at(now, zone.offset_min),
271 })
272 .collect(),
273 })
274}
275
276#[derive(Debug)]
279pub struct StampGate {
280 granularity: ClockStampGranularity,
281 zones: Vec<ClockZone>,
282 last_bucket: Option<u64>,
283}
284
285impl StampGate {
286 pub fn new(granularity: ClockStampGranularity, zones: Vec<ClockZone>) -> StampGate {
289 StampGate {
290 granularity,
291 zones,
292 last_bucket: None,
293 }
294 }
295
296 pub fn observe(
298 &mut self,
299 now: TimeMs,
300 temporal: Temporal,
301 ) -> Result<Option<ClockStamp>, AxError> {
302 let Some(width) = bucket_ms(self.granularity) else {
303 return Ok(None);
304 };
305 let bucket = now.value().checked_div(width).ok_or_else(|| {
306 AxError::failure(
307 AxCode::InvalidArgs,
308 "bucket clock stamp",
309 "zero bucket width",
310 )
311 .with_recovery(
312 "report this against runtime::clock: every granularity except the one \
313 that shows no clock owes a bucket width above zero",
314 )
315 })?;
316 let due = match temporal {
317 Temporal::Timestamped => true,
318 Temporal::Timeless => self.last_bucket != Some(bucket),
319 } || self.last_bucket.is_none();
320 if !due {
321 return Ok(None);
322 }
323 self.last_bucket = Some(bucket);
324 stamp(now, &self.zones).map(Some)
325 }
326}
327
328#[derive(Debug, Clone, Default)]
342pub struct ClockReading(Arc<Mutex<Option<TimeMs>>>);
343
344impl ClockReading {
345 pub fn keep(&self, at: TimeMs) {
347 *self.0.lock().unwrap_or_else(PoisonError::into_inner) = Some(at);
348 }
349
350 #[must_use]
353 pub fn latest(&self) -> Option<TimeMs> {
354 *self.0.lock().unwrap_or_else(PoisonError::into_inner)
355 }
356}
357
358#[cfg(test)]
359#[allow(
360 clippy::unwrap_used,
361 clippy::expect_used,
362 clippy::panic,
363 clippy::indexing_slicing,
364 reason = "test code"
365)]
366mod tests {
367 use super::*;
368
369 fn tokyo() -> ClockZone {
370 ClockZone {
371 id: "tokyo".to_owned(),
372 offset_min: 540,
373 }
374 }
375
376 fn rendered(at: u64, zones: &[ClockZone]) -> String {
377 stamp(TimeMs::new(at), zones).unwrap().render()
378 }
379
380 #[test]
381 fn a_stamp_reads_as_iso_utc_to_the_second() {
382 assert_eq!(rendered(0, &[]), "clock: 1970-01-01T00:00:00Z;");
383 assert_eq!(
384 rendered(1_709_251_199_999, &[]),
385 "clock: 2024-02-29T23:59:59Z;"
386 );
387 assert_eq!(
388 rendered(1_785_585_607_000, &[]),
389 "clock: 2026-08-01T12:00:07Z;"
390 );
391 assert_eq!(
392 rendered(978_287_400_000, &[]),
393 "clock: 2000-12-31T18:30:00Z;"
394 );
395 }
396
397 #[test]
398 fn a_zone_row_is_the_same_second_with_its_offset_across_midnight_both_ways() {
399 let west = ClockZone {
402 id: "west".to_owned(),
403 offset_min: -780,
404 };
405 assert_eq!(
406 rendered(1_785_585_607_000, &[tokyo(), west]),
407 "clock: 2026-08-01T12:00:07Z; tokyo 2026-08-01T21:00:07+09:00; \
408 west 2026-07-31T23:00:07-13:00;"
409 );
410 assert!(stamp(TimeMs::new(0), &[]).unwrap().zones.is_empty());
411 }
412
413 #[test]
414 fn zones_beyond_the_cap_are_refused_not_dropped() {
415 let too_many: Vec<ClockZone> = (0..5)
416 .map(|i| ClockZone {
417 id: format!("z{i}"),
418 offset_min: 0,
419 })
420 .collect();
421 let err = stamp(TimeMs::new(0), &too_many).unwrap_err();
422 assert_eq!(err, CLOCK_ZONES_MAX.admit(5).unwrap_err());
425 }
426
427 #[test]
428 fn off_emits_never_even_for_timestamped_tools() {
429 let mut gate = StampGate::new(ClockStampGranularity::Off, Vec::new());
430 for t in [1u64, 60_001, 3_600_001] {
431 let out = gate.observe(TimeMs::new(t), Temporal::Timestamped).unwrap();
432 assert!(out.is_none(), "A18: Off must cost zero bytes");
433 }
434 }
435
436 #[test]
437 fn first_result_emits_once_then_timeless_deduplicates_within_a_bucket() {
438 let mut gate = StampGate::new(ClockStampGranularity::Minute, Vec::new());
439 assert!(
441 gate.observe(TimeMs::new(1_000), Temporal::Timeless)
442 .unwrap()
443 .is_some()
444 );
445 assert!(
447 gate.observe(TimeMs::new(30_000), Temporal::Timeless)
448 .unwrap()
449 .is_none()
450 );
451 let s = gate
454 .observe(TimeMs::new(61_000), Temporal::Timeless)
455 .unwrap()
456 .unwrap();
457 assert_eq!(s.utc_ms, TimeMs::new(61_000));
458 }
459
460 #[test]
464 fn an_iso_moment_reads_back_to_the_second_it_was_written() {
465 let last = 253_402_300_799_999u64; let step = 7_919_999_999u64; let moments = (0..=last / step).map(|k| k * step).chain([
468 0,
469 999,
470 951_782_400_000, 1_709_251_199_999, 4_107_542_400_000, last,
474 ]);
475 for at in moments {
476 let written = iso(TimeMs::new(at));
477 assert_eq!(
478 parse_iso(&written),
479 Ok(TimeMs::new(at - at % 1_000)),
480 "{written}"
481 );
482 }
483 assert_eq!(
484 parse_iso("2024-02-29T23:59:59Z"),
485 Ok(TimeMs::new(1_709_251_199_000))
486 );
487 }
488
489 #[test]
492 fn parse_iso_refuses_every_spelling_iso_does_not_write() {
493 for raw in [
494 "2026-05-14T18:31:07+09:00",
495 "2026-05-14T09:31:07.000Z",
496 "2026-05-14",
497 "2026-05-14 09:31:07Z",
498 "2026-05-14t09:31:07z",
499 "2026-02-30T00:00:00Z",
500 "2025-02-29T00:00:00Z",
501 "2100-02-29T00:00:00Z",
502 "2026-13-01T00:00:00Z",
503 "2026-00-10T00:00:00Z",
504 "2026-05-00T00:00:00Z",
505 "2026-05-14T24:00:00Z",
506 "2026-05-14T09:60:00Z",
507 "2026-12-31T23:59:60Z",
508 "1969-12-31T23:59:59Z",
509 "+026-05-14T09:31:07Z",
510 " 2026-05-14T09:31:07Z",
511 "",
512 ] {
513 let refused = parse_iso(raw);
514 assert!(
515 matches!(&refused, Err(err) if *err.code() == AxCode::InvalidArgs
516 && err.subject() == raw
517 && err.recovery().contains("2026-05-14T09:31:07Z")),
518 "{raw:?}: {refused:?}"
519 );
520 }
521 }
522
523 #[test]
526 fn a_span_keeps_its_start_and_leaves_out_its_end() {
527 let at = TimeMs::new;
528 let both = UtcSpan::new(Some(at(1_000)), Some(at(2_000))).unwrap();
529 assert_eq!(
530 [999, 1_000, 1_999, 2_000].map(|t| both.contains(at(t))),
531 [false, true, true, false]
532 );
533 let until = UtcSpan::new(None, Some(at(2_000))).unwrap();
534 assert_eq!([0, 2_000].map(|t| until.contains(at(t))), [true, false]);
535 let since = UtcSpan::new(Some(at(1_000)), None).unwrap();
536 assert_eq!(
537 [999, u64::MAX].map(|t| since.contains(at(t))),
538 [false, true]
539 );
540 assert!(UtcSpan::default().contains(at(0)));
541 }
542
543 #[test]
546 fn a_span_with_no_moment_in_it_is_refused() {
547 let at = TimeMs::new;
548 for (since, until) in [(2_000, 2_000), (2_001, 2_000)] {
549 let refused = UtcSpan::new(Some(at(since)), Some(at(until)));
550 assert!(
551 matches!(&refused, Err(err) if *err.code() == AxCode::InvalidArgs
552 && err.subject().contains(&iso(at(since)))
553 && err.subject().contains(&iso(at(until)))),
554 "{since}..{until}: {refused:?}"
555 );
556 }
557 }
558
559 #[test]
560 fn timestamped_emits_every_result_with_its_own_reading() {
561 let mut gate = StampGate::new(ClockStampGranularity::Hour, Vec::new());
562 let a = gate
563 .observe(TimeMs::new(1_000), Temporal::Timestamped)
564 .unwrap()
565 .unwrap();
566 let b = gate
567 .observe(TimeMs::new(2_000), Temporal::Timestamped)
568 .unwrap()
569 .unwrap();
570 assert_eq!(a.render(), "clock: 1970-01-01T00:00:01Z;");
571 assert_eq!(b.render(), "clock: 1970-01-01T00:00:02Z;");
572 }
573}