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.1 · published 2026-10-03 by charlie · Anterra
Pinned by 23 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/26academic_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 begunacademic_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_date | date | the date to place |
| year_start | string? | MM-DD the year begins on; null for 09-01, the academic year in England |
| terms | Term[] | the institution's term dates, in any order, not overlapping; may be empty |
| returns | AcademicYear |
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(…)
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
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./education.academic-year-1.0.1-rust.fune, or fetch it from a terminal with fune pull education.academic-year@1.0.1:rust.
The whole function, every language, is one file too: education.academic-year-1.0.1.fune, 21,655 bytes, sha256 1fb1026f5bc09794198c4d38378e7004589a623292db9380b50098ecd9c976e8. 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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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 13 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 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 |
| a yearStart with a trailing newline is an error | 2025-10-15, 09-01 , | → | error: yearStart must be MM-DD on a day every year has, received "09-01 " |
| a yearStart in Arabic-Indic digits is an error | 2025-10-15, ٠٩-٠١, | → | error: yearStart must be MM-DD on a day every year has, received "٠٩-٠١" |
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.
1.0.1 fixes Python accepting a trailing newline or non-ASCII digits in yearStart; adds tests.
Files
| Path | Bytes |
|---|---|
| README.md | 1,421 |
| impl/python.py | 2,254 |
| impl/rust.rs | 3,608 |
| impl/typescript.ts | 2,180 |
| vectors.json | 7,638 |