Skip to main content

ssg/core/
dates.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Flexible, dependency-free date parsing shared by the feed and
5//! sitemap post-processing plugins.
6//!
7//! Issue #586 / v0.0.47 plan §2 item 1.4 (spec A4): the native
8//! `rss.rs`, `atom.rs`, `json_feed.rs`, `news_sitemap.rs`, and
9//! `sitemap.rs` plugins each used to parse dates independently, and
10//! only understood RFC 2822. Front matter written as `July 1, 2026`
11//! or `2026-07-01` fell through and produced the upstream
12//! `'day' component could not be parsed` warning spam. This module
13//! is the single parsing chain they all share.
14//!
15//! [`parse_flexible_date`] accepts, in priority order:
16//!
17//! 1. **RFC 2822** — `Wed, 01 Jul 2026 07:07:07 +0000` (weekday
18//!    optional and *not* verified, matching the lenient behaviour the
19//!    plugins already relied on; seconds optional; named zones from
20//!    the RFC 2822 obsolete table accepted).
21//! 2. **Long form** — `July 1, 2026` and `1 July 2026` (full month
22//!    names or three-letter abbreviations; midnight UTC assumed).
23//! 3. **ISO 8601 date** — `2026-07-01` (midnight UTC assumed).
24//! 4. **ISO 8601 datetime** — `2026-07-01T07:07:07Z`, with `Z`,
25//!    `±hh:mm`, or `±hhmm` offsets (UTC assumed when absent).
26//!
27//! Everything is hand-rolled — month-name tables, leap-year aware
28//! day validation, timezone offset parsing — so no new dependency is
29//! introduced. Parsing is fully deterministic: no locale lookups and
30//! no system-time reads.
31//!
32//! The output side offers the exact shapes the plugins emit today:
33//! [`FlexibleDate::to_rfc2822`] for RSS `<pubDate>`,
34//! [`FlexibleDate::to_rfc3339`] / [`FlexibleDate::to_w3c_date`] for
35//! Atom `<updated>` and the news-sitemap `<news:publication_date>`,
36//! and [`FlexibleDate::to_iso_date`] for sitemap `<lastmod>`.
37
38use std::fmt;
39
40/// The formats attempted by [`parse_flexible_date`], in priority order.
41///
42/// Referenced by [`DateParseError`] so call sites can log exactly what
43/// was tried (plan §2 item 1.4: "log which field/format failed").
44///
45/// # Examples
46///
47/// ```rust
48/// use ssg::dates::ATTEMPTED_FORMATS;
49///
50/// assert_eq!(ATTEMPTED_FORMATS.len(), 4);
51/// assert!(ATTEMPTED_FORMATS[0].starts_with("RFC 2822"));
52/// ```
53pub const ATTEMPTED_FORMATS: [&str; 4] = [
54    "RFC 2822 (e.g. `Wed, 01 Jul 2026 07:07:07 +0000`)",
55    "long form (e.g. `July 1, 2026` or `1 July 2026`)",
56    "ISO 8601 date (e.g. `2026-07-01`)",
57    "ISO 8601 datetime (e.g. `2026-07-01T07:07:07Z`)",
58];
59
60/// Three-letter month abbreviations used for RFC 2822 output.
61const MONTH_ABBR: [&str; 12] = [
62    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct",
63    "Nov", "Dec",
64];
65
66/// Full month names (lowercase) used for input matching.
67const MONTH_FULL: [&str; 12] = [
68    "january",
69    "february",
70    "march",
71    "april",
72    "may",
73    "june",
74    "july",
75    "august",
76    "september",
77    "october",
78    "november",
79    "december",
80];
81
82/// The input format that [`parse_flexible_date`] matched.
83///
84/// Callers that must preserve their current output byte-for-byte
85/// (e.g. the RSS plugin passes RFC 2822 strings through verbatim,
86/// wrong weekday and all) can branch on this instead of re-formatting.
87///
88/// # Examples
89///
90/// ```rust
91/// use ssg::dates::{parse_flexible_date, DateFormat};
92///
93/// let dt = parse_flexible_date("2026-07-01").unwrap();
94/// assert_eq!(dt.format, DateFormat::IsoDate);
95///
96/// let dt = parse_flexible_date("July 1, 2026").unwrap();
97/// assert_eq!(dt.format, DateFormat::LongForm);
98/// ```
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
100pub enum DateFormat {
101    /// RFC 2822, e.g. `Wed, 01 Jul 2026 07:07:07 +0000`.
102    Rfc2822,
103    /// Long form, e.g. `July 1, 2026` or `1 July 2026`.
104    LongForm,
105    /// ISO 8601 calendar date, e.g. `2026-07-01`.
106    IsoDate,
107    /// ISO 8601 datetime, e.g. `2026-07-01T07:07:07Z`.
108    IsoDateTime,
109}
110
111/// A parsed calendar date-time with a fixed UTC offset.
112///
113/// This is deliberately a plain component struct (spec A4 allows "your
114/// own small struct or components") rather than a wrapper around a
115/// date crate — the project pins its dependency set and the feed
116/// plugins only need formatting, not arithmetic.
117///
118/// # Examples
119///
120/// ```rust
121/// use ssg::dates::parse_flexible_date;
122///
123/// let dt = parse_flexible_date("2026-07-01T07:07:07Z").unwrap();
124/// assert_eq!((dt.year, dt.month, dt.day), (2026, 7, 1));
125/// assert_eq!((dt.hour, dt.minute, dt.second), (7, 7, 7));
126/// assert_eq!(dt.offset_minutes, 0);
127/// ```
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
129pub struct FlexibleDate {
130    /// Calendar year (1–9999).
131    pub year: i32,
132    /// Calendar month (1–12).
133    pub month: u8,
134    /// Day of month (1–31, leap-year validated at parse time).
135    pub day: u8,
136    /// Hour (0–23).
137    pub hour: u8,
138    /// Minute (0–59).
139    pub minute: u8,
140    /// Second (0–59).
141    pub second: u8,
142    /// Offset from UTC in minutes (e.g. `+0530` is `330`).
143    pub offset_minutes: i32,
144    /// Which input format matched during parsing.
145    pub format: DateFormat,
146}
147
148impl FlexibleDate {
149    /// Format as RFC 2822 for RSS `<pubDate>` /
150    /// `<lastBuildDate>`: `Wed, 01 Jul 2026 07:07:07 +0000`.
151    ///
152    /// The weekday is *computed* (Sakamoto's algorithm), so an input
153    /// carrying a wrong weekday name is corrected on the way out.
154    ///
155    /// # Examples
156    ///
157    /// ```rust
158    /// use ssg::dates::parse_flexible_date;
159    ///
160    /// // "Mon" is wrong — 2026-07-01 is a Wednesday; output corrects it.
161    /// let dt = parse_flexible_date("Mon, 01 Jul 2026 07:07:07 +0000").unwrap();
162    /// assert_eq!(dt.to_rfc2822(), "Wed, 01 Jul 2026 07:07:07 +0000");
163    /// ```
164    #[must_use]
165    pub fn to_rfc2822(&self) -> String {
166        let month = MONTH_ABBR
167            .get(usize::from(self.month.saturating_sub(1)))
168            .copied()
169            .unwrap_or(MONTH_ABBR[0]);
170        format!(
171            "{}, {:02} {} {:04} {:02}:{:02}:{:02} {}",
172            self.weekday_abbr(),
173            self.day,
174            month,
175            self.year,
176            self.hour,
177            self.minute,
178            self.second,
179            self.offset_string("")
180        )
181    }
182
183    /// Format as RFC 3339 / ISO 8601 datetime for Atom
184    /// `<updated>`/`<published>` and JSON Feed `date_published`:
185    /// `2026-07-01T07:07:07+00:00`.
186    ///
187    /// UTC is rendered as `+00:00` (not `Z`) to match the output the
188    /// plugins have always produced — golden feed fixtures assert the
189    /// numeric form.
190    ///
191    /// # Examples
192    ///
193    /// ```rust
194    /// use ssg::dates::parse_flexible_date;
195    ///
196    /// let dt = parse_flexible_date("2026-07-01T07:07:07+05:30").unwrap();
197    /// assert_eq!(dt.to_rfc3339(), "2026-07-01T07:07:07+05:30");
198    ///
199    /// // Bare dates render as midnight UTC in the numeric form.
200    /// let dt = parse_flexible_date("2026-07-01").unwrap();
201    /// assert_eq!(dt.to_rfc3339(), "2026-07-01T00:00:00+00:00");
202    /// ```
203    #[must_use]
204    pub fn to_rfc3339(&self) -> String {
205        format!(
206            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}",
207            self.year,
208            self.month,
209            self.day,
210            self.hour,
211            self.minute,
212            self.second,
213            self.offset_string(":")
214        )
215    }
216
217    /// Format as a W3C datetime for the news sitemap
218    /// `<news:publication_date>`. Identical to [`Self::to_rfc3339`]
219    /// (the W3C datetime profile of ISO 8601 is what Google News
220    /// accepts); provided under its spec name so call sites read like
221    /// the sitemap spec.
222    ///
223    /// # Examples
224    ///
225    /// ```rust
226    /// use ssg::dates::parse_flexible_date;
227    ///
228    /// let dt = parse_flexible_date("July 1, 2026").unwrap();
229    /// assert_eq!(dt.to_w3c_date(), dt.to_rfc3339());
230    /// assert_eq!(dt.to_w3c_date(), "2026-07-01T00:00:00+00:00");
231    /// ```
232    #[must_use]
233    pub fn to_w3c_date(&self) -> String {
234        self.to_rfc3339()
235    }
236
237    /// Format as a bare ISO 8601 calendar date for sitemap
238    /// `<lastmod>`: `2026-07-01`.
239    ///
240    /// # Examples
241    ///
242    /// ```rust
243    /// use ssg::dates::parse_flexible_date;
244    ///
245    /// let dt = parse_flexible_date("Wed, 01 Jul 2026 07:07:07 +0000").unwrap();
246    /// assert_eq!(dt.to_iso_date(), "2026-07-01");
247    /// ```
248    #[must_use]
249    pub fn to_iso_date(&self) -> String {
250        format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
251    }
252
253    /// Three-letter weekday abbreviation via Sakamoto's algorithm.
254    fn weekday_abbr(&self) -> &'static str {
255        const OFFSETS: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
256        const NAMES: [&str; 7] =
257            ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
258        let mut y = i64::from(self.year);
259        if self.month < 3 {
260            y -= 1;
261        }
262        let month_idx = usize::from(self.month.saturating_sub(1)) % 12;
263        let idx = (y + y.div_euclid(4) - y.div_euclid(100)
264            + y.div_euclid(400)
265            + OFFSETS[month_idx]
266            + i64::from(self.day))
267        .rem_euclid(7) as usize;
268        NAMES[idx]
269    }
270
271    /// Render the UTC offset with the given hour/minute separator
272    /// (`""` for RFC 2822 `+0000`, `":"` for RFC 3339 `+00:00`).
273    fn offset_string(&self, sep: &str) -> String {
274        let sign = if self.offset_minutes < 0 { '-' } else { '+' };
275        let abs = self.offset_minutes.unsigned_abs();
276        format!("{sign}{:02}{sep}{:02}", abs / 60, abs % 60)
277    }
278}
279
280/// Error for input that matched none of the supported date formats.
281///
282/// Returned by [`parse_flexible_date`]. Its `Display` output names
283/// every attempted format so call-site `log::warn!` lines say exactly
284/// what was tried and on which value (plan §2 item 1.4).
285///
286/// # Examples
287///
288/// ```rust
289/// use ssg::dates::parse_flexible_date;
290///
291/// let err = parse_flexible_date("not a date").unwrap_err();
292/// let msg = err.to_string();
293/// assert!(msg.contains("not a date"));
294/// assert!(msg.contains("attempted formats"));
295/// ```
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct DateParseError {
298    /// The rejected input (truncated to 64 chars for log hygiene).
299    input: String,
300}
301
302impl DateParseError {
303    /// Build an error for the given rejected input.
304    fn new(input: &str) -> Self {
305        let mut owned: String = input.chars().take(64).collect();
306        if owned.len() < input.len() {
307            owned.push('…');
308        }
309        Self { input: owned }
310    }
311
312    /// The formats that were attempted, in priority order.
313    ///
314    /// # Examples
315    ///
316    /// ```rust
317    /// use ssg::dates::{parse_flexible_date, ATTEMPTED_FORMATS};
318    ///
319    /// let err = parse_flexible_date("nope").unwrap_err();
320    /// assert_eq!(err.attempted_formats(), &ATTEMPTED_FORMATS);
321    /// ```
322    #[must_use]
323    pub const fn attempted_formats(&self) -> &'static [&'static str] {
324        &ATTEMPTED_FORMATS
325    }
326
327    /// The rejected input value (possibly truncated).
328    ///
329    /// # Examples
330    ///
331    /// ```rust
332    /// use ssg::dates::parse_flexible_date;
333    ///
334    /// let err = parse_flexible_date("mystery value").unwrap_err();
335    /// assert_eq!(err.input(), "mystery value");
336    /// ```
337    #[must_use]
338    pub fn input(&self) -> &str {
339        &self.input
340    }
341}
342
343impl fmt::Display for DateParseError {
344    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345        write!(
346            f,
347            "could not parse date {:?}; attempted formats: {}",
348            self.input,
349            ATTEMPTED_FORMATS.join(", ")
350        )
351    }
352}
353
354impl std::error::Error for DateParseError {}
355
356/// `true` for Gregorian leap years (divisible by 4, except centuries
357/// not divisible by 400).
358///
359/// # Examples
360///
361/// ```rust
362/// use ssg::dates::is_leap_year;
363///
364/// assert!(is_leap_year(2024));
365/// assert!(is_leap_year(2000)); // century divisible by 400
366/// assert!(!is_leap_year(1900)); // century not divisible by 400
367/// assert!(!is_leap_year(2026));
368/// ```
369#[must_use]
370pub const fn is_leap_year(year: i32) -> bool {
371    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
372}
373
374/// Number of days in the given month of the given year (leap-year
375/// aware). Returns 0 for an out-of-range month.
376///
377/// # Examples
378///
379/// ```rust
380/// use ssg::dates::days_in_month;
381///
382/// assert_eq!(days_in_month(2026, 7), 31);
383/// assert_eq!(days_in_month(2024, 2), 29); // leap February
384/// assert_eq!(days_in_month(2026, 2), 28);
385/// assert_eq!(days_in_month(2026, 13), 0); // out of range
386/// ```
387#[must_use]
388pub const fn days_in_month(year: i32, month: u8) -> u8 {
389    match month {
390        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
391        4 | 6 | 9 | 11 => 30,
392        2 => {
393            if is_leap_year(year) {
394                29
395            } else {
396                28
397            }
398        }
399        _ => 0,
400    }
401}
402
403/// Parse a date string, trying RFC 2822, long form, ISO 8601 date,
404/// then ISO 8601 datetime (spec A4 priority order).
405///
406/// Deterministic and locale-independent: month names come from fixed
407/// English tables and no system time is read.
408///
409/// # Examples
410///
411/// ```rust
412/// use ssg::dates::parse_flexible_date;
413///
414/// let dt = parse_flexible_date("Wed, 01 Jul 2026 07:07:07 +0000")
415///     .expect("RFC 2822 parses");
416/// assert_eq!(dt.to_rfc3339(), "2026-07-01T07:07:07+00:00");
417///
418/// let dt = parse_flexible_date("July 1, 2026").expect("long form parses");
419/// assert_eq!(dt.to_rfc2822(), "Wed, 01 Jul 2026 00:00:00 +0000");
420///
421/// let dt = parse_flexible_date("2026-07-01").expect("ISO date parses");
422/// assert_eq!(dt.to_iso_date(), "2026-07-01");
423///
424/// assert!(parse_flexible_date("not a date").is_err());
425/// ```
426pub fn parse_flexible_date(
427    input: &str,
428) -> Result<FlexibleDate, DateParseError> {
429    let trimmed = input.trim();
430    if trimmed.is_empty() {
431        return Err(DateParseError::new(input));
432    }
433    parse_rfc2822(trimmed)
434        .or_else(|| parse_long_form(trimmed))
435        .or_else(|| parse_iso8601(trimmed))
436        .ok_or_else(|| DateParseError::new(input))
437}
438
439/// Validate components and assemble a [`FlexibleDate`].
440#[allow(clippy::too_many_arguments)]
441fn build_date(
442    year: i32,
443    month: u8,
444    day: u8,
445    hour: u8,
446    minute: u8,
447    second: u8,
448    offset_minutes: i32,
449    format: DateFormat,
450) -> Option<FlexibleDate> {
451    if !(1..=9999).contains(&year) {
452        return None;
453    }
454    if month == 0 || month > 12 {
455        return None;
456    }
457    if day == 0 || day > days_in_month(year, month) {
458        return None;
459    }
460    if hour > 23 || minute > 59 || second > 59 {
461        return None;
462    }
463    Some(FlexibleDate {
464        year,
465        month,
466        day,
467        hour,
468        minute,
469        second,
470        offset_minutes,
471        format,
472    })
473}
474
475/// Map a month name (`Jul`, `July`, case-insensitive) to 1–12.
476fn month_from_name(name: &str) -> Option<u8> {
477    let lower = name.to_ascii_lowercase();
478    MONTH_FULL
479        .iter()
480        .position(|full| {
481            *full == lower || (lower.len() == 3 && full.starts_with(&lower))
482        })
483        .map(|idx| idx as u8 + 1)
484}
485
486/// Parse a `hh:mm[:ss]` time token. Seconds default to 0 (RFC 2822
487/// permits omitting them).
488fn parse_hms(token: &str) -> Option<(u8, u8, u8)> {
489    let mut parts = token.split(':');
490    // `split` always yields a first item, so the empty-string fallback
491    // is purely defensive: "" fails the numeric parse just below.
492    let hour: u8 = parts.next().unwrap_or_default().parse().ok()?;
493    let minute: u8 = parts.next()?.parse().ok()?;
494    let second: u8 = match parts.next() {
495        Some(sec) => sec.parse().ok()?,
496        None => 0,
497    };
498    if parts.next().is_some() {
499        return None;
500    }
501    Some((hour, minute, second))
502}
503
504/// Parse a timezone token into minutes east of UTC. Accepts `Z`,
505/// `±hhmm`, `±hh:mm`, and the RFC 2822 obsolete named zones.
506fn parse_zone(token: &str) -> Option<i32> {
507    match token.to_ascii_uppercase().as_str() {
508        "Z" | "UT" | "GMT" | "UTC" => return Some(0),
509        // RFC 2822 §4.3 obsolete zone names.
510        "EST" => return Some(-5 * 60),
511        "EDT" => return Some(-4 * 60),
512        "CST" => return Some(-6 * 60),
513        "CDT" => return Some(-5 * 60),
514        "MST" => return Some(-7 * 60),
515        "MDT" => return Some(-6 * 60),
516        "PST" => return Some(-8 * 60),
517        "PDT" => return Some(-7 * 60),
518        _ => {}
519    }
520    let bytes = token.as_bytes();
521    let sign = match bytes.first()? {
522        b'+' => 1,
523        b'-' => -1,
524        _ => return None,
525    };
526    let digits = &bytes[1..];
527    let (hh, mm) = match digits {
528        // ±hhmm (RFC 2822 / compact RFC 3339)
529        [h1, h2, m1, m2] => (ascii_pair(*h1, *h2)?, ascii_pair(*m1, *m2)?),
530        // ±hh:mm (RFC 3339)
531        [h1, h2, b':', m1, m2] => {
532            (ascii_pair(*h1, *h2)?, ascii_pair(*m1, *m2)?)
533        }
534        _ => return None,
535    };
536    if hh > 23 || mm > 59 {
537        return None;
538    }
539    Some(sign * (i32::from(hh) * 60 + i32::from(mm)))
540}
541
542/// Combine two ASCII digit bytes into a number.
543const fn ascii_pair(first: u8, second: u8) -> Option<u8> {
544    if first.is_ascii_digit() && second.is_ascii_digit() {
545        Some((first - b'0') * 10 + (second - b'0'))
546    } else {
547        None
548    }
549}
550
551/// Parse a run of ASCII digit bytes into a u32.
552fn ascii_number(bytes: &[u8]) -> Option<u32> {
553    if bytes.is_empty() {
554        return None;
555    }
556    bytes.iter().try_fold(0u32, |acc, b| {
557        if b.is_ascii_digit() {
558            acc.checked_mul(10)?.checked_add(u32::from(b - b'0'))
559        } else {
560            None
561        }
562    })
563}
564
565/// Lenient RFC 2822: `[Www, ]DD Mon YYYY hh:mm[:ss] [zone]`.
566///
567/// The weekday name is stripped without verification — generated
568/// feeds routinely carry the wrong weekday and the previous per-plugin
569/// parser already tolerated that.
570fn parse_rfc2822(input: &str) -> Option<FlexibleDate> {
571    // Strip an optional "Www," weekday prefix (letters only, so the
572    // comma in long-form "July 1, 2026" never matches).
573    let rest = match input.split_once(',') {
574        Some((weekday, tail))
575            if !weekday.is_empty()
576                && weekday.len() <= 9
577                && weekday.chars().all(|c| c.is_ascii_alphabetic()) =>
578        {
579            tail.trim_start()
580        }
581        _ => input,
582    };
583    let tokens: Vec<&str> = rest.split_whitespace().collect();
584    if !(4..=5).contains(&tokens.len()) {
585        return None;
586    }
587    let day: u8 = tokens[0].parse().ok()?;
588    let month = month_from_name(tokens[1])?;
589    if tokens[2].len() != 4 {
590        return None;
591    }
592    let year = ascii_number(tokens[2].as_bytes())? as i32;
593    let (hour, minute, second) = parse_hms(tokens[3])?;
594    let offset = match tokens.get(4) {
595        Some(zone) => parse_zone(zone)?,
596        None => 0,
597    };
598    build_date(
599        year,
600        month,
601        day,
602        hour,
603        minute,
604        second,
605        offset,
606        DateFormat::Rfc2822,
607    )
608}
609
610/// Long form: `Month D[,] YYYY` or `D Month[,] YYYY`. Midnight UTC.
611fn parse_long_form(input: &str) -> Option<FlexibleDate> {
612    let cleaned = input.replace(',', " ");
613    let tokens: Vec<&str> = cleaned.split_whitespace().collect();
614    if tokens.len() != 3 {
615        return None;
616    }
617    // "July 1, 2026" (month first) or "1 July 2026" (day first); `None` if
618    // neither leading token names a month.
619    let (month, day_token) = month_from_name(tokens[0])
620        .map(|m| (m, tokens[1]))
621        .or_else(|| month_from_name(tokens[1]).map(|m| (m, tokens[0])))?;
622    if day_token.len() > 2 {
623        return None;
624    }
625    let day: u8 = day_token.parse().ok()?;
626    if tokens[2].len() != 4 {
627        return None;
628    }
629    let year = ascii_number(tokens[2].as_bytes())? as i32;
630    build_date(year, month, day, 0, 0, 0, 0, DateFormat::LongForm)
631}
632
633/// ISO 8601: `YYYY-MM-DD` alone, or followed by
634/// `[T ]hh:mm[:ss][.frac][Z|±hh:mm|±hhmm]`.
635fn parse_iso8601(input: &str) -> Option<FlexibleDate> {
636    let bytes = input.as_bytes();
637    if bytes.len() < 10 || bytes[4] != b'-' || bytes[7] != b'-' {
638        return None;
639    }
640    let year = ascii_number(&bytes[0..4])? as i32;
641    let month = ascii_number(&bytes[5..7])? as u8;
642    let day = ascii_number(&bytes[8..10])? as u8;
643    if bytes.len() == 10 {
644        return build_date(year, month, day, 0, 0, 0, 0, DateFormat::IsoDate);
645    }
646    if !matches!(bytes[10], b'T' | b't' | b' ') {
647        return None;
648    }
649    // `bytes[10]` is ASCII (checked above), so byte 11 is always a
650    // char boundary; the fallback is purely defensive and produces an
651    // empty clock that fails `parse_hms` below.
652    let rest = input.get(11..).unwrap_or("");
653    // The zone (if any) starts at the first Z/z/+/- after the time.
654    // `find` returns a char-boundary index, so `split_at` cannot panic.
655    let (time_part, zone_part) = match rest.find(['Z', 'z', '+', '-']) {
656        Some(pos) => rest.split_at(pos),
657        None => (rest, ""),
658    };
659    // Split off (and validate, but ignore) fractional seconds.
660    let clock = match time_part.split_once('.') {
661        Some((clock, frac)) => {
662            if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
663                return None;
664            }
665            clock
666        }
667        None => time_part,
668    };
669    let (hour, minute, second) = parse_hms(clock)?;
670    let offset = if zone_part.is_empty() {
671        0
672    } else {
673        parse_zone(zone_part)?
674    };
675    build_date(
676        year,
677        month,
678        day,
679        hour,
680        minute,
681        second,
682        offset,
683        DateFormat::IsoDateTime,
684    )
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    // -----------------------------------------------------------------
692    // RFC 2822
693    // -----------------------------------------------------------------
694
695    #[test]
696    fn rfc2822_spec_example() {
697        let dt = parse_flexible_date("Wed, 01 Jul 2026 07:07:07 +0000")
698            .expect("spec A4 example parses");
699        assert_eq!(dt.format, DateFormat::Rfc2822);
700        assert_eq!(dt.to_rfc3339(), "2026-07-01T07:07:07+00:00");
701        assert_eq!(dt.to_rfc2822(), "Wed, 01 Jul 2026 07:07:07 +0000");
702        assert_eq!(dt.to_iso_date(), "2026-07-01");
703    }
704
705    #[test]
706    fn rfc2822_wrong_weekday_is_tolerated() {
707        // 2026-04-11 is a Saturday; feeds label it Thursday. The
708        // legacy per-plugin parser ignored the weekday — so do we.
709        let dt = parse_flexible_date("Thu, 11 Apr 2026 06:06:06 +0000")
710            .expect("wrong weekday still parses");
711        assert_eq!(dt.to_rfc3339(), "2026-04-11T06:06:06+00:00");
712        // ...and re-formatting computes the *correct* weekday.
713        assert_eq!(dt.to_rfc2822(), "Sat, 11 Apr 2026 06:06:06 +0000");
714    }
715
716    #[test]
717    fn rfc2822_no_weekday_no_zone() {
718        let dt = parse_flexible_date("11 Apr 2026 06:06:06")
719            .expect("weekday and zone are optional");
720        assert_eq!(dt.offset_minutes, 0);
721        assert_eq!(dt.to_rfc3339(), "2026-04-11T06:06:06+00:00");
722    }
723
724    #[test]
725    fn rfc2822_optional_seconds() {
726        let dt = parse_flexible_date("11 Apr 2026 06:06 +0000")
727            .expect("RFC 2822 seconds are optional");
728        assert_eq!(dt.second, 0);
729    }
730
731    #[test]
732    fn rfc2822_positive_hhmm_offset() {
733        let dt = parse_flexible_date("Fri, 25 Dec 2026 18:30:45 +0530")
734            .expect("+hhmm offset parses");
735        assert_eq!(dt.offset_minutes, 330);
736        assert_eq!(dt.to_rfc3339(), "2026-12-25T18:30:45+05:30");
737        assert_eq!(dt.to_rfc2822(), "Fri, 25 Dec 2026 18:30:45 +0530");
738    }
739
740    #[test]
741    fn rfc2822_negative_hhmm_offset() {
742        let dt = parse_flexible_date("Sat, 04 Jul 2026 09:15:00 -0700")
743            .expect("-hhmm offset parses");
744        assert_eq!(dt.offset_minutes, -420);
745        assert_eq!(dt.to_rfc3339(), "2026-07-04T09:15:00-07:00");
746    }
747
748    #[test]
749    fn rfc2822_named_zones() {
750        let gmt = parse_flexible_date("11 Apr 2026 06:06:06 GMT").unwrap();
751        assert_eq!(gmt.offset_minutes, 0);
752        let est = parse_flexible_date("11 Apr 2026 06:06:06 EST").unwrap();
753        assert_eq!(est.offset_minutes, -300);
754        assert!(parse_flexible_date("11 Apr 2026 06:06:06 XYZ").is_err());
755    }
756
757    #[test]
758    fn rfc2822_full_month_name_with_time() {
759        let dt = parse_flexible_date("1 July 2026 06:06:06 +0000")
760            .expect("full month name with time parses");
761        assert_eq!(dt.month, 7);
762        assert_eq!(dt.format, DateFormat::Rfc2822);
763    }
764
765    // -----------------------------------------------------------------
766    // Long form
767    // -----------------------------------------------------------------
768
769    #[test]
770    fn long_form_month_first_single_digit_day() {
771        let dt = parse_flexible_date("July 1, 2026")
772            .expect("spec A4 long form parses");
773        assert_eq!(dt.format, DateFormat::LongForm);
774        assert_eq!((dt.year, dt.month, dt.day), (2026, 7, 1));
775        assert_eq!(dt.to_rfc2822(), "Wed, 01 Jul 2026 00:00:00 +0000");
776        assert_eq!(dt.to_w3c_date(), "2026-07-01T00:00:00+00:00");
777    }
778
779    #[test]
780    fn long_form_day_first() {
781        let dt = parse_flexible_date("1 July 2026")
782            .expect("day-first long form parses");
783        assert_eq!((dt.year, dt.month, dt.day), (2026, 7, 1));
784    }
785
786    #[test]
787    fn long_form_case_insensitive_and_no_comma() {
788        let dt = parse_flexible_date("december 25 2026").unwrap();
789        assert_eq!((dt.month, dt.day), (12, 25));
790    }
791
792    #[test]
793    fn long_form_rejects_bad_month_and_day() {
794        assert!(parse_flexible_date("Juvember 1, 2026").is_err());
795        assert!(parse_flexible_date("July 32, 2026").is_err());
796        assert!(parse_flexible_date("July 0, 2026").is_err());
797    }
798
799    // -----------------------------------------------------------------
800    // ISO 8601
801    // -----------------------------------------------------------------
802
803    #[test]
804    fn iso_date_only() {
805        let dt = parse_flexible_date("2026-07-01").expect("ISO date parses");
806        assert_eq!(dt.format, DateFormat::IsoDate);
807        assert_eq!(dt.to_rfc3339(), "2026-07-01T00:00:00+00:00");
808        assert_eq!(dt.to_iso_date(), "2026-07-01");
809    }
810
811    #[test]
812    fn iso_datetime_zulu() {
813        let dt = parse_flexible_date("2026-07-01T07:07:07Z")
814            .expect("Z-suffixed datetime parses");
815        assert_eq!(dt.format, DateFormat::IsoDateTime);
816        assert_eq!(dt.to_rfc3339(), "2026-07-01T07:07:07+00:00");
817    }
818
819    #[test]
820    fn iso_datetime_with_colon_offset() {
821        let dt = parse_flexible_date("2026-07-01T07:07:07+05:30").unwrap();
822        assert_eq!(dt.offset_minutes, 330);
823        let dt = parse_flexible_date("2026-07-01T07:07:07-07:00").unwrap();
824        assert_eq!(dt.offset_minutes, -420);
825    }
826
827    #[test]
828    fn iso_datetime_with_compact_offset() {
829        let dt = parse_flexible_date("2026-07-01T07:07:07+0530").unwrap();
830        assert_eq!(dt.offset_minutes, 330);
831    }
832
833    #[test]
834    fn iso_datetime_fractional_seconds_ignored() {
835        let dt = parse_flexible_date("2026-07-01T07:07:07.123Z").unwrap();
836        assert_eq!(dt.second, 7);
837        assert!(parse_flexible_date("2026-07-01T07:07:07.abcZ").is_err());
838    }
839
840    #[test]
841    fn iso_datetime_without_offset_defaults_utc() {
842        let dt = parse_flexible_date("2026-07-01T07:07:07").unwrap();
843        assert_eq!(dt.offset_minutes, 0);
844    }
845
846    #[test]
847    fn iso_rejects_invalid_components() {
848        assert!(parse_flexible_date("2026-13-01").is_err());
849        assert!(parse_flexible_date("2026-00-01").is_err());
850        assert!(parse_flexible_date("2026-04-31").is_err());
851        assert!(parse_flexible_date("2026-07-01T24:00:00").is_err());
852        assert!(parse_flexible_date("2026-07-01T07:60:00").is_err());
853        assert!(parse_flexible_date("2026-07-01T07:07:07+2500").is_err());
854    }
855
856    // -----------------------------------------------------------------
857    // Leap years
858    // -----------------------------------------------------------------
859
860    #[test]
861    fn leap_day_validation() {
862        assert!(parse_flexible_date("2024-02-29").is_ok());
863        assert!(parse_flexible_date("2023-02-29").is_err());
864        // Century rules: 2000 is a leap year, 2100 is not.
865        assert!(parse_flexible_date("2000-02-29").is_ok());
866        assert!(parse_flexible_date("2100-02-29").is_err());
867        assert!(parse_flexible_date("Thu, 29 Feb 2024 12:00:00 +0000").is_ok());
868        assert!(parse_flexible_date("February 29, 2024").is_ok());
869        assert!(parse_flexible_date("February 29, 2023").is_err());
870    }
871
872    #[test]
873    fn days_in_month_table() {
874        assert_eq!(days_in_month(2026, 1), 31);
875        assert_eq!(days_in_month(2026, 4), 30);
876        assert_eq!(days_in_month(2024, 2), 29);
877        assert_eq!(days_in_month(2025, 2), 28);
878        assert_eq!(days_in_month(2026, 13), 0);
879        assert!(is_leap_year(2000));
880        assert!(!is_leap_year(1900));
881    }
882
883    // -----------------------------------------------------------------
884    // Garbage and error reporting
885    // -----------------------------------------------------------------
886
887    #[test]
888    fn garbage_input_is_err() {
889        for garbage in [
890            "",
891            "   ",
892            "not a date",
893            "yesterday",
894            "13/01/2026",
895            "2026",
896            "--",
897        ] {
898            assert!(
899                parse_flexible_date(garbage).is_err(),
900                "{garbage:?} should not parse"
901            );
902        }
903    }
904
905    #[test]
906    fn error_names_all_attempted_formats() {
907        let err = parse_flexible_date("not a date").unwrap_err();
908        assert_eq!(err.input(), "not a date");
909        assert_eq!(err.attempted_formats().len(), 4);
910        let msg = err.to_string();
911        assert!(msg.contains("RFC 2822"), "message names RFC 2822: {msg}");
912        assert!(msg.contains("long form"), "message names long form: {msg}");
913        assert!(
914            msg.contains("ISO 8601 date"),
915            "message names ISO date: {msg}"
916        );
917        assert!(
918            msg.contains("ISO 8601 datetime"),
919            "message names ISO datetime: {msg}"
920        );
921    }
922
923    #[test]
924    fn error_truncates_long_input() {
925        let long = "x".repeat(200);
926        let err = parse_flexible_date(&long).unwrap_err();
927        assert!(err.input().chars().count() <= 65);
928        assert!(err.input().ends_with('…'));
929    }
930
931    // -----------------------------------------------------------------
932    // Formatting details
933    // -----------------------------------------------------------------
934
935    #[test]
936    fn weekday_computation_across_calendar() {
937        // Known anchors.
938        let cases = [
939            ("2026-07-01", "Wed"),
940            ("2024-02-29", "Thu"),
941            ("2000-01-01", "Sat"),
942            ("1990-01-01", "Mon"),
943            ("2100-12-31", "Fri"),
944        ];
945        for (iso, weekday) in cases {
946            let dt = parse_flexible_date(iso).unwrap();
947            let rendered = dt.to_rfc2822();
948            assert!(
949                rendered.starts_with(weekday),
950                "{iso} should be {weekday}, got {rendered}"
951            );
952        }
953    }
954
955    #[test]
956    fn rfc2822_round_trips_through_itself() {
957        let dt = parse_flexible_date("2026-07-01T07:07:07+05:30").unwrap();
958        let reparsed = parse_flexible_date(&dt.to_rfc2822()).unwrap();
959        assert_eq!(dt.to_rfc3339(), reparsed.to_rfc3339());
960    }
961
962    // -----------------------------------------------------------------
963    // build_date — component validation
964    // -----------------------------------------------------------------
965
966    #[test]
967    fn build_date_rejects_out_of_range_year() {
968        // Year 0 fails the `(1..=9999)` guard via the public parser.
969        assert!(parse_flexible_date("0000-01-01").is_err());
970    }
971
972    // -----------------------------------------------------------------
973    // parse_hms — malformed clock tokens
974    // -----------------------------------------------------------------
975
976    #[test]
977    fn parse_hms_rejects_malformed_tokens() {
978        // Non-numeric hour.
979        assert_eq!(parse_hms("xx:30"), None);
980        // Missing minute component entirely.
981        assert_eq!(parse_hms("12"), None);
982        // Non-numeric minute.
983        assert_eq!(parse_hms("12:xx"), None);
984        // Non-numeric second.
985        assert_eq!(parse_hms("12:30:xx"), None);
986        // Too many components.
987        assert_eq!(parse_hms("12:30:45:59"), None);
988        // Empty token: defensive first-subtag fallback parses "".
989        assert_eq!(parse_hms(""), None);
990    }
991
992    // -----------------------------------------------------------------
993    // parse_zone — named zones and malformed offsets
994    // -----------------------------------------------------------------
995
996    #[test]
997    fn parse_zone_maps_every_rfc2822_named_zone() {
998        let cases = [
999            ("EST", -5 * 60),
1000            ("EDT", -4 * 60),
1001            ("CST", -6 * 60),
1002            ("CDT", -5 * 60),
1003            ("MST", -7 * 60),
1004            ("MDT", -6 * 60),
1005            ("PST", -8 * 60),
1006            ("PDT", -7 * 60),
1007        ];
1008        for (name, minutes) in cases {
1009            assert_eq!(parse_zone(name), Some(minutes), "{name}");
1010        }
1011    }
1012
1013    #[test]
1014    fn parse_zone_rejects_malformed_offsets() {
1015        // Empty token: no first byte.
1016        assert_eq!(parse_zone(""), None);
1017        // No sign byte.
1018        assert_eq!(parse_zone("0500"), None);
1019        // ±hhmm with a non-digit hour pair.
1020        assert_eq!(parse_zone("+aa30"), None);
1021        // ±hhmm with a non-digit minute pair.
1022        assert_eq!(parse_zone("+12a0"), None);
1023        // ±hh:mm with a non-digit hour pair.
1024        assert_eq!(parse_zone("+aa:30"), None);
1025        // ±hh:mm with a non-digit minute pair.
1026        assert_eq!(parse_zone("+12:a0"), None);
1027        // Wrong digit count.
1028        assert_eq!(parse_zone("+123"), None);
1029        // Out-of-range values.
1030        assert_eq!(parse_zone("+2460"), None);
1031    }
1032
1033    // -----------------------------------------------------------------
1034    // ascii_number — emptiness, overflow, and non-digits
1035    // -----------------------------------------------------------------
1036
1037    #[test]
1038    fn ascii_number_rejects_empty_overflow_and_non_digits() {
1039        assert_eq!(ascii_number(b""), None);
1040        // 11 nines overflows u32 via `checked_mul`.
1041        assert_eq!(ascii_number(b"99999999999"), None);
1042        assert_eq!(ascii_number(b"12a4"), None);
1043        assert_eq!(ascii_number(b"2026"), Some(2026));
1044    }
1045
1046    // -----------------------------------------------------------------
1047    // parse_rfc2822 — per-token rejection paths
1048    // -----------------------------------------------------------------
1049
1050    #[test]
1051    fn rfc2822_rejects_each_malformed_token() {
1052        // Day not numeric.
1053        assert!(parse_flexible_date("aa Jul 2026 12:00").is_err());
1054        // Month name unknown.
1055        assert!(parse_flexible_date("01 Foo 2026 12:00").is_err());
1056        // Year not 4 digits.
1057        assert!(parse_flexible_date("01 Jul 26 12:00").is_err());
1058        // Year contains a non-digit.
1059        assert!(parse_flexible_date("01 Jul 2o26 12:00").is_err());
1060        // Clock malformed.
1061        assert!(parse_flexible_date("01 Jul 2026 xx:00").is_err());
1062    }
1063
1064    // -----------------------------------------------------------------
1065    // parse_long_form — per-token rejection paths
1066    // -----------------------------------------------------------------
1067
1068    #[test]
1069    fn long_form_rejects_each_malformed_token() {
1070        // Day token longer than 2 chars.
1071        assert!(parse_flexible_date("July 123 2026").is_err());
1072        // Day token not numeric.
1073        assert!(parse_flexible_date("July aa 2026").is_err());
1074        // Year not 4 digits.
1075        assert!(parse_flexible_date("July 1 26").is_err());
1076        // Year contains a non-digit.
1077        assert!(parse_flexible_date("July 1 2o26").is_err());
1078    }
1079
1080    // -----------------------------------------------------------------
1081    // parse_iso8601 — per-component rejection paths
1082    // -----------------------------------------------------------------
1083
1084    #[test]
1085    fn iso8601_rejects_each_malformed_component() {
1086        // Year, month, and day with non-digits.
1087        assert!(parse_flexible_date("2o26-07-01").is_err());
1088        assert!(parse_flexible_date("2026-o7-01").is_err());
1089        assert!(parse_flexible_date("2026-07-o1").is_err());
1090        // Separator after the date is not T/t/space.
1091        assert!(parse_flexible_date("2026-07-01x12:00").is_err());
1092        // Malformed clock in the datetime form.
1093        assert!(parse_flexible_date("2026-07-01T1a:00").is_err());
1094    }
1095}