Functional Weave
Code in Rust

education.academic-year

The academic year and term a date falls in: 1 September to 31 August by default, terms from the caller.

1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra

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

What it does

Which academic year a date falls in (label, first and last day), and which of the caller's terms, if any, contains it.

## Decisions

For example

  • academic_year(2025-10-15, —, terms ×3) → label 2025/26, start 2025-09-01, end 2026-08-31, term autumn, term start 2025-09-03, term end 2025-12-19 mid-October is autumn term of 2025/26
  • academic_year(2025-09-01, —, terms ×3) → label 2025/26, start 2025-09-01, end 2026-08-31, term —, term start —, term end — 1 September starts the year, but term has not begun
  • academic_year(2025-08-31, —, terms ×3) → label 2024/25, start 2024-09-01, end 2025-08-31, term —, term start —, term end — 31 August is the last day of the previous year

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 academic_year(on_date: &str, year_start: Option<&str>, terms: &[Term]) -> AcademicYear
on_datedatethe date to place
year_startstring?MM-DD the year begins on; null for 09-01, the academic year in England
termsTerm[]the institution's term dates, in any order, not overlapping; may be empty
returnsAcademicYear

The types it declares, generated into your project

/// One term or semester, first and last day inclusive.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Term {
    pub name: String,
    pub start: String,
    pub end: String,
}

/// The academic year containing a date, and the term if the date is in one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AcademicYear {
    /// 2025/26; a year starting 1 January is labelled 2026
    pub label: String,
    /// first day of the academic year
    pub start: String,
    /// last day, inclusive: the day before the next year starts
    pub end: String,
    /// the term containing onDate; null in a holiday
    pub term: Option<String>,
    pub term_start: Option<String>,
    pub term_end: Option<String>,
}

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

fune!(education.academic-year@^1);  // then call academic_year(…)
impl/rust.rs · 100 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 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::{add_days, days_in_month, format_iso_date, parse_iso_date, CivilDate};  ← from dates.add-days ^1.0.0 · built alongside by fune

fn parse_month_day(text: &str) -> Option<(i64, i64)> {
    let b = text.as_bytes();
    if b.len() != 5 || b[2] != b'-' || !b[0..2].iter().chain(b[3..5].iter()).all(|c| c.is_ascii_digit()) {
        return None;
    }
    let month = i64::from(b[0] - b'0') * 10 + i64::from(b[1] - b'0');
    let day = i64::from(b[3] - b'0') * 10 + i64::from(b[4] - b'0');
    if !(1..=12).contains(&month) || day < 1 || day > days_in_month(2001, month) {
        return None;
    }
    Some((month, day))
}

/// The academic year and term for a date.
///
/// The end is the day before the next year's start, worked out by date
/// arithmetic, so a 1 March start ends on 29 February in a leap year.
///
/// # Panics
/// Panics on a malformed date or yearStart, a term that ends before it
/// starts, or overlapping terms.
pub fn academic_year(on_date: &str, year_start: Option<&str>, terms: &[Term]) -> AcademicYear {
    let on = parse_iso_date(on_date);
    let start_text = year_start.unwrap_or("09-01");
    let (month, day) = match parse_month_day(start_text) {
        Some(md) => md,
        None => panic!("yearStart must be MM-DD on a day every year has, received \"{}\"", start_text),
    };

    let mut sorted: Vec<&Term> = terms.iter().collect();
    sorted.sort_by(|a, b| a.start.cmp(&b.start));
    for t in &sorted {
        parse_iso_date(&t.start);
        parse_iso_date(&t.end);
        if t.end < t.start {
            panic!("term {} ends ({}) before it starts ({})", t.name, t.end, t.start);
        }
    }
    for i in 1..sorted.len() {
        if sorted[i].start <= sorted[i - 1].end {
            panic!("terms {} and {} overlap", sorted[i - 1].name, sorted[i].name);
        }
    }

    let before_start = (on.month, on.day) < (month, day);
    let start_year = if before_start { on.year - 1 } else { on.year };
    let start = format_iso_date(&CivilDate { year: start_year, month, day });
    let end = add_days(&format_iso_date(&CivilDate { year: start_year + 1, month, day }), -1);
    let end_year: i64 = end[0..4].parse().unwrap();
    let label = if end_year == start_year {
        start_year.to_string()
    } else {
        format!("{}/{:02}", start_year, end_year % 100)
    };

    let term = sorted.iter().find(|t| t.start.as_str() <= on_date && on_date <= t.end.as_str());
    AcademicYear {
        label,
        start,
        end,
        term: term.map(|t| t.name.clone()),
        term_start: term.map(|t| t.start.clone()),
        term_end: term.map(|t| t.end.clone()),
    }
}

fn opt_str(value: &Option<String>) -> Value {
    match value {
        Some(s) => Value::str(s),
        None => Value::Null,
    }
}

pub fn academic_year_to_value(y: &AcademicYear) -> Value {
    Value::obj(vec![
        ("label", Value::str(&y.label)),
        ("start", Value::str(&y.start)),
        ("end", Value::str(&y.end)),
        ("term", opt_str(&y.term)),
        ("termStart", opt_str(&y.term_start)),
        ("termEnd", opt_str(&y.term_end)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let terms: Vec<Term> = args[2]
        .as_arr()
        .iter()
        .map(|v| Term {
            name: v.get("name").as_str().to_string(),
            start: v.get("start").as_str().to_string(),
            end: v.get("end").as_str().to_string(),
        })
        .collect();
    let year_start = if args[1].is_null() { None } else { Some(args[1].as_str()) };
    academic_year_to_value(&academic_year(args[0].as_str(), year_start, &terms))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, 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 education.academic-year
Download for Rust education.academic-year-1.0.0-rust.fune · 16,533 bytes sha256 0eb2e5213098a7dba5cd27b2a946ad772a15f6c7a1a0370f7423d3fbe3724c20

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

The whole function, every language, is one file too: education.academic-year-1.0.0.fune, 21,127 bytes, sha256 0d7011288ad1a4b1e0b788524b38b00b591e3b84038b55996cc81045f09a3a2a. 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 education.academic-year

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

// fune: after education.academic-year

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 education.academic-year

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 education.academic-year --steps.

// fune: step education.academic-year 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
mid-October is autumn term of 2025/26 2025-10-15, —, terms ×3 → label 2025/26, start 2025-09-01, end 2026-08-31, term autumn, term start 2025-09-03, term end 2025-12-19
1 September starts the year, but term has not begun 2025-09-01, —, terms ×3 → label 2025/26, start 2025-09-01, end 2026-08-31, term —, term start —, term end —
31 August is the last day of the previous year 2025-08-31, —, terms ×3 → label 2024/25, start 2024-09-01, end 2025-08-31, term —, term start —, term end —
Christmas is in the year but in no term 2025-12-25, —, terms ×3 → label 2025/26, start 2025-09-01, end 2026-08-31, term —, term start —, term end —
a term's last day is inside it 2025-12-19, —, terms ×3 → label 2025/26, start 2025-09-01, end 2026-08-31, term autumn, term start 2025-09-03, term end 2025-12-19
a term's first day is inside it 2026-04-13, —, terms ×3 → label 2025/26, start 2025-09-01, end 2026-08-31, term summer, term start 2026-04-13, term end 2026-07-22
the summer holiday after the last term 2026-08-15, —, terms ×3 → label 2025/26, start 2025-09-01, end 2026-08-31, term —, term start —, term end —
no terms given 2026-02-01, —, → label 2025/26, start 2025-09-01, end 2026-08-31, term —, term start —, term end —
an August start 2025-08-01, 08-01, → label 2025/26, start 2025-08-01, end 2026-07-31, term —, term start —, term end —
the day before an August start 2025-07-31, 08-01, → label 2024/25, start 2024-08-01, end 2025-07-31, term —, term start —, term end —
Show the other 11 tests
CaseArgumentsExpected
a 1 January start is one calendar year 2026-06-30, 01-01, → label 2026, start 2026-01-01, end 2026-12-31, term —, term start —, term end —
the century turns in the label 1999-12-01, —, → label 1999/00, start 1999-09-01, end 2000-08-31, term —, term start —, term end —
a 1 March start ends on 29 February in a leap year 2024-02-29, 03-01, → label 2023/24, start 2023-03-01, end 2024-02-29, term —, term start —, term end —
and on 28 February otherwise 2025-02-28, 03-01, → label 2024/25, start 2024-03-01, end 2025-02-28, term —, term start —, term end —
a term outside the year is still found by date 2025-09-25, 10-01, terms ×1 → label 2024/25, start 2024-10-01, end 2025-09-30, term welcome week, term start 2025-09-22, term end 2025-09-26
29 February is not a day every year has 2025-10-15, 02-29, → error: yearStart must be MM-DD on a day every year has, received "02-29"
a thirteenth month is an error 2025-10-15, 13-01, → error: yearStart must be MM-DD on a day every year has, received "13-01"
an unpadded yearStart is an error 2025-10-15, 9-1, → error: yearStart must be MM-DD on a day every year has, received "9-1"
a term ending before it starts is an error 2025-10-15, —, terms ×1 → error: term autumn ends (2025-09-03) before it starts (2025-12-19)
overlapping terms are an error 2025-10-15, —, terms ×2 → error: terms autumn and winter overlap
an impossible date is an error 2025-02-30, —, → error: "2025-02-30" is not a real calendar date

More from the author

- **The year runs from `yearStart` to the day before the next one.** With `null` it is 1 September to 31 August, the academic year the Department for Education uses in England for school years, admissions and statistics. Pass `08-01` for a year that starts in August (Scottish schools, many universities), or whatever your institution uses. - **Term dates are arguments, not data.** Every local authority, academy trust and university sets its own, so there is nothing the registry could carry that would be right. A date between terms is in the academic year but in no term: `term` is null. - A term is found by date alone, so it need not lie inside the academic year (a university's September welcome week before a `10-01` year start still resolves). Overlapping terms are refused, since a date would be in two. - The label is `2025/26`, with the second year as two digits (`1999/00`); a year starting on 1 January is one calendar year and is labelled `2026`. - `yearStart` must be a day every year has, so `02-29` is refused. A `03-01` start ends on 29 February in a leap year: the end is the day before the next start, not a fixed date.

Files

PathBytes
README.md1,326
impl/python.py2,246
impl/rust.rs3,608
impl/typescript.ts2,180
vectors.json7,254