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}