Skip to main content

manasight_parser/log/
timestamp.rs

1//! Locale-dependent timestamp parsing for MTG Arena log entries.
2//!
3//! MTGA log timestamps vary by system locale. This module handles all known
4//! formats (11+ locale-dependent variants, epoch milliseconds, .NET ticks,
5//! and ISO 8601) and normalizes them to UTC.
6
7use chrono::{DateTime, NaiveDateTime, Utc};
8
9// ---------------------------------------------------------------------------
10// Constants
11// ---------------------------------------------------------------------------
12
13/// .NET ticks between 0001-01-01T00:00:00 and 1970-01-01T00:00:00.
14const DOTNET_EPOCH_OFFSET_TICKS: i64 = 621_355_968_000_000_000;
15
16/// Number of .NET ticks per second (each tick = 100 nanoseconds).
17const TICKS_PER_SECOND: i64 = 10_000_000;
18
19/// Chrono format strings for all known MTGA locale-dependent timestamps.
20///
21/// Tried in order until one succeeds. Ordering rationale:
22/// - Year-first formats first (unambiguous date structure).
23/// - US date formats (`M/d/yyyy`) before European (`dd/MM/yyyy`) since
24///   they share the `/` separator and are ambiguous when both month and
25///   day are <= 12.
26/// - ISO 8601 with `T` separator last (11th format).
27///
28/// Extend this array when new locale variants are discovered.
29const LOCALE_FORMATS: &[&str] = &[
30    // yyyy-MM-dd (ISO date order)
31    "%Y-%-m-%-d %-H:%M:%S",
32    "%Y-%-m-%-d %-I:%M:%S %p",
33    // yyyy/MM/dd (slash-separated ISO)
34    "%Y/%-m/%-d %-H:%M:%S",
35    "%Y/%-m/%-d %-I:%M:%S %p",
36    // M/d/yyyy (US short date)
37    "%-m/%-d/%Y %-H:%M:%S",
38    "%-m/%-d/%Y %-I:%M:%S %p",
39    // dd/MM/yyyy (European)
40    "%-d/%-m/%Y %-H:%M:%S",
41    "%-d/%-m/%Y %-I:%M:%S %p",
42    // dd.MM.yyyy (German / Central European)
43    "%-d.%-m.%Y %-H:%M:%S",
44    "%-d.%-m.%Y %-I:%M:%S %p",
45    // ISO 8601 with T separator (no timezone suffix)
46    "%Y-%-m-%-dT%-H:%M:%S",
47];
48
49// ---------------------------------------------------------------------------
50// Error type
51// ---------------------------------------------------------------------------
52
53/// Error returned when a timestamp cannot be parsed.
54#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
55pub enum TimestampError {
56    /// None of the known locale-dependent formats matched the input.
57    #[error("unrecognized timestamp format: {raw:?}")]
58    UnrecognizedFormat {
59        /// The original timestamp string, preserved for diagnostics.
60        raw: String,
61    },
62
63    /// The numeric value is out of range for a valid UTC datetime.
64    #[error("timestamp value out of range: {value}")]
65    OutOfRange {
66        /// The numeric value that could not be converted.
67        value: i64,
68    },
69}
70
71// ---------------------------------------------------------------------------
72// Public parsing functions
73// ---------------------------------------------------------------------------
74
75/// Parses a locale-dependent timestamp from an MTGA log entry header.
76///
77/// Tries all 11 known locale-dependent formats in sequence until one
78/// succeeds. The input should be the timestamp portion extracted from
79/// a log entry header line.
80///
81/// All timestamps are treated as UTC (MTGA does not include timezone
82/// information in log entry headers).
83///
84/// # Ambiguity
85///
86/// When day and month are both `<= 12` and the separator is `/` (for example
87/// `02/05/2025 14:30:00`), US format (`M/d/yyyy`) is tried before European
88/// (`dd/MM/yyyy`). The input above would therefore be interpreted as
89/// February 5, not May 2. There is no way to resolve this ambiguity
90/// without out-of-band locale information; consumers targeting European
91/// locales should be aware of this silent tie-break.
92///
93/// # Errors
94///
95/// Returns [`TimestampError::UnrecognizedFormat`] if no format matches,
96/// preserving the raw string for diagnostics.
97pub fn parse_log_timestamp(s: &str) -> Result<DateTime<Utc>, TimestampError> {
98    let trimmed = s.trim();
99
100    for fmt in LOCALE_FORMATS {
101        if let Ok(naive) = NaiveDateTime::parse_from_str(trimmed, fmt) {
102            return Ok(naive.and_utc());
103        }
104    }
105
106    Err(TimestampError::UnrecognizedFormat { raw: s.to_owned() })
107}
108
109/// Parses a Unix epoch milliseconds value into a UTC datetime.
110///
111/// MTGA payloads sometimes express timestamps as milliseconds since
112/// 1970-01-01T00:00:00 UTC.
113///
114/// # Errors
115///
116/// Returns [`TimestampError::OutOfRange`] if the value cannot be
117/// represented as a valid `DateTime<Utc>`.
118pub fn parse_epoch_millis(millis: i64) -> Result<DateTime<Utc>, TimestampError> {
119    let secs = millis.div_euclid(1000);
120    let sub_millis = millis.rem_euclid(1000);
121    let nanos = u32::try_from(sub_millis * 1_000_000)
122        .map_err(|_| TimestampError::OutOfRange { value: millis })?;
123    DateTime::from_timestamp(secs, nanos).ok_or(TimestampError::OutOfRange { value: millis })
124}
125
126/// Parses a .NET ticks value into a UTC datetime.
127///
128/// .NET ticks are 100-nanosecond intervals since 0001-01-01T00:00:00.
129/// This function subtracts the .NET-to-Unix epoch offset and converts
130/// the remainder to a `DateTime<Utc>`.
131///
132/// # Errors
133///
134/// Returns [`TimestampError::OutOfRange`] if the ticks value cannot be
135/// represented as a valid `DateTime<Utc>`.
136pub fn parse_dotnet_ticks(ticks: i64) -> Result<DateTime<Utc>, TimestampError> {
137    let unix_ticks = ticks
138        .checked_sub(DOTNET_EPOCH_OFFSET_TICKS)
139        .ok_or(TimestampError::OutOfRange { value: ticks })?;
140    let secs = unix_ticks.div_euclid(TICKS_PER_SECOND);
141    let remaining = unix_ticks.rem_euclid(TICKS_PER_SECOND);
142    let nanos =
143        u32::try_from(remaining * 100).map_err(|_| TimestampError::OutOfRange { value: ticks })?;
144    DateTime::from_timestamp(secs, nanos).ok_or(TimestampError::OutOfRange { value: ticks })
145}
146
147/// Parses an ISO 8601 datetime string into a UTC datetime.
148///
149/// Accepts timezone-aware strings like `"2026-02-17T15:30:00Z"` and
150/// `"2026-02-17T15:30:00+05:00"`, as well as naive strings like
151/// `"2026-02-17T15:30:00"`. Timezone-aware inputs are normalized to
152/// UTC; naive inputs are assumed UTC.
153///
154/// # Errors
155///
156/// Returns [`TimestampError::UnrecognizedFormat`] if the string is not
157/// valid ISO 8601.
158pub fn parse_iso8601(s: &str) -> Result<DateTime<Utc>, TimestampError> {
159    let trimmed = s.trim();
160
161    // Try RFC 3339 first (handles Z, +00:00, +05:00, fractional seconds).
162    if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
163        return Ok(dt.with_timezone(&Utc));
164    }
165
166    // Fall back to naive ISO 8601 (no timezone suffix), treated as UTC.
167    NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f")
168        .map(|naive| naive.and_utc())
169        .map_err(|_| TimestampError::UnrecognizedFormat { raw: s.to_owned() })
170}
171
172// ---------------------------------------------------------------------------
173// Tests
174// ---------------------------------------------------------------------------
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use chrono::{Datelike, Timelike};
180
181    type TestResult = Result<(), Box<dyn std::error::Error>>;
182
183    // -- parse_log_timestamp: locale formats --------------------------------
184
185    mod log_timestamp {
186        use super::*;
187
188        #[test]
189        fn test_parse_log_timestamp_iso_date_24h() -> TestResult {
190            let dt = parse_log_timestamp("2025-01-15 14:30:45")?;
191            assert_eq!(dt.year(), 2025);
192            assert_eq!(dt.month(), 1);
193            assert_eq!(dt.day(), 15);
194            assert_eq!(dt.hour(), 14);
195            assert_eq!(dt.minute(), 30);
196            assert_eq!(dt.second(), 45);
197            Ok(())
198        }
199
200        #[test]
201        fn test_parse_log_timestamp_iso_date_12h_am() -> TestResult {
202            let dt = parse_log_timestamp("2025-01-15 9:30:45 AM")?;
203            assert_eq!(dt.hour(), 9);
204            Ok(())
205        }
206
207        #[test]
208        fn test_parse_log_timestamp_iso_date_12h_pm() -> TestResult {
209            let dt = parse_log_timestamp("2025-01-15 3:42:17 PM")?;
210            assert_eq!(dt.hour(), 15);
211            assert_eq!(dt.minute(), 42);
212            assert_eq!(dt.second(), 17);
213            Ok(())
214        }
215
216        #[test]
217        fn test_parse_log_timestamp_iso_date_12h_noon() -> TestResult {
218            let dt = parse_log_timestamp("2025-06-01 12:00:00 PM")?;
219            assert_eq!(dt.hour(), 12);
220            Ok(())
221        }
222
223        #[test]
224        fn test_parse_log_timestamp_iso_date_12h_midnight() -> TestResult {
225            let dt = parse_log_timestamp("2025-06-01 12:00:00 AM")?;
226            assert_eq!(dt.hour(), 0);
227            Ok(())
228        }
229
230        #[test]
231        fn test_parse_log_timestamp_slash_iso_24h() -> TestResult {
232            let dt = parse_log_timestamp("2025/01/15 14:30:45")?;
233            assert_eq!(dt.year(), 2025);
234            assert_eq!(dt.month(), 1);
235            assert_eq!(dt.day(), 15);
236            assert_eq!(dt.hour(), 14);
237            Ok(())
238        }
239
240        #[test]
241        fn test_parse_log_timestamp_slash_iso_12h() -> TestResult {
242            let dt = parse_log_timestamp("2025/01/15 3:42:17 PM")?;
243            assert_eq!(dt.hour(), 15);
244            Ok(())
245        }
246
247        #[test]
248        fn test_parse_log_timestamp_us_date_24h() -> TestResult {
249            // M/d/yyyy — day 15 > 12, so only US format matches.
250            let dt = parse_log_timestamp("1/15/2025 14:30:45")?;
251            assert_eq!(dt.month(), 1);
252            assert_eq!(dt.day(), 15);
253            assert_eq!(dt.hour(), 14);
254            Ok(())
255        }
256
257        #[test]
258        fn test_parse_log_timestamp_us_date_12h() -> TestResult {
259            let dt = parse_log_timestamp("1/15/2025 3:42:17 PM")?;
260            assert_eq!(dt.month(), 1);
261            assert_eq!(dt.day(), 15);
262            assert_eq!(dt.hour(), 15);
263            Ok(())
264        }
265
266        #[test]
267        fn test_parse_log_timestamp_european_date_24h() -> TestResult {
268            // dd/MM/yyyy — day 25 > 12, so US format fails and European
269            // matches.
270            let dt = parse_log_timestamp("25/02/2026 10:15:30")?;
271            assert_eq!(dt.day(), 25);
272            assert_eq!(dt.month(), 2);
273            assert_eq!(dt.hour(), 10);
274            Ok(())
275        }
276
277        #[test]
278        fn test_parse_log_timestamp_european_date_12h() -> TestResult {
279            let dt = parse_log_timestamp("25/02/2026 3:15:30 PM")?;
280            assert_eq!(dt.day(), 25);
281            assert_eq!(dt.month(), 2);
282            assert_eq!(dt.hour(), 15);
283            Ok(())
284        }
285
286        #[test]
287        fn test_parse_log_timestamp_german_date_24h() -> TestResult {
288            let dt = parse_log_timestamp("25.02.2026 10:15:30")?;
289            assert_eq!(dt.day(), 25);
290            assert_eq!(dt.month(), 2);
291            Ok(())
292        }
293
294        #[test]
295        fn test_parse_log_timestamp_german_date_12h() -> TestResult {
296            let dt = parse_log_timestamp("25.02.2026 3:15:30 PM")?;
297            assert_eq!(dt.day(), 25);
298            assert_eq!(dt.month(), 2);
299            assert_eq!(dt.hour(), 15);
300            Ok(())
301        }
302
303        #[test]
304        fn test_parse_log_timestamp_iso8601_t_separator() -> TestResult {
305            let dt = parse_log_timestamp("2025-01-15T14:30:45")?;
306            assert_eq!(dt.year(), 2025);
307            assert_eq!(dt.month(), 1);
308            assert_eq!(dt.day(), 15);
309            assert_eq!(dt.hour(), 14);
310            Ok(())
311        }
312
313        #[test]
314        fn test_parse_log_timestamp_trims_whitespace() -> TestResult {
315            let dt = parse_log_timestamp("  2025-01-15 14:30:45  ")?;
316            assert_eq!(dt.year(), 2025);
317            Ok(())
318        }
319
320        #[test]
321        fn test_parse_log_timestamp_zero_padded_fields() -> TestResult {
322            let dt = parse_log_timestamp("01/05/2025 08:05:09")?;
323            assert_eq!(dt.month(), 1);
324            assert_eq!(dt.day(), 5);
325            assert_eq!(dt.hour(), 8);
326            assert_eq!(dt.minute(), 5);
327            assert_eq!(dt.second(), 9);
328            Ok(())
329        }
330
331        #[test]
332        fn test_parse_log_timestamp_lowercase_am_pm() -> TestResult {
333            // chrono's %p is case-insensitive during parsing.
334            let dt = parse_log_timestamp("2025-01-15 3:42:17 pm")?;
335            assert_eq!(dt.hour(), 15);
336            Ok(())
337        }
338
339        #[test]
340        fn test_parse_log_timestamp_empty_returns_error() {
341            assert!(parse_log_timestamp("").is_err());
342        }
343
344        #[test]
345        fn test_parse_log_timestamp_garbage_returns_error() {
346            assert!(parse_log_timestamp("not a timestamp").is_err());
347        }
348
349        #[test]
350        fn test_parse_log_timestamp_error_preserves_raw_string() {
351            let input = "garbage value 123";
352            let err = parse_log_timestamp(input);
353            assert!(matches!(
354                err,
355                Err(TimestampError::UnrecognizedFormat { ref raw })
356                    if raw == input
357            ));
358        }
359    }
360
361    // -- parse_epoch_millis -------------------------------------------------
362
363    mod epoch_millis {
364        use super::*;
365        use chrono::TimeZone;
366
367        #[test]
368        fn test_parse_epoch_millis_zero_is_unix_epoch() -> TestResult {
369            let dt = parse_epoch_millis(0)?;
370            assert_eq!(dt.year(), 1970);
371            assert_eq!(dt.month(), 1);
372            assert_eq!(dt.day(), 1);
373            assert_eq!(dt.hour(), 0);
374            Ok(())
375        }
376
377        #[test]
378        fn test_parse_epoch_millis_known_date() -> TestResult {
379            let expected = Utc
380                .with_ymd_and_hms(2026, 2, 25, 12, 0, 0)
381                .single()
382                .ok_or("2026-02-25T12:00:00Z is not a valid UTC datetime")?;
383            let dt = parse_epoch_millis(expected.timestamp_millis())?;
384            assert_eq!(dt, expected);
385            Ok(())
386        }
387
388        #[test]
389        fn test_parse_epoch_millis_sub_second_precision() -> TestResult {
390            let dt = parse_epoch_millis(500)?;
391            assert_eq!(dt.nanosecond(), 500_000_000);
392            Ok(())
393        }
394
395        #[test]
396        fn test_parse_epoch_millis_negative_before_epoch() -> TestResult {
397            // -1000 ms = 1969-12-31T23:59:59 UTC
398            let dt = parse_epoch_millis(-1000)?;
399            assert_eq!(dt.year(), 1969);
400            assert_eq!(dt.month(), 12);
401            assert_eq!(dt.day(), 31);
402            assert_eq!(dt.hour(), 23);
403            assert_eq!(dt.minute(), 59);
404            assert_eq!(dt.second(), 59);
405            Ok(())
406        }
407
408        #[test]
409        fn test_parse_epoch_millis_out_of_range_returns_error() {
410            // i64::MAX milliseconds is far beyond DateTime's representable range.
411            let err = parse_epoch_millis(i64::MAX);
412            assert!(matches!(
413                err,
414                Err(TimestampError::OutOfRange { value }) if value == i64::MAX
415            ));
416        }
417    }
418
419    // -- parse_dotnet_ticks -------------------------------------------------
420
421    mod dotnet_ticks {
422        use super::*;
423        use chrono::TimeZone;
424
425        #[test]
426        fn test_parse_dotnet_ticks_unix_epoch() -> TestResult {
427            let dt = parse_dotnet_ticks(DOTNET_EPOCH_OFFSET_TICKS)?;
428            assert_eq!(dt.year(), 1970);
429            assert_eq!(dt.month(), 1);
430            assert_eq!(dt.day(), 1);
431            assert_eq!(dt.hour(), 0);
432            Ok(())
433        }
434
435        #[test]
436        fn test_parse_dotnet_ticks_known_date() -> TestResult {
437            let expected = Utc
438                .with_ymd_and_hms(2026, 2, 25, 12, 0, 0)
439                .single()
440                .ok_or("2026-02-25T12:00:00Z is not a valid UTC datetime")?;
441            let net_ticks = expected.timestamp() * TICKS_PER_SECOND + DOTNET_EPOCH_OFFSET_TICKS;
442            let dt = parse_dotnet_ticks(net_ticks)?;
443            assert_eq!(dt, expected);
444            Ok(())
445        }
446
447        #[test]
448        fn test_parse_dotnet_ticks_sub_second_precision() -> TestResult {
449            // Unix epoch + 5_000_000 ticks = 0.5 seconds
450            let ticks = DOTNET_EPOCH_OFFSET_TICKS + 5_000_000;
451            let dt = parse_dotnet_ticks(ticks)?;
452            assert_eq!(dt.nanosecond(), 500_000_000);
453            Ok(())
454        }
455
456        #[test]
457        fn test_parse_dotnet_ticks_overflow_returns_error() {
458            assert!(parse_dotnet_ticks(i64::MIN).is_err());
459        }
460    }
461
462    // -- parse_iso8601 ------------------------------------------------------
463
464    mod iso8601 {
465        use super::*;
466
467        #[test]
468        fn test_parse_iso8601_with_z_suffix() -> TestResult {
469            let dt = parse_iso8601("2026-02-17T15:30:00Z")?;
470            assert_eq!(dt.year(), 2026);
471            assert_eq!(dt.month(), 2);
472            assert_eq!(dt.day(), 17);
473            assert_eq!(dt.hour(), 15);
474            assert_eq!(dt.minute(), 30);
475            Ok(())
476        }
477
478        #[test]
479        fn test_parse_iso8601_with_zero_offset() -> TestResult {
480            let dt = parse_iso8601("2026-02-17T15:30:00+00:00")?;
481            assert_eq!(dt.hour(), 15);
482            Ok(())
483        }
484
485        #[test]
486        fn test_parse_iso8601_positive_offset_normalizes_to_utc() -> TestResult {
487            // +05:00 means local 15:30 = UTC 10:30
488            let dt = parse_iso8601("2026-02-17T15:30:00+05:00")?;
489            assert_eq!(dt.hour(), 10);
490            assert_eq!(dt.minute(), 30);
491            Ok(())
492        }
493
494        #[test]
495        fn test_parse_iso8601_negative_offset_normalizes_to_utc() -> TestResult {
496            // -08:00 means local 15:30 = UTC 23:30
497            let dt = parse_iso8601("2026-02-17T15:30:00-08:00")?;
498            assert_eq!(dt.hour(), 23);
499            assert_eq!(dt.minute(), 30);
500            Ok(())
501        }
502
503        #[test]
504        fn test_parse_iso8601_naive_treated_as_utc() -> TestResult {
505            let dt = parse_iso8601("2026-02-17T15:30:00")?;
506            assert_eq!(dt.hour(), 15);
507            Ok(())
508        }
509
510        #[test]
511        fn test_parse_iso8601_with_fractional_seconds() -> TestResult {
512            let dt = parse_iso8601("2026-02-17T15:30:00.123Z")?;
513            assert_eq!(dt.nanosecond(), 123_000_000);
514            Ok(())
515        }
516
517        #[test]
518        fn test_parse_iso8601_trims_whitespace() -> TestResult {
519            let dt = parse_iso8601("  2026-02-17T15:30:00Z  ")?;
520            assert_eq!(dt.year(), 2026);
521            Ok(())
522        }
523
524        #[test]
525        fn test_parse_iso8601_invalid_returns_error() {
526            assert!(parse_iso8601("not-a-date").is_err());
527        }
528
529        #[test]
530        fn test_parse_iso8601_error_preserves_raw_string() {
531            let input = "bad-iso-input";
532            let err = parse_iso8601(input);
533            assert!(matches!(
534                err,
535                Err(TimestampError::UnrecognizedFormat { ref raw })
536                    if raw == input
537            ));
538        }
539    }
540
541    // -- TimestampError -----------------------------------------------------
542
543    mod error {
544        use super::*;
545
546        #[test]
547        fn test_unrecognized_format_display() {
548            let err = TimestampError::UnrecognizedFormat {
549                raw: "bad".to_owned(),
550            };
551            let msg = err.to_string();
552            assert!(msg.contains("bad"));
553            assert!(msg.contains("unrecognized"));
554        }
555
556        #[test]
557        fn test_out_of_range_display() {
558            let err = TimestampError::OutOfRange { value: -999 };
559            let msg = err.to_string();
560            assert!(msg.contains("-999"));
561            assert!(msg.contains("out of range"));
562        }
563
564        #[test]
565        fn test_error_clone_is_equal() {
566            let err = TimestampError::UnrecognizedFormat {
567                raw: "test".to_owned(),
568            };
569            let cloned = err.clone();
570            assert_eq!(err, cloned);
571        }
572    }
573}