use chrono::{DateTime, FixedOffset, format::Parsed};
pub(crate) fn parse_timestamp_from_rfc3339(s: &str) -> Result<DateTime<FixedOffset>, String> {
let mut parsed = Parsed::new();
let (s, _) = parse_rfc3339(&mut parsed, s)?;
if !s.is_empty() {
return Err(format!(
"RFC3339 string is too long; trailing characters: {s}"
));
}
parsed.to_datetime().map_err(|e| e.to_string())
}
pub(crate) fn parse_rfc3339<'a>(
parsed: &mut Parsed,
mut s: &'a str,
) -> Result<(&'a str, ()), String> {
macro_rules! try_consume {
($e:expr_2021) => {{
let (s_, v) = $e.map_err(|e| e.to_string())?;
s = s_;
v
}};
}
parsed
.set_year(try_consume!(scan::number(s, 4, 4)))
.map_err(|e| e.to_string())?;
s = scan::char(s, b'-')?;
parsed
.set_month(try_consume!(scan::number(s, 2, 2)))
.map_err(|e| e.to_string())?;
s = scan::char(s, b'-')?;
parsed
.set_day(try_consume!(scan::number(s, 2, 2)))
.map_err(|e| e.to_string())?;
s = match s.as_bytes().first() {
Some(&b't' | &b'T' | &b' ') => &s[1..],
Some(c) => {
return Err(format!(
"invalid time designator '{}' in RFC3339 string; supported designators are 't', 'T', and ' '",
*c as char
));
}
None => {
return Err(
"Expected time designator 't', 'T', or ' ', but found end of string".to_string(),
);
}
};
parsed
.set_hour(try_consume!(scan::number(s, 2, 2)))
.map_err(|e| e.to_string())?;
s = scan::char(s, b':')?;
parsed
.set_minute(try_consume!(scan::number(s, 2, 2)))
.map_err(|e| e.to_string())?;
s = scan::char(s, b':')?;
parsed
.set_second(try_consume!(scan::number(s, 2, 2)))
.map_err(|e| e.to_string())?;
if s.starts_with('.') {
let nanosecond = try_consume!(scan::nanosecond(&s[1..]));
parsed
.set_nanosecond(nanosecond)
.map_err(|e| e.to_string())?;
}
let offset = try_consume!(scan::timezone_offset(s, |s| scan::char(s, b':'),));
const MAX_RFC3339_OFFSET: i32 = (23 * 60 + 59) * 60;
if !(-MAX_RFC3339_OFFSET..=MAX_RFC3339_OFFSET).contains(&offset) {
return Err(format!(
"offset {offset} is out of range; expected range is -{MAX_RFC3339_OFFSET}..={MAX_RFC3339_OFFSET}"
));
}
parsed
.set_offset(i64::from(offset))
.map_err(|e| e.to_string())?;
Ok((s, ()))
}
mod scan {
#[inline]
pub(super) fn number(s: &str, min: usize, max: usize) -> Result<(&str, i64), String> {
assert!(min <= max);
let bytes = s.as_bytes();
if bytes.len() < min {
return Err(format!(
"expected at least {min} digits, but found {}",
bytes.len()
));
}
let mut n = 0i64;
for (i, c) in bytes.iter().take(max).cloned().enumerate() {
if !c.is_ascii_digit() {
if i < min {
return Err(format!(
"invalid character '{}' in timestamp string",
c as char
));
} else {
return Ok((&s[i..], n));
}
}
n = match n
.checked_mul(10)
.and_then(|n| n.checked_add((c - b'0') as i64))
{
Some(n) => n,
None => return Err("invalid timestamp string".to_string()),
};
}
Ok((&s[core::cmp::min(max, bytes.len())..], n))
}
pub(super) fn char(s: &str, c1: u8) -> Result<&str, String> {
match s.as_bytes().first() {
Some(&c) if c == c1 => Ok(&s[1..]),
Some(c) => Err(format!(
"expected character '{}', but found '{}'",
c1 as char, *c as char
)),
None => Err(format!(
"expected character '{}', but found end of string",
c1 as char
)),
}
}
pub(super) fn nanosecond(s: &str) -> Result<(&str, i64), String> {
let origlen = s.len();
let (s, v) = number(s, 1, 9)?;
let consumed = origlen - s.len();
static SCALE: [i64; 10] = [
0,
100_000_000,
10_000_000,
1_000_000,
100_000,
10_000,
1_000,
100,
10,
1,
];
let v = v
.checked_mul(SCALE[consumed])
.ok_or(format!("fractional second value {v} is out of range"))?;
let s = s.trim_start_matches(|c: char| c.is_ascii_digit());
Ok((s, v))
}
pub(crate) fn timezone_offset<F>(
mut s: &str,
mut consume_colon: F,
) -> Result<(&str, i32), String>
where
F: FnMut(&str) -> Result<&str, String>,
{
match s.as_bytes().first() {
Some(&b'Z' | &b'z') => return Ok((&s[1..], 0)),
None => return Ok((s, 0)),
_ => (),
}
fn digits(s: &str, descr: &str) -> Result<(u8, u8), String> {
let b = s.as_bytes();
if b.len() < 2 {
Err(format!(
"invalid timezone offset spec: expected at least 2 digits for {descr}, but found {}",
b.len()
))
} else {
Ok((b[0], b[1]))
}
}
let negative = match s.chars().next() {
Some('+') => {
s = &s['+'.len_utf8()..];
false
}
Some('-') => {
s = &s['-'.len_utf8()..];
true
}
Some('−') => {
s = &s['−'.len_utf8()..];
true
}
Some(c) => {
return Err(format!(
"expected '+' or '-', but found '{c}' in timezone offset string"
));
}
None => return Err("expected '+' or '-', but found end of string".to_string()),
};
let hours = match digits(s, "hours")? {
(h1 @ b'0'..=b'9', h2 @ b'0'..=b'9') => i32::from((h1 - b'0') * 10 + (h2 - b'0')),
(h1, h2) => {
return Err(format!(
"invalid timezone offset hours: '{}{}'",
h1 as char, h2 as char
));
}
};
s = &s[2..];
s = consume_colon(s)?;
let minutes = if let Ok(ds) = digits(s, "minutes") {
match ds {
(m1 @ b'0'..=b'5', m2 @ b'0'..=b'9') => i32::from((m1 - b'0') * 10 + (m2 - b'0')),
(m1 @ b'6'..=b'9', m2 @ b'0'..=b'9') => {
return Err(format!(
"invalid timezone offset minutes: '{}{}' (expected 00-59)",
m1 as char, m2 as char
));
}
(m1, m2) => {
return Err(format!(
"invalid timezone offset minutes: '{}{}'",
m1 as char, m2 as char
));
}
}
} else {
0
};
s = match s.len() {
len if len >= 2 => &s[2..],
0 => s,
_ => {
return Err(format!(
"invalid timezone offset seconds: expected at least 2 digits for seconds, but found {}",
s.len()
));
}
};
let seconds = hours * 3600 + minutes * 60;
Ok((s, if negative { -seconds } else { seconds }))
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_parse_timestamp_from_rfc3339() {
let timestamp = parse_timestamp_from_rfc3339("2025-11-15T16:39:57+00:00").unwrap();
assert_eq!(
timestamp,
DateTime::parse_from_rfc3339("2025-11-15T16:39:57+00:00").unwrap()
);
let timestamp = parse_timestamp_from_rfc3339("2025-11-15 16:39:57+00:00").unwrap();
assert_eq!(
timestamp,
DateTime::parse_from_rfc3339("2025-11-15T16:39:57+00:00").unwrap()
);
let timestamp = parse_timestamp_from_rfc3339("2025-11-15 16:39:57").unwrap();
assert_eq!(
timestamp,
DateTime::parse_from_rfc3339("2025-11-15T16:39:57+00:00").unwrap()
);
let timestamp = parse_timestamp_from_rfc3339("2025-11-15 16:39:57+02:00").unwrap();
assert_eq!(
timestamp,
DateTime::parse_from_rfc3339("2025-11-15T16:39:57+02:00").unwrap()
);
let result = parse_timestamp_from_rfc3339("2025-11-15 16:39");
assert_eq!(
result,
Err("expected character ':', but found end of string".to_string())
);
let result = parse_timestamp_from_rfc3339("2025-11-15 16:39:9");
assert_eq!(
result,
Err("expected at least 2 digits, but found 1".to_string())
);
let result = parse_timestamp_from_rfc3339("2025-11-15 16:39:99");
assert_eq!(result, Err("input is out of range".to_string()));
let result = parse_timestamp_from_rfc3339("2025-11-15q16:39:39");
assert_eq!(result, Err("invalid time designator 'q' in RFC3339 string; supported designators are 't', 'T', and ' '".to_string()));
}
}