use chrono::{DateTime, NaiveDateTime, Utc};
const DOTNET_EPOCH_OFFSET_TICKS: i64 = 621_355_968_000_000_000;
const TICKS_PER_SECOND: i64 = 10_000_000;
const LOCALE_FORMATS: &[&str] = &[
"%Y-%-m-%-d %-H:%M:%S",
"%Y-%-m-%-d %-I:%M:%S %p",
"%Y/%-m/%-d %-H:%M:%S",
"%Y/%-m/%-d %-I:%M:%S %p",
"%-m/%-d/%Y %-H:%M:%S",
"%-m/%-d/%Y %-I:%M:%S %p",
"%-d/%-m/%Y %-H:%M:%S",
"%-d/%-m/%Y %-I:%M:%S %p",
"%-d.%-m.%Y %-H:%M:%S",
"%-d.%-m.%Y %-I:%M:%S %p",
"%Y-%-m-%-dT%-H:%M:%S",
];
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum TimestampError {
#[error("unrecognized timestamp format: {raw:?}")]
UnrecognizedFormat {
raw: String,
},
#[error("timestamp value out of range: {value}")]
OutOfRange {
value: i64,
},
}
pub fn parse_log_timestamp(s: &str) -> Result<DateTime<Utc>, TimestampError> {
let trimmed = s.trim();
for fmt in LOCALE_FORMATS {
if let Ok(naive) = NaiveDateTime::parse_from_str(trimmed, fmt) {
return Ok(naive.and_utc());
}
}
Err(TimestampError::UnrecognizedFormat { raw: s.to_owned() })
}
pub fn parse_epoch_millis(millis: i64) -> Result<DateTime<Utc>, TimestampError> {
let secs = millis.div_euclid(1000);
let sub_millis = millis.rem_euclid(1000);
let nanos = u32::try_from(sub_millis * 1_000_000)
.map_err(|_| TimestampError::OutOfRange { value: millis })?;
DateTime::from_timestamp(secs, nanos).ok_or(TimestampError::OutOfRange { value: millis })
}
pub fn parse_dotnet_ticks(ticks: i64) -> Result<DateTime<Utc>, TimestampError> {
let unix_ticks = ticks
.checked_sub(DOTNET_EPOCH_OFFSET_TICKS)
.ok_or(TimestampError::OutOfRange { value: ticks })?;
let secs = unix_ticks.div_euclid(TICKS_PER_SECOND);
let remaining = unix_ticks.rem_euclid(TICKS_PER_SECOND);
let nanos =
u32::try_from(remaining * 100).map_err(|_| TimestampError::OutOfRange { value: ticks })?;
DateTime::from_timestamp(secs, nanos).ok_or(TimestampError::OutOfRange { value: ticks })
}
pub fn parse_iso8601(s: &str) -> Result<DateTime<Utc>, TimestampError> {
let trimmed = s.trim();
if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
return Ok(dt.with_timezone(&Utc));
}
NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f")
.map(|naive| naive.and_utc())
.map_err(|_| TimestampError::UnrecognizedFormat { raw: s.to_owned() })
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Datelike, Timelike};
type TestResult = Result<(), Box<dyn std::error::Error>>;
mod log_timestamp {
use super::*;
#[test]
fn test_parse_log_timestamp_iso_date_24h() -> TestResult {
let dt = parse_log_timestamp("2025-01-15 14:30:45")?;
assert_eq!(dt.year(), 2025);
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 15);
assert_eq!(dt.hour(), 14);
assert_eq!(dt.minute(), 30);
assert_eq!(dt.second(), 45);
Ok(())
}
#[test]
fn test_parse_log_timestamp_iso_date_12h_am() -> TestResult {
let dt = parse_log_timestamp("2025-01-15 9:30:45 AM")?;
assert_eq!(dt.hour(), 9);
Ok(())
}
#[test]
fn test_parse_log_timestamp_iso_date_12h_pm() -> TestResult {
let dt = parse_log_timestamp("2025-01-15 3:42:17 PM")?;
assert_eq!(dt.hour(), 15);
assert_eq!(dt.minute(), 42);
assert_eq!(dt.second(), 17);
Ok(())
}
#[test]
fn test_parse_log_timestamp_iso_date_12h_noon() -> TestResult {
let dt = parse_log_timestamp("2025-06-01 12:00:00 PM")?;
assert_eq!(dt.hour(), 12);
Ok(())
}
#[test]
fn test_parse_log_timestamp_iso_date_12h_midnight() -> TestResult {
let dt = parse_log_timestamp("2025-06-01 12:00:00 AM")?;
assert_eq!(dt.hour(), 0);
Ok(())
}
#[test]
fn test_parse_log_timestamp_slash_iso_24h() -> TestResult {
let dt = parse_log_timestamp("2025/01/15 14:30:45")?;
assert_eq!(dt.year(), 2025);
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 15);
assert_eq!(dt.hour(), 14);
Ok(())
}
#[test]
fn test_parse_log_timestamp_slash_iso_12h() -> TestResult {
let dt = parse_log_timestamp("2025/01/15 3:42:17 PM")?;
assert_eq!(dt.hour(), 15);
Ok(())
}
#[test]
fn test_parse_log_timestamp_us_date_24h() -> TestResult {
let dt = parse_log_timestamp("1/15/2025 14:30:45")?;
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 15);
assert_eq!(dt.hour(), 14);
Ok(())
}
#[test]
fn test_parse_log_timestamp_us_date_12h() -> TestResult {
let dt = parse_log_timestamp("1/15/2025 3:42:17 PM")?;
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 15);
assert_eq!(dt.hour(), 15);
Ok(())
}
#[test]
fn test_parse_log_timestamp_european_date_24h() -> TestResult {
let dt = parse_log_timestamp("25/02/2026 10:15:30")?;
assert_eq!(dt.day(), 25);
assert_eq!(dt.month(), 2);
assert_eq!(dt.hour(), 10);
Ok(())
}
#[test]
fn test_parse_log_timestamp_european_date_12h() -> TestResult {
let dt = parse_log_timestamp("25/02/2026 3:15:30 PM")?;
assert_eq!(dt.day(), 25);
assert_eq!(dt.month(), 2);
assert_eq!(dt.hour(), 15);
Ok(())
}
#[test]
fn test_parse_log_timestamp_german_date_24h() -> TestResult {
let dt = parse_log_timestamp("25.02.2026 10:15:30")?;
assert_eq!(dt.day(), 25);
assert_eq!(dt.month(), 2);
Ok(())
}
#[test]
fn test_parse_log_timestamp_german_date_12h() -> TestResult {
let dt = parse_log_timestamp("25.02.2026 3:15:30 PM")?;
assert_eq!(dt.day(), 25);
assert_eq!(dt.month(), 2);
assert_eq!(dt.hour(), 15);
Ok(())
}
#[test]
fn test_parse_log_timestamp_iso8601_t_separator() -> TestResult {
let dt = parse_log_timestamp("2025-01-15T14:30:45")?;
assert_eq!(dt.year(), 2025);
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 15);
assert_eq!(dt.hour(), 14);
Ok(())
}
#[test]
fn test_parse_log_timestamp_trims_whitespace() -> TestResult {
let dt = parse_log_timestamp(" 2025-01-15 14:30:45 ")?;
assert_eq!(dt.year(), 2025);
Ok(())
}
#[test]
fn test_parse_log_timestamp_zero_padded_fields() -> TestResult {
let dt = parse_log_timestamp("01/05/2025 08:05:09")?;
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 5);
assert_eq!(dt.hour(), 8);
assert_eq!(dt.minute(), 5);
assert_eq!(dt.second(), 9);
Ok(())
}
#[test]
fn test_parse_log_timestamp_lowercase_am_pm() -> TestResult {
let dt = parse_log_timestamp("2025-01-15 3:42:17 pm")?;
assert_eq!(dt.hour(), 15);
Ok(())
}
#[test]
fn test_parse_log_timestamp_empty_returns_error() {
assert!(parse_log_timestamp("").is_err());
}
#[test]
fn test_parse_log_timestamp_garbage_returns_error() {
assert!(parse_log_timestamp("not a timestamp").is_err());
}
#[test]
fn test_parse_log_timestamp_error_preserves_raw_string() {
let input = "garbage value 123";
let err = parse_log_timestamp(input);
assert!(matches!(
err,
Err(TimestampError::UnrecognizedFormat { ref raw })
if raw == input
));
}
}
mod epoch_millis {
use super::*;
use chrono::TimeZone;
#[test]
fn test_parse_epoch_millis_zero_is_unix_epoch() -> TestResult {
let dt = parse_epoch_millis(0)?;
assert_eq!(dt.year(), 1970);
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 1);
assert_eq!(dt.hour(), 0);
Ok(())
}
#[test]
fn test_parse_epoch_millis_known_date() -> TestResult {
let expected = Utc
.with_ymd_and_hms(2026, 2, 25, 12, 0, 0)
.single()
.ok_or("2026-02-25T12:00:00Z is not a valid UTC datetime")?;
let dt = parse_epoch_millis(expected.timestamp_millis())?;
assert_eq!(dt, expected);
Ok(())
}
#[test]
fn test_parse_epoch_millis_sub_second_precision() -> TestResult {
let dt = parse_epoch_millis(500)?;
assert_eq!(dt.nanosecond(), 500_000_000);
Ok(())
}
#[test]
fn test_parse_epoch_millis_negative_before_epoch() -> TestResult {
let dt = parse_epoch_millis(-1000)?;
assert_eq!(dt.year(), 1969);
assert_eq!(dt.month(), 12);
assert_eq!(dt.day(), 31);
assert_eq!(dt.hour(), 23);
assert_eq!(dt.minute(), 59);
assert_eq!(dt.second(), 59);
Ok(())
}
#[test]
fn test_parse_epoch_millis_out_of_range_returns_error() {
let err = parse_epoch_millis(i64::MAX);
assert!(matches!(
err,
Err(TimestampError::OutOfRange { value }) if value == i64::MAX
));
}
}
mod dotnet_ticks {
use super::*;
use chrono::TimeZone;
#[test]
fn test_parse_dotnet_ticks_unix_epoch() -> TestResult {
let dt = parse_dotnet_ticks(DOTNET_EPOCH_OFFSET_TICKS)?;
assert_eq!(dt.year(), 1970);
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 1);
assert_eq!(dt.hour(), 0);
Ok(())
}
#[test]
fn test_parse_dotnet_ticks_known_date() -> TestResult {
let expected = Utc
.with_ymd_and_hms(2026, 2, 25, 12, 0, 0)
.single()
.ok_or("2026-02-25T12:00:00Z is not a valid UTC datetime")?;
let net_ticks = expected.timestamp() * TICKS_PER_SECOND + DOTNET_EPOCH_OFFSET_TICKS;
let dt = parse_dotnet_ticks(net_ticks)?;
assert_eq!(dt, expected);
Ok(())
}
#[test]
fn test_parse_dotnet_ticks_sub_second_precision() -> TestResult {
let ticks = DOTNET_EPOCH_OFFSET_TICKS + 5_000_000;
let dt = parse_dotnet_ticks(ticks)?;
assert_eq!(dt.nanosecond(), 500_000_000);
Ok(())
}
#[test]
fn test_parse_dotnet_ticks_overflow_returns_error() {
assert!(parse_dotnet_ticks(i64::MIN).is_err());
}
}
mod iso8601 {
use super::*;
#[test]
fn test_parse_iso8601_with_z_suffix() -> TestResult {
let dt = parse_iso8601("2026-02-17T15:30:00Z")?;
assert_eq!(dt.year(), 2026);
assert_eq!(dt.month(), 2);
assert_eq!(dt.day(), 17);
assert_eq!(dt.hour(), 15);
assert_eq!(dt.minute(), 30);
Ok(())
}
#[test]
fn test_parse_iso8601_with_zero_offset() -> TestResult {
let dt = parse_iso8601("2026-02-17T15:30:00+00:00")?;
assert_eq!(dt.hour(), 15);
Ok(())
}
#[test]
fn test_parse_iso8601_positive_offset_normalizes_to_utc() -> TestResult {
let dt = parse_iso8601("2026-02-17T15:30:00+05:00")?;
assert_eq!(dt.hour(), 10);
assert_eq!(dt.minute(), 30);
Ok(())
}
#[test]
fn test_parse_iso8601_negative_offset_normalizes_to_utc() -> TestResult {
let dt = parse_iso8601("2026-02-17T15:30:00-08:00")?;
assert_eq!(dt.hour(), 23);
assert_eq!(dt.minute(), 30);
Ok(())
}
#[test]
fn test_parse_iso8601_naive_treated_as_utc() -> TestResult {
let dt = parse_iso8601("2026-02-17T15:30:00")?;
assert_eq!(dt.hour(), 15);
Ok(())
}
#[test]
fn test_parse_iso8601_with_fractional_seconds() -> TestResult {
let dt = parse_iso8601("2026-02-17T15:30:00.123Z")?;
assert_eq!(dt.nanosecond(), 123_000_000);
Ok(())
}
#[test]
fn test_parse_iso8601_trims_whitespace() -> TestResult {
let dt = parse_iso8601(" 2026-02-17T15:30:00Z ")?;
assert_eq!(dt.year(), 2026);
Ok(())
}
#[test]
fn test_parse_iso8601_invalid_returns_error() {
assert!(parse_iso8601("not-a-date").is_err());
}
#[test]
fn test_parse_iso8601_error_preserves_raw_string() {
let input = "bad-iso-input";
let err = parse_iso8601(input);
assert!(matches!(
err,
Err(TimestampError::UnrecognizedFormat { ref raw })
if raw == input
));
}
}
mod error {
use super::*;
#[test]
fn test_unrecognized_format_display() {
let err = TimestampError::UnrecognizedFormat {
raw: "bad".to_owned(),
};
let msg = err.to_string();
assert!(msg.contains("bad"));
assert!(msg.contains("unrecognized"));
}
#[test]
fn test_out_of_range_display() {
let err = TimestampError::OutOfRange { value: -999 };
let msg = err.to_string();
assert!(msg.contains("-999"));
assert!(msg.contains("out of range"));
}
#[test]
fn test_error_clone_is_equal() {
let err = TimestampError::UnrecognizedFormat {
raw: "test".to_owned(),
};
let cloned = err.clone();
assert_eq!(err, cloned);
}
}
}