rebuild the engine in Rust

Python was never the house language here: the mw-* desktop surface it came
from is 39 bash scripts and was exactly one Python script, the one this was
extracted from. What it cost was distribution, a widget dependency that
needed pipx and a Python floor, and 24ms of interpreter startup before a line
of the program ran.

The port is verified rather than asserted. Both implementations were run over
199,446 day-records, 26 jurisdictions across 21 years, with every field of
every record required to match; the JSON, the iCalendar and 480 renderings of
the text output are identical too. examples/dump.rs is the harness.

What the type system actually buys, which is less than the usual claim and
more than nothing:

  - Level is an ordered enum, strictest first, so  reads as 'a is
    stricter' and relaxed_to() is the one place a feast can lift a fast. No
    rule can get the direction backwards.
  - Date is a newtype over the Julian Day Number and JulianDate is a separate
    type, so a civil date and an ecclesiastical one cannot be mixed up. That
    confusion is the single most common way calendar code goes wrong.
  - Octets is a newtype in the iCalendar folder, because the bug it replaced
    counted characters against a limit RFC 5545 states in octets.
  - Movables is a struct, not a map, so a mistyped key is a compile error
    rather than a None at the far end of the program.

Of the six defects an adversarial review found in the Python, exactly one had
a shape a type system would have caught. The rest were logic, and the tests
came with them.

The menologion is compiled in with include_str!, so the binary answers on its
own: 930KB, no runtime, 1.2ms to start against Python's 36ms.
This commit is contained in:
funkpower
2026-09-18 11:13:15 -04:00
parent 8247b51805
commit d115616eff
44 changed files with 3367 additions and 2287 deletions
+64
View File
@@ -0,0 +1,64 @@
//! Fasting disciplines. One module per tradition.
pub mod orthodox;
pub mod ruthenian;
use crate::calendars::{Date, Weekday};
use crate::fasting::{Fast, Level};
use crate::jurisdictions::{Jurisdiction, RuleKind};
use crate::menologion::Rank;
use crate::paschalion::Movables;
use crate::seasons::Season;
/// What a fasting rule is handed. Deliberately small.
pub struct Context<'a> {
pub civil: Date,
/// The ecclesiastical (month, day).
pub fixed: (u32, u32),
/// The ecclesiastical year, for resolving a fixed date back to a civil one.
pub fixed_year: i32,
pub weekday: Weekday,
pub mv: &'a Movables,
pub season: Option<Season>,
pub feast: String,
pub rank: Option<Rank>,
pub jurisdiction: &'a Jurisdiction,
}
impl Context<'_> {
/// The civil date of an ecclesiastical date in this day's church year.
pub fn civil_of_fixed(&self, month: u32, day: u32) -> Date {
self.jurisdiction.civil_of_fixed(self.fixed_year, month, day)
}
pub fn is_weekend(&self) -> bool {
self.weekday.is_weekend()
}
/// Is this day within `days` of the given start, inclusive?
pub fn within(&self, start: Date, days: i32) -> bool {
start <= self.civil && self.civil <= start + days
}
}
/// How a tradition names the levels it uses, and the glyph it shows them with.
pub struct Vocabulary {
pub label: fn(Level) -> &'static str,
pub emoji: fn(Level) -> &'static str,
}
pub struct Rule {
pub name: &'static str,
pub title: &'static str,
pub vocabulary: Vocabulary,
pub fast_for: fn(&Context) -> Fast,
/// Whether this tradition legislates the fast as precept at all.
pub has_obligation: bool,
}
pub fn get(kind: RuleKind) -> &'static Rule {
match kind {
RuleKind::Orthodox => &orthodox::RULE,
RuleKind::Ruthenian => &ruthenian::RULE,
}
}
+250
View File
@@ -0,0 +1,250 @@
//! The Typikon's fast, as the Orthodox Church keeps it.
//!
//! This is the discipline printed in the Typikon and reproduced, with small
//! variations, by every Orthodox jurisdiction's published calendar. It binds no
//! one in the canonical sense: the Orthodox Church legislates fasting as
//! tradition rather than as precept, so every day here is reported as
//! customary. That is not a softening. It is the difference between a rule of
//! life and a law, and the Eastern Catholic churches, which do have law about
//! this, are handled in their own module.
//!
//! A saint of vigil or polyeleos rank lifts the weekday discipline of a fast:
//! to wine and oil inside the Great Fast, as far as fish in the three lesser
//! fasts. That is why the printed calendars mark so many Wednesdays in the
//! Nativity Fast for wine and oil, and it is the rank in the menologion that
//! decides it.
//!
//! Where usages differ, this follows the common published rule and says so in
//! the reason. The known divergences:
//!
//! - **Great Thursday** is given wine and oil here. Some typika keep xerophagy.
//! - **The Exaltation of the Cross** and the **Beheading of the Forerunner**
//! are given wine and oil. Several jurisdictions print them as strict.
//! - **Lazarus Saturday** is given wine and oil; caviar is the traditional
//! addition, which this vocabulary has no level for.
//! - **Great Week** is kept at xerophagy on its weekdays whatever saint falls
//! in it, because the days of the Passion outrank the menologion.
//! - **Monastic practice**, and the Holy Mountain in particular, is stricter
//! than any of this throughout.
//!
//! A calendar cannot settle those. Your bishop and your spiritual father can.
use super::{Context, Rule, Vocabulary};
use crate::calendars::Weekday;
use crate::fasting::{Fast, Level};
use crate::menologion::Rank;
use crate::seasons::Season;
fn label(level: Level) -> &'static str {
match level {
Level::Strict => "no food until evening",
Level::Xerophagy => "xerophagy \u{2014} no oil or wine",
Level::WineOil => "wine and oil permitted",
Level::Fish => "fish, wine and oil",
Level::Dairy => "dairy and eggs; no meat",
Level::None => "no fast",
}
}
fn emoji(level: Level) -> &'static str {
match level {
Level::Strict => "\u{26d4}",
Level::Xerophagy => "\u{1f96c}",
Level::WineOil => "\u{1fad2}",
Level::Fish => "\u{1f41f}",
Level::Dairy => "\u{1f9c0}",
Level::None => "\u{1f356}",
}
}
/// How far a feast lifts a fast day.
fn relaxed_by(rank: Option<Rank>) -> Option<Level> {
match rank {
Some(Rank::Great) => Some(Level::Fish),
Some(Rank::Vigil) | Some(Rank::Polyeleos) => Some(Level::WineOil),
None => None,
}
}
/// Relax `level` for the day's saint, never past `cap`, never tightening.
fn for_feast(level: Level, ctx: &Context, cap: Level, reason: &str) -> Fast {
let Some(target) = relaxed_by(ctx.rank) else {
return Fast::customary(level, reason);
};
let lifted = level.relaxed_to(target, cap);
if lifted == level {
return Fast::customary(level, reason);
}
Fast::customary(lifted, &format!("{} \u{2014} the fast is relaxed", ctx.feast))
}
pub fn fast_for(ctx: &Context) -> Fast {
let md = ctx.fixed;
let mv = ctx.mv;
let weekend = ctx.is_weekend();
// The fast-free stretches, settled before anything else can override them.
// A Friday inside Bright Week is not a fast day, and the ordinary Friday
// rule further down would say otherwise.
match ctx.season {
Some(Season::BrightWeek) => return Fast::free("Bright Week \u{2014} no fasting"),
Some(Season::PentecostWeek) => {
return Fast::free("the week after Pentecost \u{2014} no fasting")
}
Some(Season::Christmastide) => {
return Fast::free("the Nativity to the eve of Theophany")
}
_ => {}
}
if ctx.within(mv.publican_pharisee, 6) {
// Fast-free precisely because the Pharisee boasted of fasting twice a week.
return Fast::free("fast-free week (the Publican and the Pharisee)");
}
// The Great Fast and Great Week.
if matches!(ctx.season, Some(Season::GreatFast) | Some(Season::GreatWeek)) {
let season = ctx.season.unwrap();
// The Annunciation is tested before the strict days, not after. It can
// land on Great Friday or Great Saturday, and when it does the feast
// still lifts the total fast to wine and oil: it does not vanish.
if md == (3, 25) {
if ctx.civil == mv.great_friday || ctx.civil == mv.holy_saturday {
return Fast::customary(
Level::WineOil,
"the Annunciation, on a day of the total fast",
);
}
return Fast::customary(Level::Fish, "the Annunciation");
}
if ctx.civil == mv.palm_sunday {
return Fast::customary(Level::Fish, "Palm Sunday");
}
if ctx.civil == mv.lazarus_saturday {
return Fast::customary(Level::WineOil, "Lazarus Saturday");
}
if ctx.civil == mv.clean_monday {
return Fast::customary(Level::Strict, "the first day of the Great Fast");
}
if ctx.civil == mv.great_friday {
return Fast::customary(Level::Strict, "Great and Holy Friday");
}
if ctx.civil == mv.holy_saturday {
// The one Saturday of the year kept without oil.
return Fast::customary(Level::Xerophagy, "Great and Holy Saturday");
}
if ctx.civil == mv.great_thursday {
return Fast::customary(Level::WineOil, "Great and Holy Thursday");
}
if season == Season::GreatWeek {
return Fast::customary(
Level::Xerophagy,
&format!("a weekday of the {}", season.name()),
);
}
if weekend {
return Fast::customary(
Level::WineOil,
&format!("Saturday and Sunday of the {}", season.name()),
);
}
return for_feast(
Level::Xerophagy,
ctx,
Level::WineOil,
&format!("a weekday of the {}", season.name()),
);
}
// Cheesefare: meat is set aside for good, everything else stays,
// Wednesday and Friday included.
if ctx.season == Some(Season::CheesefareWeek) {
return Fast::customary(Level::Dairy, "Cheesefare Week");
}
if ctx.civil == mv.meatfare {
return Fast::free("Meatfare Sunday \u{2014} the last day of meat");
}
// The Nativity Fast, which tightens for its last five days.
if ctx.season == Some(Season::NativityFast) {
if md == (12, 24) {
// The eve is relaxed to wine and oil on a Saturday or Sunday,
// because the Liturgy of St Basil is served with Vespers.
if weekend {
return Fast::customary(Level::WineOil, "the eve of the Nativity, on a weekend");
}
return Fast::customary(Level::Xerophagy, "the eve of the Nativity");
}
if md == (11, 21) {
return Fast::customary(Level::Fish, "the Entrance of the Theotokos");
}
if md >= (12, 20) {
if matches!(ctx.weekday, Weekday::Tuesday | Weekday::Thursday) || weekend {
return Fast::customary(Level::WineOil, "the forefeast of the Nativity");
}
return for_feast(Level::Xerophagy, ctx, Level::WineOil, "the forefeast of the Nativity");
}
if weekend {
return Fast::customary(Level::Fish, "Saturday and Sunday of the Nativity Fast");
}
if matches!(ctx.weekday, Weekday::Tuesday | Weekday::Thursday) {
return for_feast(Level::WineOil, ctx, Level::Fish, "Tuesday and Thursday of the Nativity Fast");
}
return for_feast(Level::Xerophagy, ctx, Level::Fish, "Monday, Wednesday and Friday of the Nativity Fast");
}
// The Apostles' Fast, kept the same way.
if ctx.season == Some(Season::Apostles) {
if weekend {
return Fast::customary(Level::Fish, "Saturday and Sunday of the Apostles' Fast");
}
if matches!(ctx.weekday, Weekday::Tuesday | Weekday::Thursday) {
return for_feast(Level::WineOil, ctx, Level::Fish, "Tuesday and Thursday of the Apostles' Fast");
}
return for_feast(Level::Xerophagy, ctx, Level::Fish, "Monday, Wednesday and Friday of the Apostles' Fast");
}
// The Dormition Fast, kept like the Great Fast.
if ctx.season == Some(Season::Dormition) {
if md == (8, 6) {
return Fast::customary(Level::Fish, "the Transfiguration");
}
if weekend {
return Fast::customary(Level::WineOil, "Saturday and Sunday of the Dormition Fast");
}
return for_feast(Level::Xerophagy, ctx, Level::WineOil, "a weekday of the Dormition Fast");
}
// The three fixed fast days outside any season.
if md == (1, 5) {
if weekend {
return Fast::customary(Level::WineOil, "the eve of Theophany, on a weekend");
}
return Fast::customary(Level::Xerophagy, "the eve of Theophany");
}
if md == (9, 14) {
return Fast::customary(Level::WineOil, "the Exaltation of the Holy Cross");
}
if md == (8, 29) {
return Fast::customary(Level::WineOil, "the Beheading of the Forerunner");
}
// Ordinary Wednesdays and Fridays, which a feast can lift.
if matches!(ctx.weekday, Weekday::Wednesday | Weekday::Friday) {
if let Some(target) = relaxed_by(ctx.rank) {
return Fast::customary(target, &format!("{} \u{2014} the fast is relaxed", ctx.feast));
}
return Fast::customary(Level::Xerophagy, "Wednesday and Friday");
}
Fast::new(Level::None, "", None)
}
pub static RULE: Rule = Rule {
name: "orthodox",
title: "the Typikon",
vocabulary: Vocabulary { label, emoji },
fast_for,
// The Orthodox Church does not legislate the fast as precept, so nothing
// here is reported as binding. See the module docs.
has_obligation: false,
};
+161
View File
@@ -0,0 +1,161 @@
//! Byzantine Catholic particular law, alongside the tradition it sits on.
//!
//! The Eastern Catholic churches differ from the Orthodox here in a way worth
//! being exact about: they have *law* on fasting, and it requires very much
//! less than the Typikon describes. Under current Ruthenian particular law the
//! Church binds only:
//!
//! - strict abstinence, no meat, fish, eggs or dairy, on the first day of the
//! Great Fast and on Great and Holy Friday;
//! - abstinence from meat on the Wednesdays and Fridays of the Great Fast.
//!
//! Everything else, the Apostles', Dormition and Nativity Fasts, the fuller
//! keeping of the Great Fast, ordinary Fridays through the year, is encouraged
//! and traditional and does **not** bind. So this module reports two things at
//! once: the level at which the day is kept by tradition, and the minimum that
//! actually obliges. A calendar that says "obligatory" where nothing obliges is
//! worse than no calendar; one that reports only the binding minimum makes the
//! obligatory days look laxer than the voluntary ones beside them.
//!
//! The three degrees this law knows map exactly onto the shared vocabulary: its
//! strict abstinence is `WineOil`, since it does not regulate oil or wine, and
//! its simple abstinence is `Dairy`, meat alone being set aside.
//!
//! **A known reading.** "The Wednesdays and Fridays of the Great Fast" is taken
//! here to mean the Forty Days, not Great and Holy Week, which the calendar
//! counts as its own season. Great and Holy Friday binds in its own right, so
//! only Great and Holy Wednesday turns on the reading. If your eparchy reads it
//! the other way, that Wednesday binds too.
//!
//! Source: the Byzantine Catholic Metropolitan Church of Pittsburgh. Other
//! Eastern Catholic churches legislate their own; where this library maps them
//! onto this ruleset it is an approximation, and the jurisdiction entry says so.
use super::{Context, Rule, Vocabulary};
use crate::calendars::{Date, Weekday};
use crate::fasting::{Fast, Level};
use crate::menologion::Rank;
use crate::seasons::Season;
fn label(level: Level) -> &'static str {
match level {
Level::WineOil => "no meat, fish, eggs or dairy",
Level::Dairy => "no meat",
Level::None => "no fast",
// Levels this law makes no distinction at, kept legible if ever emitted.
Level::Strict => "no food until evening",
Level::Xerophagy => "no meat, fish, eggs or dairy",
Level::Fish => "no meat",
}
}
fn emoji(level: Level) -> &'static str {
match level {
Level::Strict | Level::Xerophagy | Level::WineOil => "\u{1f96c}",
Level::Fish | Level::Dairy => "\u{1f41f}",
Level::None => "\u{1f356}",
}
}
/// Vigil fasts are kept on the preceding Friday when they land on a weekend.
///
/// "If the vigils of Christmas or Theophany fall on Saturday or Sunday, the
/// fasting and abstinence is done on the previous Friday."
fn off_weekend(day: Date) -> Date {
match day.weekday() {
Weekday::Saturday => day - 1,
Weekday::Sunday => day - 2,
_ => day,
}
}
pub fn fast_for(ctx: &Context) -> Fast {
let mv = ctx.mv;
// The two days the Church binds strictly.
if ctx.civil == mv.clean_monday {
return Fast::binding(Level::WineOil, "the first day of the Great Fast", Level::WineOil);
}
if ctx.civil == mv.great_friday {
return Fast::binding(Level::WineOil, "Great and Holy Friday", Level::WineOil);
}
// And the days it binds to abstinence from meat. Kept strictly by
// tradition; binding only as far as meat.
if ctx.season == Some(Season::GreatFast)
&& matches!(ctx.weekday, Weekday::Wednesday | Weekday::Friday)
{
return Fast::binding(
Level::WineOil,
"Wednesday and Friday of the Great Fast",
Level::Dairy,
);
}
// Fixed vigils and the Exaltation, moved off a weekend where the rule says.
for (when, label) in [
(ctx.civil_of_fixed(9, 14), "the Exaltation of the Holy Cross"),
(off_weekend(ctx.civil_of_fixed(12, 24)), "the vigil of the Nativity"),
(off_weekend(ctx.civil_of_fixed(1, 5)), "the vigil of Theophany"),
] {
if ctx.civil == when {
return Fast::customary(Level::WineOil, label);
}
}
// The fast-free stretches, which have to be settled before the ordinary
// Wednesday and Friday rule below or they get overridden by it. Christmas
// Day 2026 is a Friday, and telling you to abstain from meat on it would be
// plainly wrong.
match ctx.season {
Some(Season::BrightWeek) => return Fast::free("Bright Week \u{2014} no fasting"),
Some(Season::PentecostWeek) => {
return Fast::free("the week after Pentecost \u{2014} no fasting")
}
Some(Season::Christmastide) => return Fast::free("Christmastide \u{2014} no fasting"),
_ => {}
}
if ctx.within(mv.publican_pharisee, 6) {
return Fast::free("fast-free week (the Publican and the Pharisee)");
}
// The rest of the Great Fast and Great Week.
if let Some(season @ (Season::GreatFast | Season::GreatWeek)) = ctx.season {
if ctx.is_weekend() {
return Fast::customary(Level::Dairy, season.name());
}
return Fast::customary(Level::WineOil, season.name());
}
if ctx.season == Some(Season::CheesefareWeek) {
return Fast::customary(Level::Dairy, "Cheesefare Week");
}
// The three voluntary seasons.
if let Some(season @ (Season::Apostles | Season::Dormition | Season::NativityFast)) =
ctx.season
{
return Fast::customary(Level::Dairy, season.name());
}
// A great feast releases the ordinary weekday abstinence.
if matches!(ctx.weekday, Weekday::Wednesday | Weekday::Friday) {
if ctx.rank == Some(Rank::Great) {
return Fast::free(&format!("{} \u{2014} the fast is released", ctx.feast));
}
return Fast::customary(
Level::Dairy,
if ctx.weekday == Weekday::Friday { "Friday" } else { "Wednesday" },
);
}
Fast::new(Level::None, "", None)
}
pub static RULE: Rule = Rule {
name: "ruthenian",
title: "Ruthenian particular law",
vocabulary: Vocabulary { label, emoji },
fast_for,
has_obligation: true,
};