Skip to main content

arch_toolkit/news/
date.rs

1//! Feed date normalization utilities.
2
3/// What: Normalize a feed date string to `YYYY-MM-DD`.
4///
5/// Inputs:
6/// - `raw`: Date string in RFC 2822 (RSS), RFC 3339/ISO 8601 (Atom), or
7///   already-normalized `YYYY-MM-DD` form.
8///
9/// Output:
10/// - `YYYY-MM-DD` string on successful parse; otherwise the input with time
11///   and timezone components stripped best-effort.
12///
13/// Details:
14/// - Ported from Pacsea's `strip_time_and_tz()`.
15/// - Normalized dates sort lexicographically, which the cutoff-date filtering
16///   in the parsers relies on.
17///
18/// # Example
19///
20/// ```
21/// use arch_toolkit::news::normalize_feed_date;
22///
23/// assert_eq!(normalize_feed_date("Thu, 21 Aug 2025 12:34:56 +0000"), "2025-08-21");
24/// assert_eq!(normalize_feed_date("2025-12-07T14:00:00Z"), "2025-12-07");
25/// assert_eq!(normalize_feed_date("2025-12-07"), "2025-12-07");
26/// ```
27#[must_use]
28pub fn normalize_feed_date(raw: &str) -> String {
29    let trimmed = raw.trim();
30
31    // RFC 2822 (RSS pubDate: "Thu, 21 Aug 2025 12:34:56 +0000")
32    if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(trimmed) {
33        return dt.format("%Y-%m-%d").to_string();
34    }
35
36    // RFC 3339 (Atom updated/published: "2025-12-07T14:00:00Z")
37    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(trimmed) {
38        return dt.format("%Y-%m-%d").to_string();
39    }
40
41    // ISO 8601 without timezone
42    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
43        return dt.format("%Y-%m-%d").to_string();
44    }
45
46    // Already date-only
47    if chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d").is_ok() {
48        return trimmed.to_string();
49    }
50
51    // Partial RFC 2822 without timezone ("Thu, 21 Aug 2025" / "... 12:34:56")
52    if let Some(date) = parse_partial_rfc2822(trimmed) {
53        return date;
54    }
55
56    // Last resort: strip trailing "+ZZZZ" timezone and "HH:MM:SS" time manually.
57    let mut t = trimmed.to_string();
58    if let Some(pos) = t.rfind(" +") {
59        t.truncate(pos);
60        t = t.trim_end().to_string();
61    }
62    if t.len() >= 9 {
63        let n = t.len();
64        let time_part = &t[n - 8..n];
65        let looks_time = time_part.chars().enumerate().all(|(i, c)| match i {
66            2 | 5 => c == ':',
67            _ => c.is_ascii_digit(),
68        });
69        if looks_time && t.as_bytes()[n - 9] == b' ' {
70            t.truncate(n - 9);
71        }
72    }
73    t.trim_end().to_string()
74}
75
76/// What: Parse an RFC 2822-like date lacking a timezone into `YYYY-MM-DD`.
77///
78/// Inputs:
79/// - `s`: String like "Thu, 21 Aug 2025" or "Thu, 21 Aug 2025 12:34:56".
80///
81/// Output:
82/// - `Some("YYYY-MM-DD")` on success, `None` otherwise.
83///
84/// Details:
85/// - Handles feeds that omit the timezone, which `parse_from_rfc2822` rejects.
86fn parse_partial_rfc2822(s: &str) -> Option<String> {
87    let without_weekday = s.split_once(',').map_or(s, |(_, rest)| rest).trim();
88    for fmt in ["%d %b %Y %H:%M:%S", "%d %b %Y"] {
89        if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(without_weekday, fmt) {
90            return Some(dt.format("%Y-%m-%d").to_string());
91        }
92        if let Ok(d) = chrono::NaiveDate::parse_from_str(without_weekday, fmt) {
93            return Some(d.format("%Y-%m-%d").to_string());
94        }
95    }
96    None
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    /// What: Verify all supported date formats normalize to `YYYY-MM-DD`.
105    ///
106    /// Inputs:
107    /// - RFC 2822, RFC 3339, ISO 8601, date-only, and partial formats.
108    ///
109    /// Output:
110    /// - Consistent `YYYY-MM-DD` output for each.
111    ///
112    /// Details:
113    /// - Mirrors Pacsea's `strip_time_and_tz()` behavior.
114    fn all_formats() {
115        assert_eq!(
116            normalize_feed_date("Thu, 21 Aug 2025 12:34:56 +0000"),
117            "2025-08-21"
118        );
119        assert_eq!(normalize_feed_date("2025-12-07T14:00:00Z"), "2025-12-07");
120        assert_eq!(normalize_feed_date("2025-12-07T14:00:00"), "2025-12-07");
121        assert_eq!(normalize_feed_date("2025-12-07"), "2025-12-07");
122        assert_eq!(normalize_feed_date("Thu, 21 Aug 2025"), "2025-08-21");
123        assert_eq!(
124            normalize_feed_date("Thu, 21 Aug 2025 12:34:56"),
125            "2025-08-21"
126        );
127    }
128
129    #[test]
130    /// What: Verify unparseable input degrades gracefully.
131    ///
132    /// Inputs:
133    /// - Arbitrary text with trailing time/timezone patterns.
134    ///
135    /// Output:
136    /// - Time and timezone stripped; text otherwise preserved.
137    ///
138    /// Details:
139    /// - Matches Pacsea's manual-strip fallback for resilience.
140    fn fallback_stripping() {
141        assert_eq!(normalize_feed_date("someday 12:34:56 +0100"), "someday");
142        assert_eq!(normalize_feed_date("not a date"), "not a date");
143        assert_eq!(normalize_feed_date(""), "");
144    }
145}