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