//! Civil-date arithmetic on ISO "YYYY-MM-DD" strings. //! //! No `chrono`, no `time`, no crate at all: the standard library is enough //! once the arithmetic is done on integers, and a registry whose whole promise //! is "the same answer in three languages" cannot outsource the answer to a //! different library in each one. //! //! The kernel is the days-from-civil / civil-from-days pair: a calendar date is //! converted to a day number counted from 1970-01-01, shifted, and converted //! back. Every division below has non-negative operands inside the supported //! year range, so Rust's truncating division agrees exactly with Python's floor //! division and with Math.floor in TypeScript. That is what makes the three //! implementations transliterations of one another rather than three guesses. use super::funejson::Value; /// 0001-01-01 and 9999-12-31 as epoch days: the range a 4-digit ISO year can express. pub const MIN_EPOCH_DAY: i64 = -719162; pub const MAX_EPOCH_DAY: i64 = 2932896; const MONTH_LENGTHS: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; /// The Gregorian rule in full: every 4th year, except every 100th, except every /// 400th. 1900 was not a leap year and 2000 was, and code that only tests /// `year % 4` gets one of those two wrong. pub fn is_leap_year(year: i64) -> bool { year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) } /// # Panics /// Panics if the month is outside 1-12. pub fn days_in_month(year: i64, month: i64) -> i64 { if !(1..=12).contains(&month) { panic!("month must be 1-12, received {}", month); } if month == 2 && is_leap_year(year) { return 29; } MONTH_LENGTHS[(month - 1) as usize] } fn is_digits(bytes: &[u8], start: usize, stop: usize) -> bool { bytes[start..stop].iter().all(|b| b.is_ascii_digit()) } fn digits_to_i64(bytes: &[u8], start: usize, stop: usize) -> i64 { let mut value = 0i64; for b in &bytes[start..stop] { value = value * 10 + i64::from(b - b'0'); } value } /// Parse and validate an ISO date. /// /// The two shapes of bad input are rejected separately: a string that is not an /// ISO date at all, and a well-formed string naming a day that never existed /// (2026-02-30). The second is the dangerous one, because a permissive parser /// turns it into 2026-03-02 and nobody notices. /// /// # Panics /// Panics on a malformed date, an impossible date, or a year outside 0001-9999. pub fn parse_iso_date(iso: &str) -> CivilDate { let bytes = iso.as_bytes(); if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' || !is_digits(bytes, 0, 4) || !is_digits(bytes, 5, 7) || !is_digits(bytes, 8, 10) { panic!("\"{}\" is not an ISO date (YYYY-MM-DD)", iso); } let year = digits_to_i64(bytes, 0, 4); let month = digits_to_i64(bytes, 5, 7); let day = digits_to_i64(bytes, 8, 10); if year < 1 { panic!( "\"{}\" is outside the supported range 0001-01-01 to 9999-12-31", iso ); } if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) { panic!("\"{}\" is not a real calendar date", iso); } CivilDate { year, month, day } } pub fn format_iso_date(date: &CivilDate) -> String { format!("{:04}-{:02}-{:02}", date.year, date.month, date.day) } /// Days from 1970-01-01 to a civil date. Assumes the date has been validated. pub fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { // March-based years put the leap day last, so the month-length pattern // becomes a simple linear formula and no special case for February is needed. let y = year - if month <= 2 { 1 } else { 0 }; let era = y / 400; let year_of_era = y - era * 400; let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1; let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; era * 146097 + day_of_era - 719468 } /// The exact inverse of `days_from_civil`. pub fn civil_from_days(epoch_day: i64) -> CivilDate { // 146097 days is exactly 400 years, which is why the Gregorian calendar // repeats on that cycle and why this conversion needs no lookup table. let z = epoch_day + 719468; let era = z / 146097; let day_of_era = z - era * 146097; let year_of_era = (day_of_era - day_of_era / 1460 + day_of_era / 36524 - day_of_era / 146096) / 365; let y = year_of_era + era * 400; let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); let month_prime = (5 * day_of_year + 2) / 153; let day = day_of_year - (153 * month_prime + 2) / 5 + 1; let month = month_prime + if month_prime < 10 { 3 } else { -9 }; CivilDate { year: y + if month <= 2 { 1 } else { 0 }, month, day, } } /// An ISO date as a day number counted from 1970-01-01. Negative before then. pub fn epoch_day_from_iso(iso: &str) -> i64 { let date = parse_iso_date(iso); days_from_civil(date.year, date.month, date.day) } /// The inverse: a day number back to an ISO date, refusing years outside 0001-9999. /// /// # Panics /// Panics if the day number falls outside that range. pub fn iso_from_epoch_day(epoch_day: i64) -> String { if !(MIN_EPOCH_DAY..=MAX_EPOCH_DAY).contains(&epoch_day) { panic!( "day {} is outside the supported range 0001-01-01 to 9999-12-31", epoch_day ); } format_iso_date(&civil_from_days(epoch_day)) } /// Shift an ISO date by a whole number of days, forwards or backwards. /// /// Month ends and leap days need no special handling: the shift happens on the /// day number, so 2024-02-28 + 1 is 2024-02-29 and 2026-02-28 + 1 is /// 2026-03-01 for the same reason, without a branch for either. pub fn add_days(iso: &str, days: i64) -> String { iso_from_epoch_day(epoch_day_from_iso(iso) + days) } /// Whole days from one date to another, negative when the second is earlier. pub fn days_between(start_iso: &str, end_iso: &str) -> i64 { epoch_day_from_iso(end_iso) - epoch_day_from_iso(start_iso) } pub fn civil_date_to_value(date: &CivilDate) -> Value { Value::obj(vec![ ("year", Value::Int(date.year)), ("month", Value::Int(date.month)), ("day", Value::Int(date.day)), ]) } pub fn fune_vector(args: &[Value]) -> Value { // Refuse what the typed signature cannot hold, with the wording TypeScript // and Python use, rather than let the conversion below quietly change it. if let Value::Float(f) = args[1] { if f.fract() != 0.0 { panic!("days must be an integer, received {}", f); } } Value::str(&add_days(args[0].as_str(), args[1].as_i64())) }