use jiff::Timestamp;
use jiff::civil::{Date, DateTime};
use jiff::tz::TimeZone;
use crate::error::{PondError, Result};
pub fn parse_time(text: &str) -> Result<i64> {
parse_time_in(text, "UTC")
}
pub fn parse_time_in(text: &str, time_zone: &str) -> Result<i64> {
let bad = |why: String| PondError::InvalidData {
call: "parse_time",
message: format!("cannot read {text:?} as a time: {why}"),
};
let zone = || match crate::sequence::fixed_offset(time_zone) {
Some(zone) => Ok(zone),
None => TimeZone::get(time_zone).map_err(|e| PondError::InvalidData {
call: "parse_time",
message: format!("unknown time zone {time_zone:?}: {e}"),
}),
};
let b = text.as_bytes();
let digits = |r: std::ops::Range<usize>| b[r].iter().all(u8::is_ascii_digit);
let num = |r: std::ops::Range<usize>| -> i32 {
b[r].iter().fold(0, |n, d| n * 10 + i32::from(d - b'0'))
};
let is_date = b.len() >= 10 && digits(0..4) && b[4] == b'-' && digits(5..7);
let is_date = is_date && b[7] == b'-' && digits(8..10);
if b.len() == 7 && digits(0..4) && b[4] == b'-' && digits(5..7) {
let date =
Date::new(num(0..4) as i16, num(5..7) as i8, 1).map_err(|e| bad(e.to_string()))?;
return start_of_day(date, &zone()?).map_err(bad);
}
if b.len() == 10 && is_date {
let date = Date::new(num(0..4) as i16, num(5..7) as i8, num(8..10) as i8)
.map_err(|e| bad(e.to_string()))?;
return start_of_day(date, &zone()?).map_err(bad);
}
if let Some(dt) = local_date_time(b, is_date) {
let (date, hour, minute, second, nano) = dt;
let date = Date::new(date.0, date.1, date.2).map_err(|e| bad(e.to_string()))?;
if second > 60 {
return Err(bad("seconds must be 0 to 60".into()));
}
let second = second.min(59);
let dt = DateTime::new(
date.year(),
date.month(),
date.day(),
hour,
minute,
second,
nano,
)
.map_err(|e| bad(e.to_string()))?;
let zoned = dt.to_zoned(zone()?).map_err(|e| bad(e.to_string()))?;
return Ok(floor_ms(zoned.timestamp()));
}
let bare = strip_annotations(text).map_err(|why| bad(why.into()))?;
offset_check(bare).map_err(|why| bad(why.into()))?;
let ts: Timestamp = bare.parse().map_err(|e: jiff::Error| bad(e.to_string()))?;
Ok(floor_ms(ts))
}
fn strip_annotations(text: &str) -> std::result::Result<&str, &'static str> {
let Some(start) = text.find('[') else {
return Ok(text);
};
let (bare, mut rest) = text.split_at(start);
let (mut first, mut calendars, mut critical_calendar) = (true, 0, false);
while !rest.is_empty() {
let end = rest.find(']').ok_or("a '[' note is not closed")?;
let note = &rest[1..end];
rest = &rest[end + 1..];
if !rest.is_empty() && !rest.starts_with('[') {
return Err("text after a ']' note");
}
let (critical, note) = match note.strip_prefix('!') {
Some(n) => (true, n),
None => (false, note),
};
match note.split_once('=') {
Some((key, value)) => {
let key_ok = key
.bytes()
.next()
.is_some_and(|b| b.is_ascii_lowercase() || b == b'_')
&& key.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-'
});
let value_ok = !value.is_empty()
&& value
.split('-')
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_alphanumeric()));
if !key_ok || !value_ok {
return Err("a malformed '[key=value]' note");
}
if key == "u-ca" {
calendars += 1;
critical_calendar |= critical;
} else if critical {
return Err("a critical '[!…]' note of an unknown kind");
}
}
None => {
let zone_ok = if note.starts_with(['+', '-']) {
crate::sequence::fixed_offset(note).is_some()
} else {
note.split('/').all(|part| {
part.bytes()
.next()
.is_some_and(|b| b.is_ascii_alphabetic() || b == b'.' || b == b'_')
&& part
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._+-".contains(&b))
})
};
if !first || !zone_ok {
return Err("a malformed '[zone]' note");
}
}
}
first = false;
}
if calendars > 1 && critical_calendar {
return Err("a critical calendar note with a second calendar note");
}
Ok(bare)
}
fn offset_check(text: &str) -> std::result::Result<(), &'static str> {
let Some(time_at) = text.find(['T', 't', ' ']) else {
return Ok(());
};
let time = &text[time_at..];
let Some(sign_at) = time.rfind(['+', '-', '\u{2212}']) else {
return Ok(());
};
let offset = &time[sign_at..];
let offset = offset.trim_start_matches(['+', '-', '\u{2212}']);
if offset
.get(..2)
.and_then(|h| h.parse::<u8>().ok())
.is_some_and(|h| h > 23)
{
return Err("offset hours must be 0 to 23");
}
if offset.contains(['.', ',']) {
return Err("offsets with fractions of a second are not supported");
}
Ok(())
}
fn start_of_day(date: Date, zone: &TimeZone) -> std::result::Result<i64, String> {
let zoned = date
.to_zoned(zone.clone())
.and_then(|z| z.start_of_day())
.map_err(|e| e.to_string())?;
Ok(floor_ms(zoned.timestamp()))
}
type LocalParts = ((i16, i8, i8), i8, i8, i8, i32);
fn local_date_time(b: &[u8], is_date: bool) -> Option<LocalParts> {
let digits =
|r: std::ops::Range<usize>| b.get(r).is_some_and(|s| s.iter().all(u8::is_ascii_digit));
let num = |r: std::ops::Range<usize>| -> i32 {
b[r].iter().fold(0, |n, d| n * 10 + i32::from(d - b'0'))
};
if !is_date || b.len() < 16 || !matches!(b[10], b'T' | b' ') {
return None;
}
if !(digits(11..13) && b[13] == b':' && digits(14..16)) {
return None;
}
let (mut second, mut nano) = (0, 0);
match b.len() {
16 => {}
19.. if b[16] == b':' && digits(17..19) => {
second = num(17..19);
if b.len() > 19 {
let fraction = &b[20..];
if b[19] != b'.' || fraction.is_empty() || fraction.len() > 9 {
return None;
}
if !fraction.iter().all(u8::is_ascii_digit) {
return None;
}
let n = fraction.iter().fold(0, |n, d| n * 10 + i32::from(d - b'0'));
nano = n * 10_i32.pow(9 - fraction.len() as u32);
}
}
_ => return None,
}
let date = (num(0..4) as i16, num(5..7) as i8, num(8..10) as i8);
Some((
date,
num(11..13) as i8,
num(14..16) as i8,
second as i8,
nano,
))
}
fn floor_ms(ts: Timestamp) -> i64 {
ts.as_nanosecond().div_euclid(1_000_000) as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn differences_from_pond_ts_are_errors() {
assert!(parse_time("-271821-04-20T00:00Z").is_err());
assert!(parse_time("9999-12-31").is_err());
let e = parse_time("2025-01-01T09:00:00+00:00:00.5").unwrap_err();
assert!(e.to_string().contains("fractions of a second"), "{e}");
}
#[test]
fn odd_text_never_panics() {
let base = [
"2025-03",
"2025-03-30",
"2025-03-30T02:30:00.123456789",
"2025-01-01T09:00:00+01:00[Europe/Madrid]",
];
for text in base {
for (at, _) in text.char_indices().chain([(text.len(), ' ')]) {
let _ = parse_time(&text[..at]);
for c in ['é', '\u{2212}', '+', '-', 'T', ' ', '9', '.', '['] {
let mut s = text.to_string();
s.insert(at, c);
let _ = parse_time_in(&s, "Europe/Madrid");
}
}
}
}
#[test]
fn errors_say_what_could_not_be_read() {
let e = parse_time("2025-1-1").unwrap_err().to_string();
assert!(e.starts_with("parse_time: cannot read \"2025-1-1\""), "{e}");
let e = parse_time_in("2025-01-01", "Mars/Base")
.unwrap_err()
.to_string();
assert!(e.contains("unknown time zone \"Mars/Base\""), "{e}");
}
}