1use chrono::{DateTime, NaiveDateTime, Utc};
22
23const DOTNET_EPOCH_OFFSET_TICKS: i64 = 621_355_968_000_000_000;
29
30const TICKS_PER_SECOND: i64 = 10_000_000;
32
33const LOCALE_FORMATS: &[&str] = &[
44 "%Y-%-m-%-d %-H:%M:%S",
46 "%Y-%-m-%-d %-I:%M:%S %p",
47 "%Y/%-m/%-d %-H:%M:%S",
49 "%Y/%-m/%-d %-I:%M:%S %p",
50 "%-m/%-d/%Y %-H:%M:%S",
52 "%-m/%-d/%Y %-I:%M:%S %p",
53 "%-d/%-m/%Y %-H:%M:%S",
55 "%-d/%-m/%Y %-I:%M:%S %p",
56 "%-d.%-m.%Y %-H:%M:%S",
58 "%-d.%-m.%Y %-I:%M:%S %p",
59 "%Y-%-m-%-dT%-H:%M:%S",
61];
62
63#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
69pub enum TimestampError {
70 #[error("unrecognized timestamp format: {raw:?}")]
72 UnrecognizedFormat {
73 raw: String,
75 },
76
77 #[error("timestamp value out of range: {value}")]
79 OutOfRange {
80 value: i64,
82 },
83}
84
85pub 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
130pub 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
147pub 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
168pub fn parse_iso8601(s: &str) -> Result<DateTime<Utc>, TimestampError> {
180 let trimmed = s.trim();
181
182 if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
184 return Ok(dt.with_timezone(&Utc));
185 }
186
187 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#[cfg(test)]
198mod tests {
199 use super::*;
200 use chrono::{Datelike, Timelike};
201
202 type TestResult = Result<(), Box<dyn std::error::Error>>;
203
204 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 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 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 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 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 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 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 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 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 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 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 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 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}