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 daysbusiness_days_between(2026-09-16, 2026-09-16, )→ 0 the same date twice is zero: the interval is half-openbusiness_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_iso | date | ISO date, inclusive |
| end_iso | date | ISO date, exclusive |
| holidays | date[] | non-working dates; order and duplicates do not matter |
| returns | int | working 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(…)
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,432 |
| impl/python.py | 2,344 |
| impl/rust.rs | 2,833 |
| impl/typescript.ts | 2,383 |
| vectors.json | 3,482 |