use super::parse::date_parse;
const NAMED_ZONES: [(&str, &str); 6] = [
("CEST", "+0200"),
("AEST", "+1000"),
("CET", "+0100"),
("BST", "+0100"),
("JST", "+0900"),
("IST", "+0530"),
];
pub(crate) fn instant(value: &str) -> Option<i64> {
if let Some(timestamp) = date_parse(value) {
return Some(timestamp);
}
date_parse(&with_numeric_zone(value)?)
}
fn with_numeric_zone(value: &str) -> Option<String> {
let bytes = value.as_bytes();
let mut word_start = None;
for index in 0..=bytes.len() {
let is_letter = bytes.get(index).is_some_and(u8::is_ascii_alphabetic);
match (word_start, is_letter) {
(None, true) => word_start = Some(index),
(Some(start), false) => {
let word = &value[start..index];
if let Some((_, offset)) = NAMED_ZONES
.iter()
.find(|(name, _)| word.eq_ignore_ascii_case(name))
{
return Some(format!("{}{offset}{}", &value[..start], &value[index..]));
}
word_start = None;
}
_ => {}
}
}
None
}
fn digits(text: &str, width: usize) -> Option<i64> {
if text.len() != width || !text.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
text.parse().ok()
}
pub(crate) fn week_date(value: &str) -> Option<i64> {
let (year_text, rest) = value.split_once("-W")?;
let (week_text, day_text) = match rest.split_once('-') {
Some((week, day)) => (week, Some(day)),
None => (rest, None),
};
let year = digits(year_text, 4)?;
let week = digits(week_text, 2)?;
let day = match day_text {
Some(text) => digits(text, 1)?,
None => 1,
};
if !(1..=weeks_in_year(year)).contains(&week) || !(1..=7).contains(&day) {
return None;
}
let first_monday = 5 - january_fourth_weekday(year);
resolve_ordinal(year, first_monday + (week - 1) * 7 + (day - 1))
}
pub(crate) fn ordinal_date(value: &str) -> Option<i64> {
let (year_text, ordinal_text) = value.split_once('-')?;
let year = digits(year_text, 4)?;
let ordinal = digits(ordinal_text, 3)?;
let (month, day) = month_and_day(year, ordinal)?;
date_parse(&format!("{year:04}-{month:02}-{day:02}"))
}
pub(crate) fn basic_format(value: &str) -> Option<i64> {
let year = digits(value.get(0..4)?, 4)?;
let month = digits(value.get(4..6)?, 2)?;
let day = digits(value.get(6..8)?, 2)?;
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
let date = format!("{year:04}-{month:02}-{day:02}");
let rest = value.get(8..)?;
if rest.is_empty() {
if !(1900..=2099).contains(&year) {
return None;
}
return date_parse(&date);
}
let time = rest.strip_prefix('T')?;
let hour = time.get(0..2)?;
let minute = time.get(2..4)?;
let second = time.get(4..6)?;
let mut normalised = format!("{date}T{hour}:{minute}:{second}");
let mut tail = time.get(6..)?;
if let Some(fraction) = tail.strip_prefix('.') {
let digits: String = fraction.chars().take_while(char::is_ascii_digit).collect();
normalised.push('.');
normalised.push_str(&digits);
tail = tail.get(1 + digits.len()..)?;
}
normalised.push_str(&extended_zone(tail)?);
date_parse(&normalised)
}
fn extended_zone(tail: &str) -> Option<String> {
if tail.is_empty() || tail == "Z" {
return Some(tail.to_string());
}
let sign = tail.get(0..1).filter(|sign| *sign == "+" || *sign == "-")?;
let hour = tail.get(1..3)?;
let minute = match tail.len() {
3 => "00",
5 => tail.get(3..5)?,
_ => return None,
};
Some(format!("{sign}{hour}:{minute}"))
}
fn resolve_ordinal(mut year: i64, mut ordinal: i64) -> Option<i64> {
if ordinal < 1 {
year -= 1;
ordinal += days_in_year(year);
} else if ordinal > days_in_year(year) {
ordinal -= days_in_year(year);
year += 1;
}
let (month, day) = month_and_day(year, ordinal)?;
date_parse(&format!("{year:04}-{month:02}-{day:02}"))
}
fn month_and_day(year: i64, ordinal: i64) -> Option<(i64, i64)> {
if !(1..=days_in_year(year)).contains(&ordinal) {
return None;
}
let february = if is_leap(year) { 29 } else { 28 };
let lengths = [31, february, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut remaining = ordinal;
for (index, length) in lengths.into_iter().enumerate() {
if remaining <= length {
return Some((index as i64 + 1, remaining));
}
remaining -= length;
}
None
}
fn is_leap(year: i64) -> bool {
year.rem_euclid(4) == 0 && (year.rem_euclid(100) != 0 || year.rem_euclid(400) == 0)
}
fn days_in_year(year: i64) -> i64 {
if is_leap(year) { 366 } else { 365 }
}
fn last_day_of_year(year: i64) -> i64 {
(year + year.div_euclid(4) - year.div_euclid(100) + year.div_euclid(400)).rem_euclid(7)
}
fn january_fourth_weekday(year: i64) -> i64 {
(last_day_of_year(year - 1) + 3).rem_euclid(7) + 1
}
fn weeks_in_year(year: i64) -> i64 {
if last_day_of_year(year) == 4 || last_day_of_year(year - 1) == 3 {
53
} else {
52
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::time;
fn in_new_york<T>(body: impl FnOnce() -> T) -> T {
let zone: chrono_tz::Tz = "America/New_York".parse().expect("a real timezone");
time::with_zone(zone, body)
}
const JANUARY_FIFTEENTH: i64 = 1_705_276_800_000;
#[test]
fn a_week_date_is_the_monday_of_that_week() {
assert_eq!(
in_new_york(|| week_date("2024-W03")),
Some(JANUARY_FIFTEENTH)
);
assert_eq!(
in_new_york(|| week_date("2024-W03-1")),
Some(JANUARY_FIFTEENTH)
);
}
#[test]
fn the_day_of_the_week_moves_it() {
let monday = in_new_york(|| week_date("2024-W03-1")).expect("a monday");
let sunday = in_new_york(|| week_date("2024-W03-7")).expect("a sunday");
assert_eq!(sunday - monday, 6 * 86_400_000);
}
#[test]
fn a_week_can_begin_in_the_previous_year_and_end_in_the_next() {
assert_eq!(
in_new_york(|| week_date("2015-W01-1")),
in_new_york(|| date_parse("2014-12-29"))
);
assert_eq!(
in_new_york(|| week_date("2020-W53-7")),
in_new_york(|| date_parse("2021-01-03"))
);
}
#[test]
fn a_year_without_a_fifty_third_week_refuses_one() {
assert_eq!(weeks_in_year(2020), 53, "2020 has one");
assert_eq!(weeks_in_year(2024), 52, "2024 does not");
assert_eq!(in_new_york(|| week_date("2024-W53")), None);
assert!(in_new_york(|| week_date("2020-W53")).is_some());
}
#[test]
fn a_week_or_a_day_outside_its_range_is_a_refusal() {
for value in ["2024-W00", "2024-W99", "2024-W03-0", "2024-W03-8"] {
assert_eq!(in_new_york(|| week_date(value)), None, "{value}");
}
}
#[test]
fn an_ordinal_date_counts_days_from_the_first() {
assert_eq!(
in_new_york(|| ordinal_date("2024-015")),
Some(JANUARY_FIFTEENTH)
);
assert_eq!(
in_new_york(|| ordinal_date("2024-001")),
in_new_york(|| date_parse("2024-01-01"))
);
}
#[test]
fn the_sixtieth_day_depends_on_the_leap_year() {
assert_eq!(
in_new_york(|| ordinal_date("2024-060")),
in_new_york(|| date_parse("2024-02-29"))
);
assert_eq!(
in_new_york(|| ordinal_date("2023-060")),
in_new_york(|| date_parse("2023-03-01"))
);
}
#[test]
fn a_day_the_year_does_not_have_is_a_refusal() {
assert_eq!(in_new_york(|| ordinal_date("2023-366")), None);
assert!(in_new_york(|| ordinal_date("2024-366")).is_some());
assert_eq!(in_new_york(|| ordinal_date("2024-000")), None);
assert_eq!(in_new_york(|| ordinal_date("2024-400")), None);
assert_eq!(in_new_york(|| ordinal_date("nonsense")), None);
}
#[test]
fn the_basic_date_form_is_the_extended_one() {
assert_eq!(
in_new_york(|| basic_format("20240115")),
Some(JANUARY_FIFTEENTH)
);
}
#[test]
fn the_basic_date_time_form_keeps_every_zone_rule() {
for (basic, extended) in [
("20240115T103045Z", "2024-01-15T10:30:45Z"),
("20240115T103045", "2024-01-15T10:30:45"),
("20240115T103045.123Z", "2024-01-15T10:30:45.123Z"),
("20240115T103045+0530", "2024-01-15T10:30:45+05:30"),
("20240115T103045-0800", "2024-01-15T10:30:45-08:00"),
("20240115T103045+05", "2024-01-15T10:30:45+05:00"),
] {
assert_eq!(
in_new_york(|| basic_format(basic)),
in_new_york(|| date_parse(extended)),
"{basic}"
);
}
}
#[test]
fn an_eight_digit_run_outside_the_plausible_years_is_not_a_date() {
assert_eq!(in_new_york(|| basic_format("98765432")), None);
assert_eq!(in_new_york(|| basic_format("18991231")), None);
assert!(in_new_york(|| basic_format("19000101")).is_some());
assert!(in_new_york(|| basic_format("20991231")).is_some());
}
#[test]
fn a_month_or_day_that_cannot_exist_is_a_refusal() {
for value in ["20245601", "20240132", "20240100", "20241301"] {
assert_eq!(in_new_york(|| basic_format(value)), None, "{value}");
}
}
#[test]
fn a_numeral_of_the_wrong_width_is_a_refusal() {
for value in [
"-7531464512345672024-W03-1",
"99999-W03-1",
"999-W03-1",
"2024-W3-1",
"2024-W003-1",
"+024-W03-1",
] {
assert_eq!(in_new_york(|| week_date(value)), None, "{value}");
}
for value in ["99999-015", "2024-15", "2024-0015", "-999-015"] {
assert_eq!(in_new_york(|| ordinal_date(value)), None, "{value}");
}
assert_eq!(in_new_york(|| basic_format("+0240115")), None);
}
#[test]
fn a_malformed_basic_string_is_a_refusal() {
for value in [
"2024",
"20240115X103045",
"20240115T1030",
"20240115T103045+0",
] {
assert_eq!(in_new_york(|| basic_format(value)), None, "{value}");
}
}
#[test]
fn the_year_window_does_not_reach_the_date_time_form() {
assert!(in_new_york(|| basic_format("18991231T103045Z")).is_some());
}
#[test]
fn a_zone_v8_does_not_know_resolves_to_its_fixed_offset() {
for (abbreviation, offset) in [
("CEST", "+0200"),
("CET", "+0100"),
("BST", "+0100"),
("JST", "+0900"),
("AEST", "+1000"),
("IST", "+0530"),
] {
let named = format!("Mon, 15 Jan 2024 10:30:45 {abbreviation}");
let numeric = format!("Mon, 15 Jan 2024 10:30:45 {offset}");
assert_eq!(
in_new_york(|| instant(&named)),
in_new_york(|| date_parse(&numeric)),
"{abbreviation}"
);
}
}
#[test]
fn a_string_v8_reads_is_left_alone() {
let value = "Mon, 15 Jan 2024 10:30:45 EST";
assert_eq!(
in_new_york(|| instant(value)),
in_new_york(|| date_parse(value))
);
}
#[test]
fn an_abbreviation_inside_a_longer_word_is_not_a_zone() {
assert_eq!(
in_new_york(|| instant("Mon, 15 Jan 2024 10:30:45 HISTORY")),
None
);
assert_eq!(in_new_york(|| with_numeric_zone("HISTORY")), None);
}
#[test]
fn a_string_with_no_zone_word_at_all_is_still_a_refusal() {
assert_eq!(in_new_york(|| instant("not a date")), None);
assert_eq!(in_new_york(|| with_numeric_zone("2024")), None);
}
#[test]
fn the_rewrite_keeps_the_rest_of_the_string() {
assert_eq!(
with_numeric_zone("Mon, 15 Jan 2024 10:30:45 CEST").as_deref(),
Some("Mon, 15 Jan 2024 10:30:45 +0200")
);
assert_eq!(
with_numeric_zone("cest at the front").as_deref(),
Some("+0200 at the front")
);
}
#[test]
fn the_calendar_helpers_agree_with_the_calendar() {
assert!(is_leap(2024) && is_leap(2000));
assert!(!is_leap(2023) && !is_leap(1900));
assert_eq!(days_in_year(2024), 366);
assert_eq!(days_in_year(2023), 365);
assert_eq!(january_fourth_weekday(2024), 4);
assert_eq!(january_fourth_weekday(2021), 1);
assert_eq!(january_fourth_weekday(2015), 7);
assert_eq!(month_and_day(2024, 1), Some((1, 1)));
assert_eq!(month_and_day(2024, 366), Some((12, 31)));
assert_eq!(month_and_day(2024, 367), None);
}
}