Functional Weave
Code in Rust

dates.business-days-between

Working days between two dates, excluding weekends and a caller-supplied holiday list.

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

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

What it does

The interval is half-open: start inclusive, end exclusive. Two consequences a caller has to know. Same date twice is 0, not 1. And intervals compose: between(a,b) + between(b,c) = between(a,c), which is what lets a report be summed by week or by month without double counting the boundary days. If you want the end date included - "the job takes five working days and today counts" - pass the day after it.

A start later than the end returns a negative count, exactly -between(end, start). Clamping to zero would silently absorb an argument-order bug, and a payment term calculated from swapped dates should look obviously wrong rather than plausibly zero.

For example

  • business_days_between(2026-09-14, 2026-09-21, ) → 5 a full Monday-to-Monday week is five working days
  • business_days_between(2026-09-16, 2026-09-16, ) → 0 the same date twice is zero: the interval is half-open
  • business_days_between(2026-09-16, 2026-09-17, ) → 1 start is inclusive, so one working day spans one night

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 business_days_between(start_iso: &str, end_iso: &str, holidays: &[String]) -> i64
start_isodateISO date, inclusive
end_isodateISO date, exclusive
holidaysdate[]non-working dates; order and duplicates do not matter
returnsintworking days in [start, end); negative when start is after end

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

fune!(dates.business-days-between@^1);  // then call business_days_between(…)
impl/rust.rs · 66 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.

use std::collections::HashSet;

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::epoch_day_from_iso;  ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_day_of_week::day_of_week;  ← from dates.day-of-week ^1.0.0 · built alongside by fune

/// Working days between two dates, excluding Saturdays, Sundays and any date in
/// the holiday list.
///
/// The interval is half-open: the start date counts, the end date does not.
/// That is the convention that makes ranges compose -
/// `business_days_between(a, b) + business_days_between(b, c)` equals
/// `business_days_between(a, c)` - and it makes "how many working days until
/// the deadline" come out at zero on the deadline itself rather than one. If
/// you want the end date included, ask for the day after it.
///
/// A start later than the end returns a negative count, with the same magnitude
/// as the forward direction, so `business_days_between(a, b)` is exactly
/// `-business_days_between(b, a)`. Returning zero or panicking would both hide
/// a caller's argument-order bug.
///
/// Holidays are an argument, not built in, because no library knows which days
/// your company is closed. Weekend holidays are not double counted, duplicates
/// in the list are harmless, and holidays outside the interval are ignored.
///
/// # Panics
/// Panics if either date, or any holiday, is malformed or impossible.
pub fn business_days_between(start_iso: &str, end_iso: &str, holidays: &[String]) -> i64 {
    let start = epoch_day_from_iso(start_iso);
    let end = epoch_day_from_iso(end_iso);

    // Every holiday is validated even when it falls outside the interval: a typo
    // in a holiday calendar should fail loudly on the next run, not lie dormant
    // until the year the date is finally inside a query.
    let excluded: HashSet<i64> = holidays.iter().map(|h| epoch_day_from_iso(h)).collect();

    if start > end {
        return -count_working_days(end, end_iso, start, &excluded);
    }
    count_working_days(start, start_iso, end, &excluded)
}

/// Working days in the half-open interval [from_day, to_day).
fn count_working_days(from_day: i64, from_iso: &str, to_day: i64, excluded: &HashSet<i64>) -> i64 {
    // The weekday is carried forward rather than recomputed per day: one date
    // parse, then a seven-day cycle, which is the same loop in all three
    // languages and cannot drift between them.
    let mut weekday = day_of_week(from_iso);
    let mut count = 0i64;
    for day in from_day..to_day {
        if weekday <= 5 && !excluded.contains(&day) {
            count += 1;
        }
        weekday = if weekday == 7 { 1 } else { weekday + 1 };
    }
    count
}

pub fn fune_vector(args: &[Value]) -> Value {
    let holidays: Vec<String> = args[2].as_arr().iter().map(|v| v.as_str().to_string()).collect();
    Value::Int(business_days_between(
        args[0].as_str(),
        args[1].as_str(),
        &holidays,
    ))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, 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.business-days-between
Download for Rust dates.business-days-between-1.0.0-rust.fune · 9,821 bytes sha256 de5d65370ab6ecddefe274d3c92bfa66881e708c92d16ad4161ae9b9088cb79c

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

The whole function, every language, is one file too: dates.business-days-between-1.0.0.fune, 14,702 bytes, sha256 350624abbac7ca1f91a54f9cf79a8bb6f43b98eef82b4135be420f139c178a88. 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.business-days-between

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

// fune: after dates.business-days-between

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace dates.add-days in dates.business-days-between
// fune: replace dates.day-of-week in dates.business-days-between

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.business-days-between --steps.

// fune: step dates.business-days-between 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
a full Monday-to-Monday week is five working days 2026-09-14, 2026-09-21, → 5
the same date twice is zero: the interval is half-open 2026-09-16, 2026-09-16, → 0
start is inclusive, so one working day spans one night 2026-09-16, 2026-09-17, → 1
end is exclusive: Monday to Tuesday counts Monday only 2026-09-14, 2026-09-15, → 1
the first half of the week composes with the second 2026-09-14, 2026-09-16, → 2
and the second half: 2 plus 3 is the 5 of the whole week 2026-09-16, 2026-09-21, → 3
a weekend on its own has no working days 2026-09-19, 2026-09-21, → 0
Friday to Tuesday crosses a weekend and counts two days 2026-09-18, 2026-09-22, → 2
two holidays inside a Christmas week 2026-12-24, 2026-12-29, 2026-12-25, 2026-12-28 → 1
the same holiday listed twice is only subtracted once 2026-12-24, 2026-12-29, 2026-12-25, 2026-12-25 → 2
Show the other 16 tests
CaseArgumentsExpected
a holiday falling on a Saturday is not subtracted twice 2026-08-28, 2026-09-01, 2026-08-29 → 2
a holiday outside the interval is ignored 2026-09-14, 2026-09-16, 2026-12-25 → 2
a holiday on the start date removes the only working day 2026-09-14, 2026-09-15, 2026-09-14 → 0
Good Friday and Easter Monday around a weekend 2026-04-02, 2026-04-07, 2026-04-03, 2026-04-06 → 1
across the new year with New Year's Day as a holiday 2026-12-28, 2027-01-04, 2027-01-01 → 4
the 2024 leap day is an ordinary working Thursday 2024-02-28, 2024-03-01, → 2
2000 had a leap day, so the same span is still two working days 2000-02-28, 2000-03-01, → 2
1900 had no leap day and the span is two working days from the 27th 1900-02-27, 1900-03-01, → 2
a month-long span 2026-09-14, 2026-10-14, → 22
a reversed interval is negative, not zero 2026-09-21, 2026-09-14, → -5
a reversed interval respects holidays with the same magnitude 2026-09-21, 2026-09-14, 2026-09-16 → -4
a malformed start date is an error 14/09/2026, 2026-09-21, → error: is not an ISO date
a malformed end date is an error 2026-09-14, 2026-09, → error: is not an ISO date
an impossible end date is an error, not a rolled-forward guess 2026-09-14, 2026-02-30, → error: is not a real calendar date
a malformed holiday is an error even though it is outside the interval 2026-09-14, 2026-09-21, 25/12/2026 → error: is not an ISO date
an impossible holiday is an error 2026-09-14, 2026-09-21, 2026-11-31 → error: is not a real calendar date

More from the author

Holidays are an argument because no library knows which days your business is closed, and a bank holiday calendar is jurisdiction-specific, year-specific and sometimes employer-specific. Holidays on a weekend are not subtracted twice, duplicates in the list are harmless, and holidays outside the interval are ignored - but they are still validated, so a typo in a calendar fails on the next run rather than in the year it finally falls inside a query.

The count is a day-by-day loop rather than a closed-form week count. It is O(days) and that is a deliberate trade: the loop is the same six lines in all three languages, which makes the parity gate meaningful, and the closed form is where off-by-one errors around the start weekday live.

Files

PathBytes
README.md1,432
impl/python.py2,344
impl/rust.rs2,833
impl/typescript.ts2,383
vectors.json3,482