1use chrono::{DateTime, NaiveDateTime, Utc};
8
9const DOTNET_EPOCH_OFFSET_TICKS: i64 = 621_355_968_000_000_000;
15
16const TICKS_PER_SECOND: i64 = 10_000_000;
18
19const LOCALE_FORMATS: &[&str] = &[
30 "%Y-%-m-%-d %-H:%M:%S",
32 "%Y-%-m-%-d %-I:%M:%S %p",
33 "%Y/%-m/%-d %-H:%M:%S",
35 "%Y/%-m/%-d %-I:%M:%S %p",
36 "%-m/%-d/%Y %-H:%M:%S",
38 "%-m/%-d/%Y %-I:%M:%S %p",
39 "%-d/%-m/%Y %-H:%M:%S",
41 "%-d/%-m/%Y %-I:%M:%S %p",
42 "%-d.%-m.%Y %-H:%M:%S",
44 "%-d.%-m.%Y %-I:%M:%S %p",
45 "%Y-%-m-%-dT%-H:%M:%S",
47];
48
49#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
55pub enum TimestampError {
56 #[error("unrecognized timestamp format: {raw:?}")]
58 UnrecognizedFormat {
59 raw: String,
61 },
62
63 #[error("timestamp value out of range: {value}")]
65 OutOfRange {
66 value: i64,
68 },
69}
70
71pub 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
109pub 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
126pub 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
147pub fn parse_iso8601(s: &str) -> Result<DateTime<Utc>, TimestampError> {
159 let trimmed = s.trim();
160
161 if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
163 return Ok(dt.with_timezone(&Utc));
164 }
165
166 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#[cfg(test)]
177mod tests {
178 use super::*;
179 use chrono::{Datelike, Timelike};
180
181 type TestResult = Result<(), Box<dyn std::error::Error>>;
182
183 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 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 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 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 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 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 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 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 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 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 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 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 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}