use regex::Regex;
use std::sync::LazyLock;
pub(crate) static FRAC: &str = r"\.[0-9]+";
pub(crate) static ISO_BODY: LazyLock<String> = LazyLock::new(|| {
format!(
r"[0-9]{{4}}-[0-9]{{2}}-[0-9]{{2}}(?:[ T][0-9]{{2}}:[0-9]{{2}}(?::[0-9]{{2}}(?:{FRAC})?)?(?:Z|[+-][0-9]{{2}}:?[0-9]{{2}})?)?"
)
});
static FULL_ISO_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!("^(?:{})$", *ISO_BODY)).unwrap()
});
pub(crate) static TRIM_BODY: LazyLock<String> = LazyLock::new(|| {
format!(
r"[0-9]{{4}}-[0-9]{{2}}-[0-9]{{2}}[ T][0-9]{{2}}:[0-9]{{2}}(?::[0-9]{{2}}(?:{FRAC})?)?(?:Z|[+-][0-9]{{2}}:?[0-9]{{2}})?"
)
});
pub(crate) const MAX_FRAC: usize = 19;
const ZEROS: &str = "0000000000000000000"; const NINES: &str = "9999999999999999999"; const _: () = assert!(ZEROS.len() == MAX_FRAC && NINES.len() == MAX_FRAC);
fn all_digits(b: &[u8]) -> bool {
b.iter().all(|c| c.is_ascii_digit())
}
fn strip_tz(out: &mut String) {
let n = out.len();
if out.ends_with('Z') {
out.truncate(n - 1);
return;
}
if n < 6 {
return;
}
let b = out.as_bytes();
let sign = |i: usize| b[i] == b'+' || b[i] == b'-';
let six = n - 6;
let five = n - 5;
if sign(six) && all_digits(&b[six + 1..n - 3]) && b[n - 3] == b':' && all_digits(&b[n - 2..]) {
out.truncate(six); } else if sign(five) && all_digits(&b[five + 1..]) {
out.truncate(five); }
}
fn is_eod_24(b: &[u8]) -> bool {
b[11] == b'2' && b[12] == b'4' && b[13] == b':'
&& b[14] == b'0' && b[15] == b'0'
&& b.get(16) != Some(&b'.')
&& (b.len() < 19 || b[16] != b':' || (b[17] == b'0' && b[18] == b'0'))
}
pub(crate) fn is_full_iso(s: &str) -> bool {
if !FULL_ISO_RE.is_match(s) {
return false;
}
let b = s.as_bytes();
let m = ((b[5] - b'0') as u32) * 10 + (b[6] - b'0') as u32;
let d = ((b[8] - b'0') as u32) * 10 + (b[9] - b'0') as u32;
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return false;
}
if b.len() > 10 {
let h = ((b[11] - b'0') as u32) * 10 + (b[12] - b'0') as u32;
if h > 24 {
return false;
}
if h == 24 && !is_eod_24(b) {
return false;
}
let mi = ((b[14] - b'0') as u32) * 10 + (b[15] - b'0') as u32;
if h <= 23 && !(0..=59).contains(&mi) {
return false;
}
if h <= 23 && b.len() >= 19 && b[16] == b':' {
let sec = ((b[17] - b'0') as u32) * 10 + (b[18] - b'0') as u32;
if sec > 59 {
return false;
}
}
}
true
}
pub(crate) fn to_canonical(s: &str, out: &mut String) {
out.clear();
if s.len() > 10 && s.as_bytes()[10] == b' ' {
out.push_str(&s[..10]);
out.push('T');
out.push_str(&s[11..]);
} else {
out.push_str(s);
}
strip_tz(out);
if out.len() == 10 {
out.push_str("T00:00:00");
out.push('.');
out.push_str(ZEROS);
assert_canonical_shape(out);
return;
}
if out.len() >= 16 && is_eod_24(out.as_bytes()) {
let residual = if out.len() > 19 { out.split_off(19) } else { String::new() };
let next = next_day(&out[..10]);
out.clear();
match next {
Some(d) => {
out.push_str(&d);
out.push_str("T00:00:00");
out.push_str(&residual);
}
None => max_instant(out),
}
}
let res = {
let b = out.as_bytes();
out.len() >= 16 && b[10] == b'T' && b[13] == b':' && b.get(16) != Some(&b':')
}; if res {
assert!(out.len() == 16, "frac-on-minutes drift: {out:?}");
out.insert_str(16, ":00");
}
if let Some(i) = out.find('.') {
let run = out.len() - i - 1;
if run < MAX_FRAC {
out.push_str(&ZEROS[..MAX_FRAC - run]);
} else {
out.truncate(i + 1 + MAX_FRAC);
}
} else {
out.push('.');
out.push_str(ZEROS);
}
assert_canonical_shape(out);
}
pub(crate) fn normalize_iso<'a>(ts: &'a str, buf: &'a mut String) -> &'a str {
to_canonical(ts, buf);
buf.as_str()
}
pub(crate) fn canonical_bound(s: &str, until: bool) -> String {
if until && s.len() == 10 {
let mut out = String::new();
match next_day(s) {
Some(d) => to_canonical(&d, &mut out),
None => max_instant(&mut out),
}
return out;
}
let mut out = String::new();
to_canonical(s, &mut out);
out
}
pub(crate) fn next_day(date: &str) -> Option<String> {
let (mut y, mut m, mut d) = (
date[..4].parse::<u16>().unwrap_or(0),
date[5..7].parse::<u8>().unwrap_or(12),
date[8..10].parse::<u8>().unwrap_or(31),
);
d += 1;
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
let dim: u8 = match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
2 if leap => 29,
2 => 28,
_ => 30,
};
if d > dim {
d = 1;
m += 1;
if m > 12 { m = 1; y += 1; }
}
if y > 9999 {
return None; }
Some(format!("{:04}-{:02}-{:02}", y, m, d))
}
pub(crate) fn max_instant(out: &mut String) {
to_canonical("9999-12-31T23:59:59", out); out.replace_range(20.., NINES);
}
pub(crate) fn assert_canonical_shape(canonical: &str) {
let b = canonical.as_bytes();
let ok = b.len() == 19 + 1 + MAX_FRAC
&& all_digits(&b[0..4]) && b[4] == b'-'
&& all_digits(&b[5..7]) && b[7] == b'-'
&& all_digits(&b[8..10]) && b[10] == b'T'
&& all_digits(&b[11..13]) && b[13] == b':'
&& all_digits(&b[14..16]) && b[16] == b':'
&& all_digits(&b[17..19]) && b[19] == b'.'
&& all_digits(&b[20..]);
assert!(ok, "canonical shape drift: {canonical:?}");
}
pub(crate) fn until_server_bound(canonical: &str) -> Option<String> {
assert_canonical_shape(canonical);
let mut date = canonical[..10].to_string();
let mut h: u32 = canonical[11..13].parse().expect("canonical hour");
let mut m: u32 = canonical[14..16].parse().expect("canonical minute");
let mut s: u32 = canonical[17..19].parse().expect("canonical second");
s += 1;
if s == 60 { s = 0; m += 1; }
if m == 60 { m = 0; h += 1; }
if h == 24 {
h = 0;
date = next_day(&date)?;
}
Some(format!("{date}T{h:02}:{m:02}:{s:02}"))
}
pub(crate) fn server_ts(canonical: &str) -> &str {
assert_canonical_shape(canonical);
&canonical[..19]
}
#[cfg(test)]
mod tests {
use super::next_day;
#[test]
fn next_day_rollovers() {
assert_eq!(next_day("2024-02-28").as_deref(), Some("2024-02-29"));
assert_eq!(next_day("2024-02-29").as_deref(), Some("2024-03-01"));
assert_eq!(next_day("2025-02-28").as_deref(), Some("2025-03-01")); assert_eq!(next_day("2026-04-30").as_deref(), Some("2026-05-01")); assert_eq!(next_day("9999-12-31").as_deref(), None); }
}