mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-22 18:57:13 +00:00
Every desktop shell that reads typikon today runs the binary and parses its JSON. That costs about 1.5ms and is the right answer for a calendar that changes once a day, but it is the wrong shape for anything with an FFI and no business spawning a process on every redraw. Dates cross as Julian Day Numbers, so no struct layout is part of the contract. A day is opaque and owns its strings, built once when it is reckoned: an accessor returns the same borrowed pointer every time, so a statusline polling one in a loop allocates nothing. The accessors carry every field of the JSON, so nothing on the other side needs a parser. The Python bindings and this were resolving a jurisdiction by two copies of the same logic, and the copy here leaked one per call. Both now share jurisdictions::resolve_full, which interns the combinations instead. The header is a promise made in a second file, and a second file drifts: a test reads both and fails if either has a symbol the other does not, and does the same for the LuaJIT cdef, where a stale declaration is a segfault rather than a compile error.
401 lines
15 KiB
Rust
401 lines
15 KiB
Rust
//! The C ABI, exercised as a C caller would use it.
|
|
//!
|
|
//! These run only under `--features capi`, which is what builds the module.
|
|
//! They cover three things a Rust test can check and a C compiler cannot: that
|
|
//! the answers match what the crate gives natively, that the pointers an
|
|
//! accessor hands out stay put, and that the header in `include/` still
|
|
//! describes the library that is actually built.
|
|
|
|
#![cfg(feature = "capi")]
|
|
|
|
use std::ffi::{c_char, CStr, CString};
|
|
use std::ptr;
|
|
|
|
use typikon::capi::*;
|
|
|
|
/// Borrow a returned pointer as a `str`, or `None` for NULL.
|
|
///
|
|
/// # Safety
|
|
/// Only called on pointers these functions returned, which live until free.
|
|
fn s<'a>(p: *const c_char) -> Option<&'a str> {
|
|
if p.is_null() {
|
|
None
|
|
} else {
|
|
Some(unsafe { CStr::from_ptr(p) }.to_str().expect("utf-8"))
|
|
}
|
|
}
|
|
|
|
fn describe(iso: &str, jurisdiction: &str) -> *mut typikon_day {
|
|
let (y, m, d) = (
|
|
iso[0..4].parse().unwrap(),
|
|
iso[5..7].parse().unwrap(),
|
|
iso[8..10].parse().unwrap(),
|
|
);
|
|
let jdn = typikon_jdn_from_ymd(y, m, d);
|
|
assert_ne!(jdn, 0, "{iso} should be a date");
|
|
let key = CString::new(jurisdiction).unwrap();
|
|
let day = unsafe { typikon_describe(jdn, key.as_ptr()) };
|
|
assert!(!day.is_null(), "{iso} {jurisdiction} should reckon");
|
|
day
|
|
}
|
|
|
|
// ──────────────────────────────── dates ───────────────────────────────────
|
|
|
|
#[test]
|
|
fn jdn_round_trips() {
|
|
for (y, m, d) in [(1, 1, 1), (1583, 10, 15), (2026, 4, 12), (9999, 12, 31)] {
|
|
let jdn = typikon_jdn_from_ymd(y, m, d);
|
|
assert_ne!(jdn, 0, "{y}-{m}-{d}");
|
|
let (mut yy, mut mm, mut dd) = (0i32, 0u32, 0u32);
|
|
let rc = unsafe { typikon_ymd_from_jdn(jdn, &mut yy, &mut mm, &mut dd) };
|
|
assert_eq!(rc, 0);
|
|
assert_eq!((yy, mm, dd), (y, m, d));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_impossible_date_is_an_error_not_a_guess() {
|
|
assert_eq!(typikon_jdn_from_ymd(2026, 2, 30), 0);
|
|
let msg = s(typikon_last_error()).expect("a message");
|
|
assert!(msg.contains("2026-02-30"), "{msg}");
|
|
|
|
// And the next success clears it, so a caller that only checks the error
|
|
// pointer cannot read a stale one.
|
|
assert_ne!(typikon_jdn_from_ymd(2026, 2, 28), 0);
|
|
assert!(typikon_last_error().is_null());
|
|
}
|
|
|
|
#[test]
|
|
fn ymd_out_params_are_each_optional() {
|
|
let jdn = typikon_jdn_from_ymd(2026, 4, 12);
|
|
let mut year = 0i32;
|
|
let rc = unsafe { typikon_ymd_from_jdn(jdn, &mut year, ptr::null_mut(), ptr::null_mut()) };
|
|
assert_eq!(rc, 0);
|
|
assert_eq!(year, 2026);
|
|
}
|
|
|
|
#[test]
|
|
fn a_julian_day_outside_the_years_is_refused() {
|
|
let mut year = 12345i32;
|
|
let rc = unsafe { typikon_ymd_from_jdn(-1, &mut year, ptr::null_mut(), ptr::null_mut()) };
|
|
assert_eq!(rc, -1);
|
|
assert_eq!(year, 12345, "out-params are untouched on failure");
|
|
assert!(s(typikon_last_error()).is_some());
|
|
}
|
|
|
|
// ───────────────────────────────── a day ──────────────────────────────────
|
|
|
|
#[test]
|
|
fn a_day_answers_what_the_crate_answers() {
|
|
let jur = typikon::jurisdictions::resolve(Some("moscow")).unwrap();
|
|
let native = typikon::day::describe(
|
|
typikon::calendars::Date::parse_iso("2026-04-12").unwrap(),
|
|
jur,
|
|
);
|
|
|
|
let day = describe("2026-04-12", "moscow");
|
|
unsafe {
|
|
assert_eq!(s(typikon_day_iso(day)), Some("2026-04-12"));
|
|
assert_eq!(s(typikon_day_title(day)), Some(native.title()));
|
|
assert_eq!(s(typikon_day_fast(day)), Some(native.fast.key()));
|
|
assert_eq!(s(typikon_day_emoji(day)), Some(native.emoji));
|
|
assert_eq!(s(typikon_day_jurisdiction(day)), Some(native.jurisdiction));
|
|
assert_eq!(s(typikon_day_church_date(day)), Some(&*native.church_date));
|
|
assert_eq!(typikon_day_days_to_pascha(day), native.days_to_pascha);
|
|
assert_eq!(typikon_day_pascha(day), native.pascha.jdn());
|
|
assert_eq!(typikon_day_julian_offset(day), native.julian_offset);
|
|
assert_eq!(
|
|
typikon_day_commemoration_count(day) as usize,
|
|
native.commemorations.len()
|
|
);
|
|
typikon_day_free(day);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_json_is_the_document_the_command_line_emits() {
|
|
let day = describe("2026-01-07", "moscow");
|
|
let json = s(unsafe { typikon_day_json(day) }).expect("json");
|
|
let parsed: serde_json::Value = serde_json::from_str(json).expect("valid json");
|
|
|
|
let jur = typikon::jurisdictions::resolve(Some("moscow")).unwrap();
|
|
let native = typikon::day::describe(
|
|
typikon::calendars::Date::parse_iso("2026-01-07").unwrap(),
|
|
jur,
|
|
);
|
|
let expected = serde_json::to_value(native.to_json()).unwrap();
|
|
assert_eq!(parsed, expected);
|
|
|
|
unsafe { typikon_day_free(day) };
|
|
}
|
|
|
|
#[test]
|
|
fn an_accessor_returns_the_same_pointer_every_time() {
|
|
// The point of building the strings once: a statusline that reads `title`
|
|
// on every redraw must not allocate on every redraw. An earlier version
|
|
// did, and kept every allocation alive until free.
|
|
let day = describe("2026-04-12", "moscow");
|
|
unsafe {
|
|
let first = typikon_day_title(day);
|
|
for _ in 0..10_000 {
|
|
assert_eq!(typikon_day_title(day), first);
|
|
}
|
|
typikon_day_free(day);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_null_day_answers_null_rather_than_crashing() {
|
|
let n: *const typikon_day = ptr::null();
|
|
unsafe {
|
|
assert!(typikon_day_json(n).is_null());
|
|
assert!(typikon_day_title(n).is_null());
|
|
assert!(typikon_day_season(n).is_null());
|
|
assert!(typikon_day_commemoration(n, 0).is_null());
|
|
assert_eq!(typikon_day_commemoration_count(n), 0);
|
|
assert_eq!(typikon_day_is_obligatory(n), 0);
|
|
assert_eq!(typikon_day_days_to_pascha(n), 0);
|
|
assert_eq!(typikon_day_pascha(n), 0);
|
|
assert_eq!(typikon_day_julian_offset(n), 0);
|
|
typikon_day_free(ptr::null_mut()); // accepted and ignored
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_commemoration_past_the_end_is_null() {
|
|
let day = describe("2026-04-12", "moscow");
|
|
unsafe {
|
|
let n = typikon_day_commemoration_count(day);
|
|
assert!(typikon_day_commemoration(day, n).is_null());
|
|
assert!(typikon_day_commemoration(day, u32::MAX).is_null());
|
|
if n > 0 {
|
|
assert!(!typikon_day_commemoration(day, 0).is_null());
|
|
}
|
|
typikon_day_free(day);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_jurisdiction_nobody_keeps_is_an_error() {
|
|
let key = CString::new("shire").unwrap();
|
|
let day = unsafe { typikon_describe(typikon_today(), key.as_ptr()) };
|
|
assert!(day.is_null());
|
|
let msg = s(typikon_last_error()).expect("a message");
|
|
assert!(msg.contains("shire"), "{msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_null_jurisdiction_takes_the_remembered_default() {
|
|
let day = unsafe { typikon_describe(typikon_jdn_from_ymd(2026, 4, 12), ptr::null()) };
|
|
assert!(!day.is_null());
|
|
unsafe {
|
|
assert!(s(typikon_day_jurisdiction(day)).is_some());
|
|
typikon_day_free(day);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_discipline_flag_moves_what_is_shown_but_not_what_binds() {
|
|
// The Byzantine Catholic case the flag exists for: on a Wednesday of the
|
|
// Great Fast, Ruthenian law requires nothing, while the Typikon it inherits
|
|
// keeps xerophagy.
|
|
let jdn = typikon_jdn_from_ymd(2026, 3, 4); // Wednesday, Great Fast
|
|
let key = CString::new("ruthenian").unwrap();
|
|
unsafe {
|
|
let law = typikon_describe_full(jdn, key.as_ptr(), ptr::null(), 0);
|
|
let typikon = typikon_describe_full(jdn, key.as_ptr(), ptr::null(), 1);
|
|
assert!(!law.is_null() && !typikon.is_null());
|
|
|
|
assert_ne!(
|
|
s(typikon_day_fast(law)),
|
|
s(typikon_day_fast(typikon)),
|
|
"the Typikon should keep more than the law requires"
|
|
);
|
|
assert_eq!(
|
|
s(typikon_day_binds(law)),
|
|
s(typikon_day_binds(typikon)),
|
|
"what binds is the law either way"
|
|
);
|
|
typikon_day_free(law);
|
|
typikon_day_free(typikon);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_usage_can_be_overridden() {
|
|
// Fish in the Nativity Fast: Greek practice permits it on a weekday before
|
|
// 17 December, Slavic practice keeps it to Saturdays and Sundays.
|
|
let jdn = typikon_jdn_from_ymd(2025, 12, 9); // a Tuesday
|
|
let key = CString::new("old").unwrap();
|
|
let (greek, slavic) = (CString::new("greek").unwrap(), CString::new("slavic").unwrap());
|
|
unsafe {
|
|
let g = typikon_describe_full(jdn, key.as_ptr(), greek.as_ptr(), -1);
|
|
let sl = typikon_describe_full(jdn, key.as_ptr(), slavic.as_ptr(), -1);
|
|
assert_eq!(s(typikon_day_tradition(g)), Some("greek"));
|
|
assert_eq!(s(typikon_day_tradition(sl)), Some("slavic"));
|
|
typikon_day_free(g);
|
|
typikon_day_free(sl);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_usage_is_an_error() {
|
|
let key = CString::new("moscow").unwrap();
|
|
let bad = CString::new("frankish").unwrap();
|
|
let day = unsafe { typikon_describe_full(typikon_today(), key.as_ptr(), bad.as_ptr(), -1) };
|
|
assert!(day.is_null());
|
|
assert!(s(typikon_last_error()).is_some());
|
|
}
|
|
|
|
// ────────────────────────── without a day ─────────────────────────────────
|
|
|
|
#[test]
|
|
fn pascha_by_year_matches_the_published_dates() {
|
|
let old = CString::new("old").unwrap();
|
|
let west = CString::new("western").unwrap();
|
|
for (year, julian, gregorian) in [
|
|
(2026, "2026-04-12", "2026-04-05"),
|
|
(2027, "2027-05-02", "2027-03-28"),
|
|
(2028, "2028-04-16", "2028-04-16"),
|
|
] {
|
|
for (key, expected) in [(&old, julian), (&west, gregorian)] {
|
|
let jdn = unsafe { typikon_pascha(year, key.as_ptr()) };
|
|
let (mut y, mut m, mut d) = (0i32, 0u32, 0u32);
|
|
assert_eq!(unsafe { typikon_ymd_from_jdn(jdn, &mut y, &mut m, &mut d) }, 0);
|
|
assert_eq!(format!("{y:04}-{m:02}-{d:02}"), expected);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pascha_refuses_a_jurisdiction_nobody_keeps() {
|
|
let key = CString::new("shire").unwrap();
|
|
assert_eq!(unsafe { typikon_pascha(2026, key.as_ptr()) }, 0);
|
|
assert!(s(typikon_last_error()).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn every_jurisdiction_is_listed_and_every_listed_key_reckons() {
|
|
let n = typikon_jurisdiction_count();
|
|
assert_eq!(
|
|
n as usize,
|
|
typikon::jurisdictions::JURISDICTIONS.len() + typikon::jurisdictions::CALENDARS.len()
|
|
);
|
|
for i in 0..n {
|
|
let key = s(typikon_jurisdiction_key(i)).expect("a key");
|
|
assert!(s(typikon_jurisdiction_name(i)).is_some_and(|x| !x.is_empty()));
|
|
assert!(s(typikon_jurisdiction_note(i)).is_some(), "note may be empty, not NULL");
|
|
let day = describe("2026-04-12", key);
|
|
unsafe { typikon_day_free(day) };
|
|
}
|
|
assert!(typikon_jurisdiction_key(n).is_null());
|
|
}
|
|
|
|
#[test]
|
|
fn the_version_is_the_crate_version() {
|
|
assert_eq!(s(typikon_version()), Some(env!("CARGO_PKG_VERSION")));
|
|
}
|
|
|
|
// ──────────────────────────────── the header ──────────────────────────────
|
|
|
|
/// Every symbol the library exports is declared in `include/typikon.h`, and
|
|
/// every function the header declares is exported.
|
|
///
|
|
/// A header is a promise made in a second file, and a second file drifts. This
|
|
/// is the cheapest check that it has not.
|
|
#[test]
|
|
fn the_header_describes_the_library() {
|
|
let src = std::fs::read_to_string("src/capi.rs").expect("src/capi.rs");
|
|
let header = std::fs::read_to_string("include/typikon.h").expect("include/typikon.h");
|
|
|
|
let mut exported: Vec<&str> = Vec::new();
|
|
for line in src.lines() {
|
|
// `fn typikon_foo(` in a line that declares an extern function, plus
|
|
// the names the accessor macro generates, which are its first argument.
|
|
if let Some(rest) = line.trim().strip_prefix("pub extern \"C\" fn ") {
|
|
exported.push(rest.split('(').next().unwrap());
|
|
} else if let Some(rest) = line.trim().strip_prefix("pub unsafe extern \"C\" fn ") {
|
|
exported.push(rest.split('(').next().unwrap());
|
|
} else if let Some(rest) = line.trim().strip_prefix("accessor!(") {
|
|
exported.push(rest.split(',').next().unwrap());
|
|
}
|
|
}
|
|
// The macro's own definition line reads as `fn $name(`; the names it
|
|
// generates are picked up from the invocations.
|
|
exported.retain(|n| !n.starts_with('$'));
|
|
assert!(exported.len() > 20, "found only {} symbols", exported.len());
|
|
|
|
for name in &exported {
|
|
assert!(
|
|
header.contains(&format!("{name}(")),
|
|
"{name} is exported but not declared in include/typikon.h"
|
|
);
|
|
}
|
|
|
|
for line in header.lines() {
|
|
let Some(start) = line.find("typikon_") else { continue };
|
|
if !line.contains('(') || line.trim_start().starts_with('*') {
|
|
continue;
|
|
}
|
|
let name: String = line[start..]
|
|
.chars()
|
|
.take_while(|c| c.is_alphanumeric() || *c == '_')
|
|
.collect();
|
|
if !line[start + name.len()..].starts_with('(') {
|
|
continue; // a mention in prose, not a declaration
|
|
}
|
|
assert!(
|
|
exported.contains(&name.as_str()),
|
|
"{name} is declared in include/typikon.h but not exported"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The header hard-codes the ABI version so a caller can compare what it
|
|
/// compiled against with what it loaded.
|
|
#[test]
|
|
fn the_headers_abi_version_matches_the_library() {
|
|
let header = std::fs::read_to_string("include/typikon.h").expect("include/typikon.h");
|
|
let line = header
|
|
.lines()
|
|
.find(|l| l.starts_with("#define TYPIKON_ABI_VERSION"))
|
|
.expect("the header defines it");
|
|
let declared: u32 = line
|
|
.split_whitespace()
|
|
.next_back()
|
|
.unwrap()
|
|
.trim_end_matches('u')
|
|
.parse()
|
|
.expect("a number");
|
|
assert_eq!(declared, typikon_abi_version());
|
|
}
|
|
|
|
/// The LuaJIT binding declares the library it loads, and a declaration that
|
|
/// has drifted from the header is a segfault rather than a compile error.
|
|
#[test]
|
|
fn the_lua_binding_declares_the_whole_header() {
|
|
let header = std::fs::read_to_string("include/typikon.h").expect("include/typikon.h");
|
|
let lua = std::fs::read_to_string("examples/lua/typikon.lua").expect("the lua binding");
|
|
let cdef = {
|
|
let start = lua.find("ffi.cdef").expect("a cdef block");
|
|
let body = &lua[start..];
|
|
let end = body.find("]]").expect("the block ends");
|
|
&body[..end]
|
|
};
|
|
|
|
for line in header.lines() {
|
|
let Some(start) = line.find("typikon_") else { continue };
|
|
let name: String = line[start..]
|
|
.chars()
|
|
.take_while(|c| c.is_alphanumeric() || *c == '_')
|
|
.collect();
|
|
if !line[start + name.len()..].starts_with('(') {
|
|
continue;
|
|
}
|
|
assert!(
|
|
cdef.contains(&format!("{name}(")),
|
|
"{name} is in the header but not in the lua cdef"
|
|
);
|
|
}
|
|
}
|