fn digits(b: &[u8]) -> Option<u32> {
if b.is_empty() || b.len() > 9 || !b.iter().all(u8::is_ascii_digit) {
return None;
}
Some(b.iter().fold(0, |acc, d| acc * 10 + u32::from(d - b'0')))
}
fn days_in_month(year: u32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 => 29,
2 => 28,
_ => 0,
}
}
fn date(b: &[u8]) -> bool {
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
return false;
}
match (digits(&b[..4]), digits(&b[5..7]), digits(&b[8..])) {
(Some(y), Some(m), Some(d)) => (1..=12).contains(&m) && d >= 1 && d <= days_in_month(y, m),
_ => false,
}
}
fn time(b: &[u8], require_secs: bool) -> bool {
if b.len() < 5 || b[2] != b':' {
return false;
}
let (Some(h), Some(m)) = (digits(&b[..2]), digits(&b[3..5])) else {
return false;
};
if h > 23 || m > 59 {
return false;
}
let rest = &b[5..];
if rest.is_empty() {
return !require_secs;
}
if rest.len() < 3 || rest[0] != b':' {
return false;
}
match digits(&rest[1..3]) {
Some(s) if s <= 59 => {}
_ => return false,
}
let frac = &rest[3..];
frac.is_empty()
|| (frac[0] == b'.' && (2..=10).contains(&frac.len()) && digits(&frac[1..]).is_some())
}
fn offset_suffix(b: &[u8]) -> Option<(usize, Option<i32>)> {
match b.last() {
Some(b'Z' | b'z') => return Some((1, Some(0))),
None => return Some((0, None)),
_ => {}
}
if b.len() < 6 {
return Some((0, None));
}
let o = &b[b.len() - 6..];
let sign = match o[0] {
b'+' => 1,
b'-' => -1,
_ => return Some((0, None)),
};
if o[3] != b':' {
return None;
}
match (digits(&o[1..3]), digits(&o[4..])) {
(Some(h), Some(m)) if h <= 23 && m <= 59 => {
Some((6, Some(sign * (h * 3600 + m * 60) as i32)))
}
_ => None,
}
}
pub(crate) fn is_valid_iso_date(s: &str) -> bool {
date(s.as_bytes())
}
pub(crate) fn is_valid_iso_time(s: &str) -> bool {
time(s.as_bytes(), false)
}
#[cfg(any(feature = "chrono", feature = "jiff", feature = "time"))]
pub(crate) fn is_valid_time_of_day(s: &str) -> bool {
time(s.as_bytes(), true)
}
pub(crate) fn is_valid_iso_datetime(s: &str) -> bool {
split_datetime(s).is_some()
}
pub(crate) fn split_datetime(s: &str) -> Option<(String, Option<i32>)> {
let b = s.as_bytes();
if b.len() < 19 || !matches!(b[10], b'T' | b't') || !date(&b[..10]) {
return None;
}
let (n, offset) = offset_suffix(&b[11..])?;
let end = b.len() - n;
if !time(&b[11..end], true) {
return None;
}
Some((format!("{}T{}", &s[..10], &s[11..end]), offset))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_ascii_never_panics() {
for s in [
"123é56789",
"1é:00",
"2024-01-01T",
"2024-01-01Té",
"2024-01-01T1é:00:00Z",
"é",
] {
assert!(!is_valid_iso_date(s));
assert!(!is_valid_iso_time(s));
assert!(!is_valid_iso_datetime(s));
}
}
#[test]
fn calendar_and_offsets() {
assert!(is_valid_iso_date("2024-02-29"));
assert!(!is_valid_iso_date("2023-02-29"));
assert!(!is_valid_iso_date("2024-02-31"));
assert!(!is_valid_iso_date("2024-04-31"));
assert!(is_valid_iso_datetime("2024-01-01T10:00:00+03:00"));
assert!(is_valid_iso_datetime("2024-01-01t10:00:00.5z"));
assert!(!is_valid_iso_datetime("2024-01-01T10:00+03:00"));
assert!(!is_valid_iso_datetime("2024-01-01T10:00:00+garbage"));
assert!(!is_valid_iso_datetime("2024-01-01T10:00:00Zjunk"));
assert!(!is_valid_iso_datetime("2024-01-01T10:00:00+25:00"));
}
}