Skip to main content

spar/
clock.rs

1//! Dates and times without a date crate.
2//!
3//! spar needs a moment in four shapes: a file stamp that sorts, a header line
4//! a person reads, the RFC 3339 form GitHub returns so a recorded time compares
5//! with a comment's `created_at` as a string, and the HTTP date form a
6//! conditional request sends back. The days to civil arithmetic is Howard
7//! Hinnant's, valid for any date this will ever see.
8
9pub fn now_secs() -> u64 {
10    std::time::SystemTime::now()
11        .duration_since(std::time::UNIX_EPOCH)
12        .map(|d| d.as_secs())
13        .unwrap_or(0)
14}
15
16/// `yyyymmdd-hhmmss` in UTC, for a file name that sorts by when it was made.
17pub fn file_stamp(secs: u64) -> String {
18    let (y, m, d, hh, mm, ss) = civil(secs);
19    format!("{y:04}{m:02}{d:02}-{hh:02}{mm:02}{ss:02}")
20}
21
22/// `2026-09-12 14:03 UTC`, for the header a person reads.
23pub fn date_line(secs: u64) -> String {
24    let (y, m, d, hh, mm, _) = civil(secs);
25    format!("{y:04}-{m:02}-{d:02} {hh:02}:{mm:02} UTC")
26}
27
28/// `2026-10-20T18:04:05Z`, the shape GitHub returns, so a time spar recorded
29/// and a time GitHub reports compare as strings.
30pub fn rfc3339(secs: u64) -> String {
31    let (y, m, d, hh, mm, ss) = civil(secs);
32    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
33}
34
35/// `2026-10-20T18:04:05Z` back to seconds. A fractional second is accepted and
36/// dropped; anything else that is not that shape, or is before 1970, is None.
37pub fn parse_rfc3339(text: &str) -> Option<u64> {
38    let text = text.trim().strip_suffix('Z')?;
39    let (date, time) = text.split_once('T')?;
40    let time = time.split_once('.').map_or(time, |(whole, _)| whole);
41    let mut ymd = date.split('-');
42    let y: i64 = ymd.next()?.parse().ok()?;
43    let m: u32 = ymd.next()?.parse().ok()?;
44    let d: u32 = ymd.next()?.parse().ok()?;
45    if ymd.next().is_some() {
46        return None;
47    }
48    let mut hms = time.split(':');
49    let hh: u64 = hms.next()?.parse().ok()?;
50    let mm: u64 = hms.next()?.parse().ok()?;
51    let ss: u64 = hms.next()?.parse().ok()?;
52    if hms.next().is_some() {
53        return None;
54    }
55    if !(1..=12).contains(&m) || !(1..=31).contains(&d) || hh > 23 || mm > 59 || ss > 59 {
56        return None;
57    }
58    let days = days_from_civil(y, m, d);
59    if days < 0 {
60        return None;
61    }
62    Some(days as u64 * 86_400 + hh * 3600 + mm * 60 + ss)
63}
64
65/// `Tue, 20 Oct 2026 10:00:00 GMT`, the shape a `Last-Modified` header takes
66/// and an `If-Modified-Since` header sends back.
67pub fn http_date(secs: u64) -> String {
68    const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
69    const MONTHS: [&str; 12] = [
70        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
71    ];
72    let (y, m, d, hh, mm, ss) = civil(secs);
73    // The epoch fell on a Thursday.
74    let weekday = DAYS[((secs / 86_400 + 4) % 7) as usize];
75    let month = MONTHS[(m - 1) as usize];
76    format!("{weekday}, {d:02} {month} {y:04} {hh:02}:{mm:02}:{ss:02} GMT")
77}
78
79/// Seconds since the epoch to a civil date and time.
80pub(crate) fn civil(secs: u64) -> (i64, u32, u32, u32, u32, u32) {
81    let days = (secs / 86_400) as i64;
82    let rem = (secs % 86_400) as u32;
83    let z = days + 719_468;
84    let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
85    let doe = (z - era * 146_097) as u64;
86    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
87    let y = yoe as i64 + era * 400;
88    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
89    let mp = (5 * doy + 2) / 153;
90    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
91    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
92    let y = if m <= 2 { y + 1 } else { y };
93    (y, m, d, rem / 3600, (rem % 3600) / 60, rem % 60)
94}
95
96/// A civil date to days since the epoch, the inverse of `civil`.
97fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
98    let y = if m <= 2 { y - 1 } else { y };
99    let era = (if y >= 0 { y } else { y - 399 }) / 400;
100    let yoe = (y - era * 400) as u64;
101    let mp = if m > 2 { m - 3 } else { m + 9 } as u64;
102    let doy = (153 * mp + 2) / 5 + u64::from(d) - 1;
103    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
104    era * 146_097 + doe as i64 - 719_468
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn stamps_are_utc_and_sort_by_time() {
113        let secs = 1_789_221_787;
114        assert_eq!("20260912-140307", file_stamp(secs));
115        assert_eq!("2026-09-12 14:03 UTC", date_line(secs));
116        assert_eq!("19700101-000000", file_stamp(0));
117        // The day after a leap day.
118        assert_eq!("20240301-000000", file_stamp(1_709_251_200));
119    }
120
121    #[test]
122    fn rfc3339_formats_the_epoch_as_github_does() {
123        assert_eq!("1970-01-01T00:00:00Z", rfc3339(0));
124        assert_eq!("2026-09-12T14:03:07Z", rfc3339(1_789_221_787));
125        assert_eq!("2026-10-20T10:00:00Z", rfc3339(1_792_490_400));
126    }
127
128    #[test]
129    fn unix_seconds_round_trip_through_github_timestamps() {
130        for secs in [
131            0,
132            951_825_600,   // a leap day
133            1_709_251_200, // the day after one
134            1_789_221_787,
135            4_102_444_799, // the last second of 2099
136        ] {
137            assert_eq!(Some(secs), parse_rfc3339(&rfc3339(secs)), "{secs}");
138        }
139        assert_eq!(Some(1_792_490_400), parse_rfc3339("2026-10-20T10:00:00Z"));
140        assert_eq!(
141            Some(1_792_490_400),
142            parse_rfc3339("2026-10-20T10:00:00.123Z")
143        );
144        assert_eq!(
145            Some(1_792_490_400),
146            parse_rfc3339(" 2026-10-20T10:00:00Z\n")
147        );
148    }
149
150    #[test]
151    fn a_timestamp_that_is_not_one_is_none() {
152        for junk in [
153            "",
154            "junk",
155            "2026-10-20",
156            "2026-10-20 10:00:00",
157            "2026-10-20T10:00:00",
158            "2026-13-01T00:00:00Z",
159            "2026-10-20T24:00:00Z",
160            "1969-12-31T23:59:59Z",
161            "2026-10-20T10:00:00Z extra",
162        ] {
163            assert_eq!(None, parse_rfc3339(junk), "{junk:?}");
164        }
165    }
166
167    #[test]
168    fn an_http_date_reads_like_the_one_github_sends() {
169        assert_eq!("Tue, 20 Oct 2026 10:00:00 GMT", http_date(1_792_490_400));
170        assert_eq!("Thu, 01 Jan 1970 00:00:00 GMT", http_date(0));
171        assert_eq!("Tue, 29 Feb 2000 12:00:00 GMT", http_date(951_825_600));
172    }
173}