const MAX_CALENDAR_YEAR: i64 = 999_999;
pub fn parse(field: &str) -> Option<f64> {
let text = field.trim();
if text.is_empty() {
return None;
}
if let Ok(number) = text.parse::<f64>() {
if !number.is_finite() {
return None;
}
return Some(if number.abs() >= 1e11 {
number / 1000.0
} else {
number
});
}
parse_iso(text)
}
fn parse_iso(text: &str) -> Option<f64> {
let (date, rest) = match text.split_once(['T', ' ']) {
Some((date, rest)) => (date, Some(rest)),
None => (text, None),
};
let mut parts = date.split('-');
let year: i64 = parts.next()?.parse().ok()?;
let month: i64 = two(parts.next()?)?;
let day: i64 = two(parts.next()?)?;
if parts.next().is_some()
|| !(0..=MAX_CALENDAR_YEAR).contains(&year)
|| !(1..=12).contains(&month)
|| !(1..=days_in_month(year, month)?).contains(&day)
{
return None;
}
let mut seconds = days_from_civil(year, month, day)? as f64 * 86_400.0;
if let Some(rest) = rest {
let (clock, offset) = split_timezone(rest)?;
seconds += clock_seconds(clock)?;
seconds -= offset;
}
Some(seconds)
}
fn split_timezone(rest: &str) -> Option<(&str, f64)> {
if let Some(clock) = rest.strip_suffix('Z') {
return Some((clock, 0.0));
}
if let Some(sign) = rest.find(['+', '-']) {
let (clock, zone) = rest.split_at(sign);
let (zh, zm) = zone[1..].split_once(':')?;
let hours = two(zh)?;
let minutes = two(zm)?;
if !(0..=23).contains(&hours) || !(0..=59).contains(&minutes) {
return None;
}
let offset = (hours * 3600 + minutes * 60) as f64;
let offset = if zone.starts_with('-') {
-offset
} else {
offset
};
return Some((clock, offset));
}
Some((rest, 0.0))
}
fn clock_seconds(clock: &str) -> Option<f64> {
let mut parts = clock.split(':');
let hour = two(parts.next()?)?;
let minute = two(parts.next()?)?;
if !(0..=23).contains(&hour) || !(0..=59).contains(&minute) {
return None;
}
let mut seconds = (hour * 3600 + minute * 60) as f64;
if let Some(sec) = parts.next() {
let value = second(sec)?;
if !(0.0..60.0).contains(&value) {
return None;
}
seconds += value;
}
if parts.next().is_some() {
return None;
}
Some(seconds)
}
fn second(text: &str) -> Option<f64> {
let (whole, fraction) = match text.split_once('.') {
Some((whole, fraction)) if !fraction.is_empty() => (whole, Some(fraction)),
Some(_) => return None,
None => (text, None),
};
if two(whole).is_none()
|| fraction.is_some_and(|digits| !digits.bytes().all(|b| b.is_ascii_digit()))
{
return None;
}
text.parse().ok()
}
fn two(text: &str) -> Option<i64> {
if text.is_empty() || text.len() > 2 || !text.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
text.parse().ok()
}
fn days_in_month(year: i64, month: i64) -> Option<i64> {
Some(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,
})
}
fn is_leap_year(year: i64) -> bool {
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}
fn days_from_civil(y: i64, m: i64, d: i64) -> Option<i64> {
let y = i128::from(y) - i128::from(m <= 2);
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let shifted_month = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * i128::from(shifted_month) + 2) / 5 + i128::from(d) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
(era * 146_097 + doe - 719_468).try_into().ok()
}
#[cfg(test)]
#[path = "tests/time_tests.rs"]
mod tests;