use crate::error::{BzrError, Result};
pub fn parse_iso8601_or_date(s: &str, flag: &str) -> Result<String> {
match try_canonicalize(s) {
Some(canon) => Ok(canon),
None => Err(BzrError::InputValidation(format!(
"{flag}: '{s}' is not a valid ISO-8601 date or datetime.\n\
Expected: YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS, \
YYYY-MM-DDTHH:MM:SSZ, or YYYY-MM-DDTHH:MM:SS±HH:MM"
))),
}
}
pub fn parse_date_only(s: &str, flag: &str) -> Result<String> {
if s.is_ascii() && s.len() == 10 && parse_date(s).is_some() {
Ok(s.to_string())
} else {
Err(BzrError::InputValidation(format!(
"{flag}: '{s}' is not a valid date. Expected: YYYY-MM-DD"
)))
}
}
fn try_canonicalize(s: &str) -> Option<String> {
if !s.is_ascii() {
return None;
}
match s.len() {
10 => parse_date(s).map(|()| format!("{s}T00:00:00Z")),
19 => parse_datetime_naive(s).map(|()| s.to_string()),
20 => parse_datetime_z(s).map(|()| s.to_string()),
25 => parse_datetime_offset(s).map(|()| s.to_string()),
_ => None,
}
}
fn parse_date(s: &str) -> Option<()> {
let bytes = s.as_bytes();
if bytes[4] != b'-' || bytes[7] != b'-' {
return None;
}
if !bytes[..4].iter().all(u8::is_ascii_digit)
|| !bytes[5..7].iter().all(u8::is_ascii_digit)
|| !bytes[8..10].iter().all(u8::is_ascii_digit)
{
return None;
}
let month: u32 = s[5..7].parse().ok()?;
let day: u32 = s[8..10].parse().ok()?;
let year: u32 = s[..4].parse().ok()?;
let max_day = days_in_month(year, month)?;
if day == 0 || day > max_day {
return None;
}
Some(())
}
fn days_in_month(year: u32, month: u32) -> Option<u32> {
let days = match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => return None,
};
Some(days)
}
fn is_leap_year(year: u32) -> bool {
year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
}
fn parse_time(s: &str) -> Option<()> {
debug_assert_eq!(s.len(), 8, "parse_time requires exactly 8 bytes (HH:MM:SS)");
let bytes = s.as_bytes();
if bytes[2] != b':' || bytes[5] != b':' {
return None;
}
if !bytes[..2].iter().all(u8::is_ascii_digit)
|| !bytes[3..5].iter().all(u8::is_ascii_digit)
|| !bytes[6..8].iter().all(u8::is_ascii_digit)
{
return None;
}
let h: u32 = s[..2].parse().ok()?;
let m: u32 = s[3..5].parse().ok()?;
let sec: u32 = s[6..8].parse().ok()?;
if h > 23 || m > 59 || sec > 60 {
return None;
}
Some(())
}
fn parse_datetime_naive(s: &str) -> Option<()> {
if s.as_bytes()[10] != b'T' {
return None;
}
parse_date(&s[..10])?;
parse_time(&s[11..19])?;
Some(())
}
fn parse_datetime_z(s: &str) -> Option<()> {
if !s.ends_with('Z') {
return None;
}
parse_datetime_naive(&s[..19])?;
Some(())
}
fn parse_datetime_offset(s: &str) -> Option<()> {
parse_datetime_naive(&s[..19])?;
let bytes = s.as_bytes();
let sign = bytes[19];
if sign != b'+' && sign != b'-' {
return None;
}
if bytes[22] != b':' {
return None;
}
if !bytes[20..22].iter().all(u8::is_ascii_digit)
|| !bytes[23..25].iter().all(u8::is_ascii_digit)
{
return None;
}
let h: u32 = s[20..22].parse().ok()?;
let m: u32 = s[23..25].parse().ok()?;
if h > 14 || m > 59 || (h == 14 && m > 0) {
return None;
}
Some(())
}
#[cfg(test)]
#[path = "datetime_tests.rs"]
mod tests;