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
12pub 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 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 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 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
100fn normalize_nonstandard_tz(s: &str) -> String {
103 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 let match_start = cap.get(0).unwrap().start();
114 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
137fn clamp_timestamp(timestamp: i64) -> i64 {
141 const MIN_TS: i64 = -62167219200; const MAX_TS: i64 = 253402300799; 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 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; 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; 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 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 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); }
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); 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 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; 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}