Skip to main content

okf_core/
date.rs

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