Skip to main content

hypermail/
date.rs

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