Skip to main content

hypermail/
date.rs

1use crate::error::{HypermailError, Result};
2use crate::i18n::I18n;
3use chrono::{DateTime, FixedOffset, NaiveDateTime, Offset, TimeZone, Utc};
4use std::sync::LazyLock;
5
6pub const MONTHS: &[&str] = &[
7    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
8];
9
10pub const DAYS: &[&str] = &["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
11
12/// Replace English day/month abbreviations in a formatted date string with localized equivalents
13/// using the locale JSON files.
14pub fn localize_date_str(date_str: &str, lang: &str) -> String {
15    if lang == "en" {
16        return date_str.to_string();
17    }
18    let i18n = I18n::new(lang);
19    let mut result = date_str.to_string();
20
21    // Replace day abbreviations
22    for &en_day in DAYS {
23        let localized = i18n.get(en_day);
24        if localized != en_day && result.contains(en_day) {
25            result = result.replacen(en_day, localized, 1);
26            break;
27        }
28    }
29
30    // Replace month abbreviations
31    for &en_month in MONTHS {
32        let localized = i18n.get(en_month);
33        if localized != en_month && result.contains(en_month) {
34            result = result.replacen(en_month, localized, 1);
35            break;
36        }
37    }
38
39    result
40}
41
42pub fn is_leap(y: i32) -> bool {
43    y > 1752 && (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0))
44}
45
46pub fn month_from_str(s: &str) -> Option<u32> {
47    MONTHS.iter().position(|&m| m.eq_ignore_ascii_case(s)).map(|p| (p + 1) as u32)
48}
49
50pub fn parse_rfc2822_date(s: &str) -> Result<i64> {
51    let s = s.trim();
52
53    let parsed = DateTime::parse_from_rfc2822(s);
54    if let Ok(dt) = parsed {
55        return Ok(dt.timestamp());
56    }
57
58    try_parse_flexible(s)
59}
60
61fn try_parse_flexible(s: &str) -> Result<i64> {
62    let s = s.trim().to_string();
63
64    if let Ok(dt) = DateTime::parse_from_rfc3339(&s) {
65        return Ok(dt.timestamp());
66    }
67
68    let cleaned = s.replace(" (", "(").replace(") ", ")").trim().to_string();
69
70    if let Ok(dt) = DateTime::parse_from_rfc2822(&cleaned) {
71        return Ok(dt.timestamp());
72    }
73
74    // Normalize non-standard timezone suffixes like "GMT+2", "GMT-5", "UTC+3"
75    // into offset form "+0200" that chrono can parse.
76    let normalized = normalize_nonstandard_tz(&cleaned);
77    if normalized != cleaned {
78        if let Ok(dt) = DateTime::parse_from_rfc2822(&normalized) {
79            return Ok(dt.timestamp());
80        }
81    }
82
83    if let Ok(naive) = NaiveDateTime::parse_from_str(&cleaned, "%a %b %d %H:%M:%S %Y") {
84        if let Some(local) = LocalFixOffset::local_to_fixed(naive) {
85            return Ok(local.timestamp());
86        }
87    }
88
89    if let Ok(naive) = chrono::NaiveDate::parse_from_str(&cleaned, "%Y-%m-%d")
90        .map(|d| d.and_hms_opt(0, 0, 0).unwrap())
91    {
92        if let Some(local) = LocalFixOffset::local_to_fixed(naive) {
93            return Ok(local.timestamp());
94        }
95    }
96
97    Err(HypermailError::DateParse(format!("Cannot parse date: {}", s)))
98}
99
100/// Convert trailing `GMT+N`, `GMT-N`, `UTC+N`, `UTC-N` (and fractional-hour forms like `GMT+5:30`)
101/// into an RFC 2822-compatible numeric offset (`+HHMM` / `-HHMM`).
102fn normalize_nonstandard_tz(s: &str) -> String {
103    // Match pattern: ...whitespace(GMT|UTC)(+|-)H or HH or H:MM or HH:MM at end
104    let re = LazyLock::force(&NONSTANDARD_TZ_RE);
105    if let Some(cap) = re.captures(s) {
106        let sign = &cap[1];
107        let hours_str = &cap[2];
108        let mins_str = cap.get(3).map(|m| m.as_str()).unwrap_or("00");
109        let hours: i32 = hours_str.parse().unwrap_or(0);
110        let mins: i32 = mins_str.parse().unwrap_or(0);
111        let offset = format!("{}{:02}{:02}", sign, hours, mins);
112        // Replace the matched GMT/UTC+N suffix with the numeric offset
113        let match_start = cap.get(0).unwrap().start();
114        // Keep everything before the GMT/UTC sign, append the offset
115        let base = &s[..match_start];
116        return format!("{}{}", base.trim_end(), offset.replace("+", " +").replace("-", " -"));
117    }
118    s.to_string()
119}
120
121static NONSTANDARD_TZ_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
122    regex::Regex::new(r"(?i)(?:GMT|UTC)([+-])(\d{1,2})(?::(\d{2}))?$").unwrap()
123});
124
125struct LocalFixOffset;
126
127impl LocalFixOffset {
128    fn local_to_fixed(naive: NaiveDateTime) -> Option<DateTime<FixedOffset>> {
129        let local_offset = chrono::Local::now().offset().fix();
130        match FixedOffset::east_opt(local_offset.local_minus_utc()) {
131            Some(fixed) => fixed.from_local_datetime(&naive).single(),
132            None => None,
133        }
134    }
135}
136
137/// Clamp a Unix timestamp to chrono's valid range to prevent panics.
138/// chrono supports roughly -262144-01-01 to +262143-12-31, but we clamp
139/// to a practical range: 0000-01-01 to 9999-12-31.
140fn clamp_timestamp(timestamp: i64) -> i64 {
141    const MIN_TS: i64 = -62167219200; // 0000-01-01T00:00:00Z
142    const MAX_TS: i64 = 253402300799; // 9999-12-31T23:59:59Z
143    timestamp.clamp(MIN_TS, MAX_TS)
144}
145
146pub fn get_date_str(
147    timestamp: i64,
148    fmt: Option<&str>,
149    gmtime: bool,
150    eurodate: bool,
151    isodate: bool,
152    lang: &str,
153) -> String {
154    let timestamp = clamp_timestamp(timestamp);
155    let dt: DateTime<FixedOffset> = if gmtime {
156        FixedOffset::east_opt(0).unwrap().from_utc_datetime(
157            &Utc.timestamp_opt(timestamp, 0).single().unwrap_or_default().naive_utc(),
158        )
159    } else {
160        match chrono::Local.timestamp_opt(timestamp, 0).single() {
161            Some(local) => {
162                let offset = *local.offset();
163                let naive = local.naive_local();
164                FixedOffset::east_opt(offset.local_minus_utc())
165                    .unwrap()
166                    .from_local_datetime(&naive)
167                    .single()
168                    .unwrap_or_else(|| {
169                        FixedOffset::east_opt(0)
170                            .unwrap()
171                            .from_utc_datetime(&naive.and_utc().naive_utc())
172                    })
173            },
174            None => {
175                // Fallback for timestamps that can't be resolved to local time
176                FixedOffset::east_opt(0).unwrap().from_utc_datetime(
177                    &Utc.timestamp_opt(timestamp, 0).single().unwrap_or_default().naive_utc(),
178                )
179            },
180        }
181    };
182
183    let raw = if let Some(format_str) = fmt {
184        dt.format(format_str).to_string()
185    } else if isodate {
186        if gmtime {
187            dt.format("%Y-%m-%d %H:%M:%SZ").to_string()
188        } else {
189            dt.format("%Y-%m-%d %H:%M:%S").to_string()
190        }
191    } else if eurodate {
192        dt.format("%a %d %b %Y %H:%M:%S %z").to_string()
193    } else {
194        dt.format("%a %b %d %Y %H:%M:%S %z").to_string()
195    };
196
197    localize_date_str(&raw, lang)
198}
199
200pub fn secs_to_iso(timestamp: i64) -> String {
201    let timestamp = clamp_timestamp(timestamp);
202    let dt = Utc.timestamp_opt(timestamp, 0).single().unwrap_or_default();
203    dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()
204}
205
206pub fn iso_to_secs(s: &str) -> Result<i64> {
207    let cleaned = s.trim();
208    if let Ok(dt) = DateTime::parse_from_rfc3339(cleaned) {
209        return Ok(dt.timestamp());
210    }
211    if let Ok(dt) = NaiveDateTime::parse_from_str(cleaned, "%Y-%m-%dT%H:%M:%S") {
212        return Ok(dt.and_utc().timestamp());
213    }
214    Err(HypermailError::DateParse(format!("Cannot parse ISO date: {}", s)))
215}
216
217pub fn get_timezone_str() -> String {
218    let offset = chrono::Local::now().offset().fix();
219    let total_secs = offset.local_minus_utc();
220    let hours = total_secs / 3600;
221    let mins = (total_secs.abs() / 60) % 60;
222    if total_secs >= 0 {
223        format!("+{:02}{:02}", hours, mins)
224    } else {
225        format!("-{:02}{:02}", -hours, mins)
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use chrono::Timelike;
233
234    #[test]
235    fn test_parse_rfc2822() {
236        let ts = parse_rfc2822_date("Mon, 15 Mar 2021 12:00:00 +0000").unwrap();
237        assert_eq!(ts, 1615809600);
238
239        let ts = parse_rfc2822_date("15 Mar 2021 12:00:00 +0000").unwrap();
240        assert_eq!(ts, 1615809600);
241
242        let ts = parse_rfc2822_date("Mon, 15 Mar 2021 12:00:00 GMT").unwrap();
243        assert_eq!(ts, 1615809600);
244    }
245
246    #[test]
247    fn test_parse_various_dates() {
248        assert!(parse_rfc2822_date("Thu, 01 Jan 1998 00:00:00 +0000").is_ok());
249        assert!(parse_rfc2822_date("1 Jan 1998 00:00:00 +0000").is_ok());
250        assert!(parse_rfc2822_date("1 Jan 1998 00:00:00 +0100 (CET)").is_ok());
251        assert!(parse_rfc2822_date("Tue, 1 Jul 2003 10:52:37 +1200").is_ok());
252    }
253
254    #[test]
255    fn test_date_roundtrip() {
256        let original = "Mon, 15 Mar 2021 12:00:00 +0000";
257        let ts = parse_rfc2822_date(original).unwrap();
258        let formatted = secs_to_iso(ts);
259        assert!(formatted.starts_with("2021-03-15T12:00:00"));
260    }
261
262    #[test]
263    fn test_date_str() {
264        let ts = 1615809600; // 2021-03-15 12:00:00 UTC
265                             // Default (US): month before day
266        let ds = get_date_str(ts, None, true, false, false, "en");
267        assert_eq!(ds, "Mon Mar 15 2021 12:00:00 +0000");
268    }
269
270    #[test]
271    fn test_isodate_str() {
272        let ts = 1615809600;
273        let ds = get_date_str(ts, None, true, false, true, "en");
274        assert!(ds.contains("2021-03-15"));
275    }
276
277    #[test]
278    fn test_iso_roundtrip() {
279        let ts = 1615809600;
280        let iso = secs_to_iso(ts);
281        let back = iso_to_secs(&iso).unwrap();
282        assert_eq!(ts, back);
283    }
284
285    #[test]
286    fn test_month_from_str() {
287        assert_eq!(month_from_str("Jan"), Some(1));
288        assert_eq!(month_from_str("jan"), Some(1));
289        assert_eq!(month_from_str("Dec"), Some(12));
290        assert_eq!(month_from_str("Foo"), None);
291    }
292
293    #[test]
294    fn test_invalid_date() {
295        assert!(parse_rfc2822_date("").is_err());
296        assert!(parse_rfc2822_date("not a date").is_err());
297    }
298
299    #[test]
300    fn test_localize_date_greek() {
301        let ts = 1615809600; // Mon Mar 15 2021
302        let ds = get_date_str(ts, None, true, false, false, "el");
303        assert!(ds.contains("Δευ"), "Expected Greek Monday abbreviation, got: {}", ds);
304        assert!(ds.contains("Μαρ"), "Expected Greek March abbreviation, got: {}", ds);
305    }
306
307    #[test]
308    fn test_localize_date_german() {
309        let ts = 1615809600;
310        let ds = get_date_str(ts, None, true, false, false, "de");
311        assert!(ds.contains("Mo"), "Expected German Monday abbreviation, got: {}", ds);
312        assert!(ds.contains("Mär"), "Expected German March abbreviation, got: {}", ds);
313    }
314
315    #[test]
316    fn test_localize_date_unknown_lang_falls_back() {
317        let ts = 1615809600;
318        let ds = get_date_str(ts, None, true, false, false, "xx");
319        assert!(ds.contains("Mon"), "Unknown lang should keep English, got: {}", ds);
320    }
321
322    #[test]
323    fn test_parse_gmt_plus_offset() {
324        // "GMT+2" style non-standard timezone — seen in old 1996 emails
325        let ts = parse_rfc2822_date("Mon, 12 Feb 1996 15:24:04 GMT+2");
326        assert!(ts.is_ok(), "GMT+2 should parse successfully, got: {:?}", ts);
327        let t = ts.unwrap();
328        // 1996-02-12 15:24:04 at +02:00 = 13:24:04 UTC
329        let dt = chrono::Utc.timestamp_opt(t, 0).unwrap();
330        assert_eq!(dt.hour(), 13, "Expected 13:24 UTC, got {:?}", dt);
331        assert_eq!(dt.minute(), 24);
332    }
333
334    #[test]
335    fn test_parse_gmt_minus_offset() {
336        let ts = parse_rfc2822_date("Wed, 01 May 1996 08:00:00 GMT-5");
337        assert!(ts.is_ok(), "GMT-5 should parse, got: {:?}", ts);
338        let t = ts.unwrap();
339        let dt = chrono::Utc.timestamp_opt(t, 0).unwrap();
340        assert_eq!(dt.hour(), 13); // 08:00 + 05:00 = 13:00 UTC
341    }
342
343    #[test]
344    fn test_parse_utc_plus_fractional_offset() {
345        let ts = parse_rfc2822_date("Thu, 01 Jan 1998 12:00:00 UTC+5:30");
346        assert!(ts.is_ok(), "UTC+5:30 should parse, got: {:?}", ts);
347        let t = ts.unwrap();
348        let dt = chrono::Utc.timestamp_opt(t, 0).unwrap();
349        assert_eq!(dt.hour(), 6); // 12:00 - 05:30 = 06:30 UTC
350        assert_eq!(dt.minute(), 30);
351    }
352
353    #[test]
354    fn test_is_leap_year() {
355        assert!(is_leap(2000));
356        assert!(is_leap(2004));
357        assert!(!is_leap(1900));
358        assert!(!is_leap(2001));
359    }
360
361    #[test]
362    fn test_is_leap_before_1752() {
363        // Gregorian calendar reform: years <= 1752 are always false
364        assert!(!is_leap(1600));
365        assert!(!is_leap(1752));
366    }
367
368    #[test]
369    fn test_get_timezone_str_format() {
370        let tz = get_timezone_str();
371        assert!(tz.starts_with('+') || tz.starts_with('-'), "Expected +/- prefix, got: {}", tz);
372        assert_eq!(tz.len(), 5, "Expected ±HHMM (5 chars), got: {}", tz);
373    }
374
375    #[test]
376    fn test_eurodate_str() {
377        let ts = 1615809600; // Mon Mar 15 2021 12:00:00 UTC
378        let ds = get_date_str(ts, None, true, true, false, "en");
379        assert_eq!(ds, "Mon 15 Mar 2021 12:00:00 +0000");
380    }
381
382    #[test]
383    fn test_custom_format_str() {
384        let ts = 1615809600;
385        let ds = get_date_str(ts, Some("%Y-%m-%d"), true, false, false, "en");
386        assert_eq!(ds, "2021-03-15");
387    }
388}