Skip to main content

okf_core/
date.rs

1//! Calendar dates and ISO-8601 datetimes for the trust and lifecycle families
2//! (§5).
3//!
4//! OKF v0.2 puts timestamp fields in frontmatter and asks consumers to answer
5//! questions about them: which verification is the most recent, and is
6//! `now >= stale_after`.
7//!
8//! Every timestamp-valued key in OKF frontmatter is an ISO-8601 datetime with
9//! an explicit UTC offset (e.g. `2026-06-30T14:00:00Z`), across `generated.at`,
10//! `verified[].at`, `stale_after`, `sources[].last_modified`, and `usage_window`.
11//! Plain `YYYY-MM-DD` dates are used only in `log.md` section headings (§9).
12//!
13//! Answering those needs real date arithmetic, so this module implements the
14//! small amount required on the standard library alone: a proleptic Gregorian
15//! [`Date`], an offset-aware [`DateTime`] that orders correctly across time
16//! zones, and the [`DateField`] / [`DateTimeField`] wrappers that keep the raw
17//! scalar around so a validator can report *what* failed to parse.
18
19use std::fmt;
20use std::time::{SystemTime, UNIX_EPOCH};
21
22/// A proleptic-Gregorian calendar date (`YYYY-MM-DD`).
23///
24/// Ordering is chronological: the derived field order (year, month, day) is
25/// exactly calendar order.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
27pub struct Date {
28    /// Calendar year.
29    pub year: i32,
30    /// Month, 1 to 12.
31    pub month: u32,
32    /// Day of month, 1 to 31 (validated against the month and leap year).
33    pub day: u32,
34}
35
36impl Date {
37    /// Builds a date, returning `None` for a day that does not exist in that
38    /// month (`2026-02-30`, `2026-13-01`, …).
39    #[must_use]
40    pub fn new(year: i32, month: u32, day: u32) -> Option<Self> {
41        if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
42            return None;
43        }
44        Some(Self { year, month, day })
45    }
46
47    /// Parses a strict `YYYY-MM-DD` date. Returns `None` for any other shape,
48    /// including datetimes; use [`DateTime::parse`] for those.
49    #[must_use]
50    pub fn parse(s: &str) -> Option<Self> {
51        let b = s.as_bytes();
52        if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
53            return None;
54        }
55        if !b
56            .iter()
57            .enumerate()
58            .all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit())
59        {
60            return None;
61        }
62        Self::new(
63            s[0..4].parse().ok()?,
64            s[5..7].parse().ok()?,
65            s[8..10].parse().ok()?,
66        )
67    }
68
69    /// Today's date in UTC, from the system clock.
70    ///
71    /// Returns `None` only if the clock reports a time before the Unix epoch.
72    #[must_use]
73    pub fn today_utc() -> Option<Self> {
74        let secs = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
75        // `secs` is `u64`; saturate at `i64::MAX` (far past any representable
76        // date) rather than `as i64`, which would silently wrap near the limit.
77        let secs = i64::try_from(secs).unwrap_or(i64::MAX);
78        Some(Self::from_days_since_epoch(secs.div_euclid(86_400)))
79    }
80
81    /// Days since 1970-01-01 (negative before it).
82    #[must_use]
83    pub fn days_since_epoch(&self) -> i64 {
84        days_from_civil(self.year, self.month, self.day)
85    }
86
87    /// The date `days` after 1970-01-01.
88    #[must_use]
89    pub fn from_days_since_epoch(days: i64) -> Self {
90        let (year, month, day) = civil_from_days(days);
91        Self { year, month, day }
92    }
93
94    /// Returns a UTC datetime at midnight (`00:00:00Z`) on this date.
95    #[must_use]
96    pub const fn to_utc_datetime(&self) -> DateTime {
97        DateTime::from_date_utc(*self)
98    }
99}
100
101impl fmt::Display for Date {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
104    }
105}
106
107impl std::str::FromStr for Date {
108    type Err = ParseDateError;
109    fn from_str(s: &str) -> Result<Self, Self::Err> {
110        Self::parse(s).ok_or_else(|| ParseDateError(s.to_string()))
111    }
112}
113
114/// Error returned when a string is not a valid date or datetime.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct ParseDateError(pub String);
117
118impl fmt::Display for ParseDateError {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        write!(f, "not an ISO-8601 date/datetime: {:?}", self.0)
121    }
122}
123
124impl std::error::Error for ParseDateError {}
125
126/// An ISO-8601 datetime: a [`Date`], an optional time of day, and an optional
127/// UTC offset.
128///
129/// A value with no offset is treated as UTC for comparison, which is what
130/// consumers need in order to answer "which verification is most recent" (§5.2)
131/// across producers that write `Z`, `+02:00`, or nothing at all.
132#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
133pub struct DateTime {
134    /// The calendar date.
135    pub date: Date,
136    /// Hour, 0 to 23 (0 when the value is date-only).
137    pub hour: u32,
138    /// Minute, 0 to 59.
139    pub minute: u32,
140    /// Second, 0 to 60 (60 admits a leap second).
141    pub second: u32,
142    /// Fractional second in nanoseconds.
143    pub nanosecond: u32,
144    /// Minutes east of UTC, or `None` when the value carries no zone.
145    pub offset_minutes: Option<i32>,
146    /// `false` when only a date was written (`2026-09-23`).
147    pub has_time: bool,
148}
149
150impl DateTime {
151    /// Parses an ISO-8601 datetime.
152    ///
153    /// Accepts `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM[:SS[.fraction]]` (with `T`, `t`,
154    /// or a space as the separator), and an optional `Z` / `±HH:MM` / `±HHMM` /
155    /// `±HH` zone.
156    pub fn parse(s: &str) -> Option<Self> {
157        let s = s.trim();
158        if !s.is_ascii() || s.len() < 10 {
159            return None;
160        }
161        let date = Date::parse(&s[..10])?;
162        let rest = &s[10..];
163        if rest.is_empty() {
164            return Some(Self {
165                date,
166                hour: 0,
167                minute: 0,
168                second: 0,
169                nanosecond: 0,
170                offset_minutes: None,
171                has_time: false,
172            });
173        }
174
175        let sep = rest.as_bytes()[0];
176        if sep != b'T' && sep != b't' && sep != b' ' {
177            return None;
178        }
179        let mut rest = &rest[1..];
180
181        let hour = take_u32(&mut rest, 2)?;
182        expect(&mut rest, ':')?;
183        let minute = take_u32(&mut rest, 2)?;
184        let mut second = 0;
185        let mut nanosecond = 0;
186        if rest.starts_with(':') {
187            rest = &rest[1..];
188            second = take_u32(&mut rest, 2)?;
189            if rest.starts_with('.') || rest.starts_with(',') {
190                rest = &rest[1..];
191                let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
192                if digits.is_empty() {
193                    return None;
194                }
195                rest = &rest[digits.len()..];
196                // Left-align to nanosecond precision, truncating beyond 9 digits.
197                let mut nanos = digits;
198                nanos.truncate(9);
199                while nanos.len() < 9 {
200                    nanos.push('0');
201                }
202                nanosecond = nanos.parse().ok()?;
203            }
204        }
205        if hour > 23 || minute > 59 || second > 60 {
206            return None;
207        }
208
209        let offset_minutes = parse_offset(rest)?;
210        Some(Self {
211            date,
212            hour,
213            minute,
214            second,
215            nanosecond,
216            offset_minutes,
217            has_time: true,
218        })
219    }
220
221    /// Seconds since the Unix epoch, normalizing the zone (a missing offset is
222    /// read as UTC).
223    #[must_use]
224    pub fn to_utc_seconds(&self) -> i64 {
225        self.date.days_since_epoch() * 86_400
226            + i64::from(self.hour) * 3600
227            + i64::from(self.minute) * 60
228            + i64::from(self.second)
229            - i64::from(self.offset_minutes.unwrap_or(0)) * 60
230    }
231
232    /// The calendar date in UTC, which differs from [`DateTime::date`] when the
233    /// value carries an offset that crosses midnight.
234    #[must_use]
235    pub fn utc_date(&self) -> Date {
236        Date::from_days_since_epoch(self.to_utc_seconds().div_euclid(86_400))
237    }
238
239    /// `true` if the parsed datetime carries an explicit UTC offset.
240    #[must_use]
241    pub const fn has_offset(&self) -> bool {
242        self.offset_minutes.is_some()
243    }
244
245    /// The current instant in UTC from the system clock.
246    #[must_use]
247    pub fn now_utc() -> Option<Self> {
248        let duration = SystemTime::now().duration_since(UNIX_EPOCH).ok()?;
249        let secs = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX);
250        let nanos = duration.subsec_nanos();
251        let days = secs.div_euclid(86_400);
252        let rem_secs = secs.rem_euclid(86_400);
253        let hour = u32::try_from(rem_secs / 3600).ok()?;
254        let minute = u32::try_from((rem_secs % 3600) / 60).ok()?;
255        let second = u32::try_from(rem_secs % 60).ok()?;
256        Some(Self {
257            date: Date::from_days_since_epoch(days),
258            hour,
259            minute,
260            second,
261            nanosecond: nanos,
262            offset_minutes: Some(0),
263            has_time: true,
264        })
265    }
266
267    /// Creates a UTC datetime at midnight (`00:00:00Z`) for a given [`Date`].
268    #[must_use]
269    pub const fn from_date_utc(date: Date) -> Self {
270        Self {
271            date,
272            hour: 0,
273            minute: 0,
274            second: 0,
275            nanosecond: 0,
276            offset_minutes: Some(0),
277            has_time: true,
278        }
279    }
280}
281
282impl PartialOrd for DateTime {
283    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
284        Some(self.cmp(other))
285    }
286}
287
288impl Ord for DateTime {
289    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
290        self.to_utc_seconds()
291            .cmp(&other.to_utc_seconds())
292            .then(self.nanosecond.cmp(&other.nanosecond))
293    }
294}
295
296impl fmt::Display for DateTime {
297    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298        write!(f, "{}", self.date)?;
299        if !self.has_time {
300            return Ok(());
301        }
302        write!(f, "T{:02}:{:02}:{:02}", self.hour, self.minute, self.second)?;
303        if self.nanosecond > 0 {
304            let frac = format!("{:09}", self.nanosecond);
305            write!(f, ".{}", frac.trim_end_matches('0'))?;
306        }
307        match self.offset_minutes {
308            None => Ok(()),
309            Some(0) => f.write_str("Z"),
310            Some(m) => {
311                let sign = if m < 0 { '-' } else { '+' };
312                write!(f, "{sign}{:02}:{:02}", m.abs() / 60, m.abs() % 60)
313            }
314        }
315    }
316}
317
318impl std::str::FromStr for DateTime {
319    type Err = ParseDateError;
320    fn from_str(s: &str) -> Result<Self, Self::Err> {
321        Self::parse(s).ok_or_else(|| ParseDateError(s.to_string()))
322    }
323}
324
325/// A frontmatter date field: the scalar exactly as written, plus its parse.
326///
327/// Keeping the raw text lets a consumer round-trip the value and lets
328/// the okf-validator crate report *which* scalar is malformed instead of
329/// silently dropping it: the spec's permissiveness rule (§11) means an
330/// unparseable date must never make a document unreadable.
331#[derive(Clone, Debug, PartialEq, Eq)]
332pub struct DateField {
333    /// The scalar as written in the frontmatter.
334    pub raw: String,
335    /// The parsed date, or `None` if `raw` is not a `YYYY-MM-DD` date.
336    pub date: Option<Date>,
337}
338
339impl DateField {
340    /// Wraps a raw scalar, parsing it if possible.
341    pub fn new(raw: impl Into<String>) -> Self {
342        let raw = raw.into();
343        let date = Date::parse(raw.trim());
344        Self { raw, date }
345    }
346
347    /// `true` if the raw scalar parsed as a date.
348    #[must_use]
349    pub const fn is_valid(&self) -> bool {
350        self.date.is_some()
351    }
352
353    /// The date this field designates, accepting a datetime by taking its date
354    /// part.
355    ///
356    /// [`DateField::date`] is deliberately strict, because §5.5 asks
357    /// `stale_after` for "an absolute date (`YYYY-MM-DD`)" and the validator
358    /// should report a datetime there as the deviation it is. A comparison still
359    /// has to reach a verdict, though, and reading a malformed date as "never
360    /// stale" is the dangerous direction: a concept well past its date would
361    /// look current. So comparisons use this instead, which matches the
362    /// reference implementation's `is_stale` (it truncates with
363    /// `date.fromisoformat(str(raw)[:10])`). The written date is used as-is,
364    /// without shifting by any UTC offset, exactly as that truncation does.
365    #[must_use]
366    pub fn effective_date(&self) -> Option<Date> {
367        self.date
368            .or_else(|| DateTime::parse(self.raw.trim()).map(|parsed| parsed.date))
369    }
370}
371
372impl fmt::Display for DateField {
373    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374        f.write_str(&self.raw)
375    }
376}
377
378/// A frontmatter datetime field: the scalar exactly as written, plus its parse.
379///
380/// Keeping the raw text lets a consumer round-trip the value and lets
381/// the okf-validator crate report *which* scalar is malformed instead of
382/// silently dropping it: the spec's permissiveness rule (§11) means an
383/// unparseable datetime must never make a document unreadable.
384#[derive(Clone, Debug, PartialEq, Eq)]
385pub struct DateTimeField {
386    /// The scalar as written in the frontmatter.
387    pub raw: String,
388    /// The parsed datetime, or `None` if `raw` is not an ISO-8601
389    /// datetime. A date-only or offset-less value remains available through
390    /// `datetime` so it can be diagnosed without losing the original scalar.
391    pub datetime: Option<DateTime>,
392}
393
394impl DateTimeField {
395    /// Wraps a raw scalar, parsing it if possible.
396    pub fn new(raw: impl Into<String>) -> Self {
397        let raw = raw.into();
398        let datetime = DateTime::parse(&raw);
399        Self { raw, datetime }
400    }
401
402    /// `true` if the raw scalar parsed as an ISO-8601 datetime with a time of
403    /// day and an explicit UTC offset (§5).
404    #[must_use]
405    pub const fn is_valid(&self) -> bool {
406        self.has_time() && self.has_offset()
407    }
408
409    /// `true` if the raw scalar parsed as a datetime with a time of day.
410    ///
411    /// [`DateTime::parse`] intentionally also accepts date-only values for
412    /// callers that need a generic ISO-8601 date/datetime parser. OKF frontmatter
413    /// timestamp fields use [`DateTimeField::is_valid`].
414    #[must_use]
415    pub const fn has_time(&self) -> bool {
416        match self.datetime {
417            Some(datetime) => datetime.has_time,
418            None => false,
419        }
420    }
421
422    /// `true` if the raw scalar parsed with an explicit UTC offset.
423    #[must_use]
424    pub const fn has_offset(&self) -> bool {
425        match self.datetime {
426            Some(datetime) => datetime.offset_minutes.is_some(),
427            None => false,
428        }
429    }
430}
431
432impl fmt::Display for DateTimeField {
433    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
434        f.write_str(&self.raw)
435    }
436}
437
438fn expect<'a>(s: &mut &'a str, c: char) -> Option<()> {
439    let rest: &'a str = (*s).strip_prefix(c)?;
440    *s = rest;
441    Some(())
442}
443
444/// Consumes exactly `n` ASCII digits from the front of `s`.
445fn take_u32<'a>(s: &mut &'a str, n: usize) -> Option<u32> {
446    let src: &'a str = s;
447    if src.len() < n || !src.as_bytes()[..n].iter().all(u8::is_ascii_digit) {
448        return None;
449    }
450    let value = src[..n].parse().ok()?;
451    *s = &src[n..];
452    Some(value)
453}
454
455/// Parses a trailing zone designator: empty (`None`), `Z`, or `±HH[[:]MM]`.
456///
457/// The outer `Option` distinguishes "no zone designator at all" (a bare
458/// `YYYY-MM-DD` date, returned as `Some(None)`) from "a syntactically invalid
459/// zone" (returned as `None`). The inner `Option<i32>` is the parsed offset in
460/// minutes, with `Some(0)` for an explicit `Z` and `None` for an absent zone.
461#[allow(clippy::option_option)]
462fn parse_offset(s: &str) -> Option<Option<i32>> {
463    if s.is_empty() {
464        return Some(None);
465    }
466    if s.eq_ignore_ascii_case("z") {
467        return Some(Some(0));
468    }
469    let (sign, rest) = match s.as_bytes()[0] {
470        b'+' => (1, &s[1..]),
471        b'-' => (-1, &s[1..]),
472        _ => return None,
473    };
474    let mut rest = rest;
475    let hours = take_u32(&mut rest, 2)?;
476    let minutes = if rest.is_empty() {
477        0
478    } else {
479        let _ = expect(&mut rest, ':');
480        take_u32(&mut rest, 2)?
481    };
482    if !rest.is_empty() || hours > 23 || minutes > 59 {
483        return None;
484    }
485    // `hours` (≤ 23) and `minutes` (≤ 59) fit in `i32` by construction; the
486    // `try_from` keeps the cast explicit instead of relying on `as`.
487    let h = i32::try_from(hours).expect("hours bounded to 23");
488    let m = i32::try_from(minutes).expect("minutes bounded to 59");
489    Some(Some(sign * (h * 60 + m)))
490}
491
492const fn is_leap_year(year: i32) -> bool {
493    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
494}
495
496const fn days_in_month(year: i32, month: u32) -> u32 {
497    match month {
498        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
499        4 | 6 | 9 | 11 => 30,
500        2 if is_leap_year(year) => 29,
501        2 => 28,
502        _ => 0,
503    }
504}
505
506/// Days from 1970-01-01 to `y-m-d` (Howard Hinnant's `days_from_civil`).
507fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
508    let y = i64::from(y) - i64::from(m <= 2);
509    let era = if y >= 0 { y } else { y - 399 } / 400;
510    let yoe = y - era * 400; // [0, 399]
511    let mp = i64::from(if m > 2 { m - 3 } else { m + 9 }); // [0, 11]
512    let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; // [0, 365]
513    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
514    era * 146_097 + doe - 719_468
515}
516
517/// The inverse of [`days_from_civil`] (Hinnant's `civil_from_days`).
518///
519/// The algorithm bounds `y` to a signed 32-bit range and `m`, `d` to `[1, 12]`
520/// and `[1, 31]` respectively, so the narrowing casts below cannot truncate or
521/// wrap for any date representable under the algorithm.
522#[allow(
523    clippy::cast_possible_truncation,
524    clippy::cast_sign_loss,
525    clippy::cast_possible_wrap
526)]
527fn civil_from_days(z: i64) -> (i32, u32, u32) {
528    let z = z + 719_468;
529    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
530    let doe = z - era * 146_097; // [0, 146096]
531    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
532    let y = yoe + era * 400;
533    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
534    let mp = (5 * doy + 2) / 153; // [0, 11]
535    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
536    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
537    ((y + i64::from(m <= 2)) as i32, m as u32, d as u32)
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn parses_plain_dates() {
546        assert_eq!(Date::parse("2026-09-23"), Date::new(2026, 9, 23));
547        assert_eq!(Date::parse("2024-02-29"), Date::new(2024, 2, 29));
548        assert_eq!(Date::parse("2026-02-29"), None); // not a leap year
549        assert_eq!(Date::parse("2026-13-01"), None);
550        assert_eq!(Date::parse("2026-9-23"), None); // must be zero-padded
551        assert_eq!(Date::parse("2026-09-23T00:00:00Z"), None);
552    }
553
554    #[test]
555    fn epoch_roundtrip() {
556        for days in [-40_000_i64, -1, 0, 1, 20_000, 100_000] {
557            let d = Date::from_days_since_epoch(days);
558            assert_eq!(d.days_since_epoch(), days, "{d}");
559        }
560        assert_eq!(Date::from_days_since_epoch(0).to_string(), "1970-01-01");
561    }
562
563    #[test]
564    fn parses_datetimes_with_zones() {
565        let z = DateTime::parse("2026-06-20T22:53:05Z").unwrap();
566        assert_eq!(z.offset_minutes, Some(0));
567        assert_eq!(z.to_string(), "2026-06-20T22:53:05Z");
568
569        let offset = DateTime::parse("2026-05-28T22:53:05+00:00").unwrap();
570        assert_eq!(
571            offset.to_utc_seconds(),
572            DateTime::parse("2026-05-28T22:53:05Z")
573                .unwrap()
574                .to_utc_seconds()
575        );
576
577        // Same instant, written in two zones.
578        let a = DateTime::parse("2026-06-25T09:00:00+02:00").unwrap();
579        let b = DateTime::parse("2026-06-25T07:00:00Z").unwrap();
580        assert_eq!(a.cmp(&b), std::cmp::Ordering::Equal);
581
582        assert!(DateTime::parse("2026-06-20 22:53:05").unwrap().has_time);
583        assert!(!DateTime::parse("2026-06-20").unwrap().has_time);
584        assert_eq!(DateTime::parse("2026-06-20T22:53").unwrap().second, 0);
585        assert_eq!(
586            DateTime::parse("2026-06-20T22:53:05.25Z")
587                .unwrap()
588                .nanosecond,
589            250_000_000
590        );
591        assert_eq!(DateTime::parse("2026-06-20T25:00:00Z"), None);
592        assert_eq!(DateTime::parse("not a date"), None);
593    }
594
595    #[test]
596    fn offsets_order_across_midnight() {
597        let late = DateTime::parse("2026-06-20T23:00:00-05:00").unwrap();
598        assert_eq!(late.utc_date(), Date::new(2026, 6, 21).unwrap());
599        assert!(late > DateTime::parse("2026-06-21T03:00:00Z").unwrap());
600    }
601
602    #[test]
603    fn fields_keep_raw_text() {
604        let bad = DateField::new("last tuesday");
605        assert!(!bad.is_valid());
606        assert_eq!(bad.raw, "last tuesday");
607
608        let good = DateTimeField::new("2026-06-25T09:00:00Z");
609        assert!(good.is_valid());
610        assert!(good.has_time());
611        assert!(good.has_offset());
612        assert_eq!(good.datetime.unwrap().date, Date::new(2026, 6, 25).unwrap());
613
614        let no_offset = DateTimeField::new("2026-06-25T09:00:00");
615        assert!(!no_offset.is_valid());
616        assert!(no_offset.has_time());
617        assert!(!no_offset.has_offset());
618
619        let date_only = DateTimeField::new("2026-06-25");
620        assert!(!date_only.is_valid());
621        assert!(!date_only.has_time());
622        assert!(!date_only.has_offset());
623    }
624}