Functional Weave
Code in Rust

dates.add-days

Shift an ISO date by a whole number of days, forwards or backwards, with exact calendar arithmetic.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 28 tests, run in TypeScript, Python and Rust.

What it does

This is the civil-date kernel for the whole dates family: days-from-civil and civil-from-days, converting a calendar date to a day number counted from 1970-01-01 and back. Every other dates capability imports it rather than re-deriving the arithmetic.

No date library is used in any of the three languages, and that is a decision, not an omission. JavaScript's Date parses "2026-09-16" as UTC midnight but new Date(2026, 8, 16) as local midnight, so the same calendar day can come back a day out depending on the machine's timezone, and setMonth rolls 31 January into 3 March. Python's datetime and Rust's chrono are each correct on their own but would mean three different libraries answering the same question, which is exactly what the parity vectors exist to rule out.

For example

  • add_days(2026-09-16, 1) → 2026-09-17 one day forward mid-month
  • add_days(2026-09-16, 0) → 2026-09-16 zero days is the same date back
  • add_days(2024-02-28, 1) → 2024-02-29 2024 is a leap year so 28 February is followed by the 29th

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

pub fn add_days(iso: &str, days: i64) -> String
isodateISO date, YYYY-MM-DD
daysintwhole days to add; negative moves backwards
returnsdate

The type it declares, generated into your project

/// A calendar date as numbers, for the arithmetic other date capabilities do.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CivilDate {
    pub year: i64,
    /// 1 to 12
    pub month: i64,
    /// 1 to 31
    pub day: i64,
}

Your code names it in one line, in the file that uses it

fune!(dates.add-days@^1);  // then call add_days(…)
impl/rust.rs · 177 lines · open · 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()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add dates.add-days
Download for Rust dates.add-days-1.0.0-rust.fune · 13,630 bytes sha256 45bce72cf9d89d00cbd39d13ec02d265010f0f3c3ef5ff39181cb8303bfe6987

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./dates.add-days-1.0.0-rust.fune, or fetch it from a terminal with fune pull dates.add-days@1.0.0:rust.

The whole function, every language, is one file too: dates.add-days-1.0.0.fune, 26,107 bytes, sha256 c8546db9870fee4bc128650b0c3801cf6b3a0bc4daa6338c53a5f3b6d15a79e5. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before dates.add-days

after — your function gets the result and the arguments, and returns the final result.

// fune: after dates.add-days

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show dates.add-days --steps.

// fune: step dates.add-days after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
one day forward mid-month 2026-09-16, 1 → 2026-09-17
zero days is the same date back 2026-09-16, 0 → 2026-09-16
2024 is a leap year so 28 February is followed by the 29th 2024-02-28, 1 → 2024-02-29
2026 is not a leap year so 28 February rolls into March 2026-02-28, 1 → 2026-03-01
1900 was a century year and not a leap year 1900-02-28, 1 → 1900-03-01
2000 was divisible by 400 and was a leap year 2000-02-28, 1 → 2000-02-29
2100 is a century year and will not be a leap year 2100-02-28, 1 → 2100-03-01
31 January rolls to 1 February, never to 3 March 2026-01-31, 1 → 2026-02-01
31 March rolls to 1 April 2026-03-31, 1 → 2026-04-01
year rollover 2026-12-31, 1 → 2027-01-01
Show the other 18 tests
CaseArgumentsExpected
year rollover into a leap year 1999-12-31, 1 → 2000-01-01
negative offset crosses back over new year 2027-01-01, -1 → 2026-12-31
negative offset lands on the leap day 2024-03-01, -1 → 2024-02-29
negative offset skips the leap day that does not exist 1900-03-01, -1 → 1900-02-28
365 days in a common year lands on the same date 2025-03-01, 365 → 2026-03-01
365 days across a leap day lands a day short 2024-01-01, 365 → 2024-12-31
a large negative offset 2026-09-16, -1,000 → 2023-12-21
a large positive offset 2026-09-16, 10,000 → 2054-02-01
dates before the 1970 epoch work the same way 1969-12-31, 1 → 1970-01-01
a slash-separated date is an error, not a guess 16/09/2026, 1 → error: is not an ISO date
an unpadded month is an error 2026-9-16, 1 → error: is not an ISO date
a date with a time attached is an error 2026-09-16T00:00:00Z, 1 → error: is not an ISO date
30 February is not a real date and is not silently rolled forward 2026-02-30, 1 → error: is not a real calendar date
29 February in a non-leap year is rejected 2026-02-29, 1 → error: is not a real calendar date
month 13 is rejected 2026-13-01, 1 → error: is not a real calendar date
31 April is rejected 2026-04-31, 1 → error: is not a real calendar date
a fractional day count is an error 2026-09-16, 1.5 → error: days must be an integer
running off the end of the supported range is an error 9999-12-31, 1 → error: outside the supported range

More from the author

Impossible dates are rejected rather than normalised. 2026-02-30 is an error, not 2026-03-02: a parser that silently rolls a bad date forward turns a data-entry mistake into a plausible wrong answer.

Supported range is 0001-01-01 to 9999-12-31, the dates a four-digit ISO year can express. Inside that range every division in the kernel has non-negative operands, so truncating division in Rust, floor division in Python and Math.floor in TypeScript all agree.

Files

PathBytes
README.md1,255
impl/python.py5,846
impl/rust.rs6,800
impl/typescript.ts6,218
vectors.json3,217