Skip to main content

plates_render/
dates.rs

1//! Turning the dates a vault writes into the dates a feed reader will accept.
2//!
3//! A vault's frontmatter carries dates as whatever the author typed —
4//! `2026-08-16`, `2026-08-16T09:30:00Z`, a YAML timestamp with a space in it.
5//! That is right for a document: the grain a person wrote in is information,
6//! and prov keeps it. It is wrong for syndication, where the formats are
7//! specified and checked: Atom requires RFC 3339 and RSS 2.0 requires RFC 822
8//! dates, and feeds carrying a bare `2026-08-16` are rejected by validators and
9//! misparsed by readers.
10//!
11//! So this module reads the loose spelling once and writes the strict one, for
12//! each of the two grammars that need it. A date with no time of day is read as
13//! midnight UTC — the only reading available, and the one every static site
14//! generator makes.
15//!
16//! Hand-rolled rather than `chrono`, because this crate must stay portable to
17//! `wasm32-unknown-unknown` and free of a clock: nothing here asks what time it
18//! is, it only re-spells a time it was given.
19
20/// A moment, as precisely as the vault happened to write one.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct Timestamp {
23    year: i64,
24    month: u32,
25    day: u32,
26    hour: u32,
27    minute: u32,
28    second: u32,
29    /// Minutes east of UTC. Zero for a date written without an offset, which is
30    /// also how a date written without a time of day is read.
31    offset_minutes: i32,
32}
33
34/// The instant a required-but-missing feed date falls back to.
35///
36/// Atom makes `<updated>` mandatory on the feed and on every entry, so an entry
37/// whose vault date is absent or unreadable still needs *something* valid. The
38/// epoch says "no date known" in a way a reader sorts to the bottom, where
39/// inventing a plausible one would quietly reorder somebody's archive.
40pub const EPOCH_RFC3339: &str = "1970-01-01T00:00:00Z";
41
42/// Read one of the date spellings a vault may hold.
43///
44/// Accepted: `YYYY-MM-DD`, optionally followed by `T` or a space and
45/// `HH:MM[:SS]`, optionally followed by `Z` or `±HH[:]MM`. Trailing fractional
46/// seconds are read and dropped — neither output grammar carries them.
47///
48/// `None` for anything else, including a well-formed string naming a day that
49/// does not exist (`2026-02-30`): a date that cannot be placed on a calendar
50/// cannot be given a weekday, and RFC 822 needs one.
51pub fn parse(raw: &str) -> Option<Timestamp> {
52    let s = raw.trim();
53    let bytes = s.as_bytes();
54    if bytes.len() < 10 {
55        return None;
56    }
57
58    let year: i64 = digits(&s[0..4])? as i64;
59    if bytes[4] != b'-' {
60        return None;
61    }
62    let month = digits(&s[5..7])?;
63    if bytes[7] != b'-' {
64        return None;
65    }
66    let day = digits(&s[8..10])?;
67    if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
68        return None;
69    }
70
71    let mut ts = Timestamp {
72        year,
73        month,
74        day,
75        hour: 0,
76        minute: 0,
77        second: 0,
78        offset_minutes: 0,
79    };
80
81    let rest = &s[10..];
82    if rest.is_empty() {
83        return Some(ts);
84    }
85
86    // A time of day, introduced by `T` (RFC 3339) or a space (YAML).
87    let sep = rest.as_bytes()[0];
88    if !matches!(sep, b'T' | b't' | b' ') {
89        return None;
90    }
91    let rest = rest[1..].trim_start();
92    if rest.len() < 5 {
93        return None;
94    }
95    ts.hour = digits(&rest[0..2])?;
96    if rest.as_bytes()[2] != b':' {
97        return None;
98    }
99    ts.minute = digits(&rest[3..5])?;
100    let mut rest = &rest[5..];
101    if rest.starts_with(':') {
102        if rest.len() < 3 {
103            return None;
104        }
105        ts.second = digits(&rest[1..3])?;
106        rest = &rest[3..];
107    }
108    // Fractional seconds, which neither output grammar carries.
109    if rest.starts_with('.') {
110        let end = rest[1..]
111            .find(|c: char| !c.is_ascii_digit())
112            .map_or(rest.len(), |i| i + 1);
113        rest = &rest[end..];
114    }
115    if ts.hour > 23 || ts.minute > 59 || ts.second > 60 {
116        return None;
117    }
118
119    ts.offset_minutes = parse_offset(rest.trim())?;
120    Some(ts)
121}
122
123/// Minutes east of UTC from a trailing `Z`, `±HH:MM`, `±HHMM` or nothing.
124fn parse_offset(s: &str) -> Option<i32> {
125    if s.is_empty() {
126        return Some(0);
127    }
128    if s.eq_ignore_ascii_case("z") {
129        return Some(0);
130    }
131    let sign = match s.as_bytes()[0] {
132        b'+' => 1,
133        b'-' => -1,
134        _ => return None,
135    };
136    let body = &s[1..];
137    let (hh, mm) = match body.len() {
138        5 if body.as_bytes()[2] == b':' => (&body[0..2], &body[3..5]),
139        4 => (&body[0..2], &body[2..4]),
140        2 => (&body[0..2], "00"),
141        _ => return None,
142    };
143    let hours = digits(hh)? as i32;
144    let minutes = digits(mm)? as i32;
145    if hours > 23 || minutes > 59 {
146        return None;
147    }
148    Some(sign * (hours * 60 + minutes))
149}
150
151/// Parse a fixed-width run of ASCII digits. Rejects signs and spaces, which
152/// `str::parse` would otherwise accept in the middle of a date.
153fn digits(s: &str) -> Option<u32> {
154    if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
155        return None;
156    }
157    s.parse().ok()
158}
159
160fn is_leap(year: i64) -> bool {
161    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
162}
163
164fn days_in_month(year: i64, month: u32) -> u32 {
165    match month {
166        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
167        4 | 6 | 9 | 11 => 30,
168        2 if is_leap(year) => 29,
169        2 => 28,
170        _ => 0,
171    }
172}
173
174const MONTHS: [&str; 12] = [
175    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
176];
177const WEEKDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
178
179impl Timestamp {
180    /// Day of the week, by Sakamoto's method — 0 is Sunday.
181    ///
182    /// RFC 822 puts the weekday in the date it specifies, so a feed cannot be
183    /// written without deriving one.
184    fn weekday(&self) -> usize {
185        const OFFSETS: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
186        let mut y = self.year;
187        if self.month < 3 {
188            y -= 1;
189        }
190        let idx =
191            y + y / 4 - y / 100 + y / 400 + OFFSETS[(self.month - 1) as usize] + self.day as i64;
192        idx.rem_euclid(7) as usize
193    }
194
195    /// RFC 3339, as Atom requires: `2026-08-16T09:30:00Z`.
196    pub fn to_rfc3339(&self) -> String {
197        let zone = if self.offset_minutes == 0 {
198            "Z".to_string()
199        } else {
200            let (sign, abs) = signed(self.offset_minutes);
201            format!("{sign}{:02}:{:02}", abs / 60, abs % 60)
202        };
203        format!(
204            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{zone}",
205            self.year, self.month, self.day, self.hour, self.minute, self.second
206        )
207    }
208
209    /// RFC 822 with a four-digit year, as RSS 2.0 requires:
210    /// `Sun, 16 Aug 2026 09:30:00 +0000`.
211    pub fn to_rfc822(&self) -> String {
212        let (sign, abs) = signed(self.offset_minutes);
213        format!(
214            "{}, {:02} {} {:04} {:02}:{:02}:{:02} {sign}{:02}{:02}",
215            WEEKDAYS[self.weekday()],
216            self.day,
217            MONTHS[(self.month - 1) as usize],
218            self.year,
219            self.hour,
220            self.minute,
221            self.second,
222            abs / 60,
223            abs % 60,
224        )
225    }
226}
227
228fn signed(offset_minutes: i32) -> (char, i32) {
229    if offset_minutes < 0 {
230        ('-', -offset_minutes)
231    } else {
232        ('+', offset_minutes)
233    }
234}
235
236/// Re-spell a vault date as RFC 3339, or `None` if it cannot be read.
237pub fn to_rfc3339(raw: &str) -> Option<String> {
238    parse(raw).map(|ts| ts.to_rfc3339())
239}
240
241/// Re-spell a vault date as RFC 822, or `None` if it cannot be read.
242pub fn to_rfc822(raw: &str) -> Option<String> {
243    parse(raw).map(|ts| ts.to_rfc822())
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    /// The spelling almost every vault actually holds — a day, no time. It is
251    /// what a feed validator rejects, and the reason this module exists.
252    #[test]
253    fn a_bare_day_becomes_midnight_utc() {
254        assert_eq!(to_rfc3339("2026-08-16").unwrap(), "2026-08-16T00:00:00Z");
255        assert_eq!(
256            to_rfc822("2026-08-16").unwrap(),
257            "Sun, 16 Aug 2026 00:00:00 +0000"
258        );
259    }
260
261    #[test]
262    fn a_full_rfc3339_instant_survives_the_round_trip() {
263        assert_eq!(
264            to_rfc3339("2026-08-16T09:30:15Z").unwrap(),
265            "2026-08-16T09:30:15Z"
266        );
267        assert_eq!(
268            to_rfc822("2026-08-16T09:30:15Z").unwrap(),
269            "Sun, 16 Aug 2026 09:30:15 +0000"
270        );
271    }
272
273    /// YAML writes a timestamp with a space where RFC 3339 writes a `T`, and a
274    /// vault's metadata is YAML by default.
275    #[test]
276    fn a_yaml_timestamp_is_read_too() {
277        assert_eq!(
278            to_rfc3339("2026-08-16 09:30:15").unwrap(),
279            "2026-08-16T09:30:15Z"
280        );
281    }
282
283    #[test]
284    fn an_offset_is_kept_in_both_grammars() {
285        assert_eq!(
286            to_rfc3339("2026-08-16T09:30:00+02:00").unwrap(),
287            "2026-08-16T09:30:00+02:00"
288        );
289        assert_eq!(
290            to_rfc822("2026-08-16T09:30:00+02:00").unwrap(),
291            "Sun, 16 Aug 2026 09:30:00 +0200"
292        );
293        assert_eq!(
294            to_rfc822("2026-08-16T09:30:00-0530").unwrap(),
295            "Sun, 16 Aug 2026 09:30:00 -0530"
296        );
297    }
298
299    #[test]
300    fn seconds_and_fractions_are_optional() {
301        assert_eq!(
302            to_rfc3339("2026-08-16T09:30Z").unwrap(),
303            "2026-08-16T09:30:00Z"
304        );
305        assert_eq!(
306            to_rfc3339("2026-08-16T09:30:15.250Z").unwrap(),
307            "2026-08-16T09:30:15Z"
308        );
309    }
310
311    /// Weekdays are derived, so they are worth checking against dates whose
312    /// answer is known — including a leap day, which is where an arithmetic
313    /// slip shows up first.
314    #[test]
315    fn weekdays_are_derived_correctly() {
316        assert!(to_rfc822("2024-02-29").unwrap().starts_with("Thu,"));
317        assert!(to_rfc822("2000-01-01").unwrap().starts_with("Sat,"));
318        assert!(to_rfc822("1970-01-01").unwrap().starts_with("Thu,"));
319        assert!(to_rfc822("2026-01-15").unwrap().starts_with("Thu,"));
320        assert!(to_rfc822("2100-03-01").unwrap().starts_with("Mon,"));
321    }
322
323    /// A day that is not on the calendar has no weekday, so it is refused
324    /// rather than published as a date a reader would misplace.
325    #[test]
326    fn impossible_and_malformed_dates_are_refused() {
327        for bad in [
328            "",
329            "not a date",
330            "2026-13-01",
331            "2026-02-30",
332            "2023-02-29",
333            "2026-00-10",
334            "2026-08-00",
335            "2026-8-16",
336            "2026/08/16",
337            "20260816",
338            "2026-08-16X09:30:00Z",
339            "2026-08-16T25:00:00Z",
340            "2026-08-16T09:70:00Z",
341            "2026-08-16T09:30:00+99:00",
342        ] {
343            assert!(parse(bad).is_none(), "{bad:?} should not parse");
344        }
345    }
346
347    /// A leap day exists in a leap year and not in a common one.
348    #[test]
349    fn leap_years_follow_the_gregorian_rule() {
350        assert!(parse("2024-02-29").is_some());
351        assert!(parse("2000-02-29").is_some());
352        assert!(parse("1900-02-29").is_none());
353        assert!(parse("2026-02-29").is_none());
354    }
355}