Functional Weave
Code in TypeScript

dates.add-days@1.0.0

impl/rust.rs

6,800 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

//! 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;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

/// 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()))
}