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
11pub 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 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 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 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
99fn normalize_nonstandard_tz(s: &str) -> String {
102 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 let match_start = cap.get(0).unwrap().start();
113 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; 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; 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 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 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); }
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); 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 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; 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}