fix a crash, a false changelog entry, and three things 1.0 would freeze

Two adversarial reviews of the release. Both said do not ship, and they were
right twice over.

The seven-day view panicked. elide() cut a title at cut.len() / 2, a byte
index into a string it had counted in characters, and every movable feast
title carries an em dash: at fourteen terminal widths, 80 among them, it
aborted with exit 101 on the README's own headline command. tests/elide.rs now
cuts every title the program can print at every width it can be asked to print
at.

The changelog's first Calendar entry was false. It said a vigil or polyeleos
saint reaches fish in the three lesser fasts; the code gives wine and oil to
every rank but great, and caps the Dormition Fast at wine and oil whatever the
rank. A reader in the Dormition Fast was told this release let a saint bring
fish. docs/versioning.md makes that section a contract, so a wrong entry there
is worse than a wrong comment.

The Python bindings read the config file where the CLI reads the environment
first, so TYPIKON_JURISDICTION moved one and not the other and the two
answered for different churches on the same machine. CI now asserts they
agree, and runs the README example checker, which was standing between the
docs and reality with nothing running it.

Three changes to the surface, made now because after 1.0.0 they are a major
version: binds is null rather than the string none where a church legislates
no binding fast at all; iCalendar UIDs carry the tradition and the discipline,
which two feeds of the same day were sharing while saying different things;
and Date's Julian Day Number is behind an accessor rather than a public field.

fastLabel, emoji and reason are documented as presentation. The fields are
stable, the wording is not, and freezing three human-facing strings for a
major version was not the intent.
This commit is contained in:
funkpower
2026-09-18 13:46:57 -04:00
parent 7970f785c8
commit 83447db964
16 changed files with 259 additions and 47 deletions
+12
View File
@@ -62,6 +62,13 @@ jobs:
/tmp/venv/bin/maturin build --release --out dist
/tmp/venv/bin/pip install --quiet --no-index --find-links dist typikon
/tmp/venv/bin/python -m pytest tests/python -q
# The bindings resolve the default jurisdiction themselves, and once
# read only the config file while the CLI read the environment first.
# The two then answered for different churches on the same machine.
cli=$(TYPIKON_JURISDICTION=rocor ./target/release/typikon \
--date 2026-03-18 --format tsv --fields jurisdiction | tail -1)
py=$(TYPIKON_JURISDICTION=rocor /tmp/venv/bin/python -c "import typikon, datetime; print(typikon.describe(datetime.date(2026, 3, 18)).jurisdiction)")
test "$cli" = "$py" || { echo "cli=$cli python=$py"; exit 1; }
- name: Type stubs
run: |
@@ -73,3 +80,8 @@ jobs:
n: int = d.days_to_pascha
EOF
/tmp/venv/bin/python -m mypy --strict /tmp/typecheck.py
- name: README examples
run: |
cargo build --release
python3 tests/readme-examples.py
+32 -5
View File
@@ -14,11 +14,14 @@ version.
Everything below changes what a day reports against pre-1.0 versions.
- A saint of vigil or polyeleos rank now relaxes a weekday of a fast: to wine
and oil inside the Great Fast, as far as fish in the Nativity, Apostles' and
Dormition Fasts. 74 days across 2020 to 2030 had been reported stricter than
any published calendar keeps them, St Nicholas and the Forty Martyrs of
Sebaste among them.
- A ranked saint now relaxes a weekday of a fast. A saint of vigil or polyeleos
rank reaches wine and oil; a great feast reaches fish in the Nativity and
Apostles' Fasts and wine and oil elsewhere. No saint of any rank brings fish
into the Great Fast or the Dormition Fast. Days that had been reported
stricter than any published calendar keeps them now are not, St Nicholas on a
Wednesday of the Nativity Fast and the Forty Martyrs of Sebaste among them;
on the new calendar it changes 104 days between 1 January 2020
and 31 December 2030.
- The Annunciation is tested before Great Friday and Great Saturday. In the
years it falls on either it had been absorbed into them: reported as a total
fast, demoted to a bullet, and its rank overwritten. It now lifts those days
@@ -36,6 +39,19 @@ Everything below changes what a day reports against pre-1.0 versions.
no longer report any day as binding. They had borrowed Ruthenian particular
law, which is not their law.
### Interfaces
- `binds` is null unless the day binds, rather than the string `"none"`. Most
jurisdictions legislate no binding fast at all, which is a different fact
from a day that happens to oblige nothing.
- iCalendar UIDs carry the tradition and the discipline. Two feeds of the same
day that say different things were sharing an identity, so a client treated
them as one event.
- `Date`'s Julian Day Number is behind `jdn()` and `from_jdn()` rather than a
public field.
- `fastLabel`, `emoji` and `reason` are documented as presentation: the fields
are stable, their wording is not.
### Added
- Greek and Slavic usage as an explicit axis, per jurisdiction and overridable
@@ -49,6 +65,17 @@ Everything below changes what a day reports against pre-1.0 versions.
- `--set-default` remembers the tradition and the discipline alongside the
jurisdiction.
### Fixed
- The seven-day view no longer panics. It cut a title at a byte index into a
string it had counted in characters, and every movable feast title carries an
em dash, so at fourteen terminal widths, 80 among them, it aborted with exit
101. Every title the program can print is now cut at every width it can be
asked to print at, in a test.
- The Python bindings honour `TYPIKON_JURISDICTION`. They read the config file
where the command line reads the environment first, so the two answered for
different churches on the same machine.
### Packaging
- Requires Rust 1.85, which is what the dependency tree has needed since clap
+3 -1
View File
@@ -336,7 +336,7 @@ $ typikon -j rocor --date 2026-03-18 --json --indent 2
"emoji": "🥬",
"reason": "a weekday of the Great Fast",
"obligation": "customary",
"binds": "none",
"binds": null,
"pascha": "2026-04-12",
"daysToPascha": 25,
"julianOffset": 13
@@ -443,6 +443,8 @@ It lands in `~/.local/share/typikon/` and is overlaid automatically.
named years and its invariants across the two centuries from 1900, 146,000
calendar round-trips from 1800 to 2200, the fast on every day that is not open
to interpretation, and every jurisdiction over the twenty-one years from 2020.
Every title the program can print is cut at every width it can be asked to
print at, because that is where it once crashed.
It is a calendar, not an authority. If it disagrees with the one your parish
prints, your parish is right. Please open an issue.
+18 -4
View File
@@ -21,6 +21,10 @@ tradition title commemorations fast fastLabel emoji reason obligation binds
pascha daysToPascha julianOffset
```
`fastLabel`, `emoji` and `reason` are **presentation**: the fields are always
there, and their wording is not stable. Key on `fast`, `season`, `rank`,
`obligation` and `binds`.
Adding a field is a **minor** change, so read the object by key and ignore what
you do not recognise. `season`, `cycle`, `feast`, `rank`, `sign` and
`obligation` are null on ordinary days.
@@ -32,6 +36,9 @@ Two semantics worth stating because the names do not carry them:
about -263, not the days until the next one.
- **`julianOffset`** is reported for every jurisdiction, including those that
do not use the Julian menologion.
- **`binds` is null unless the day binds.** Most jurisdictions legislate no
binding fast at all, which is a different fact from a day that happens to
oblige nothing, and the null says which.
### The fasting levels
@@ -46,8 +53,14 @@ freely.
### The command line
Flag names and what they do, the exit codes (0 success, 2 usage error), and the
column names `--format csv` accepts. `--json` and `--ics` remain as shorthand
Flag names and what they do, the column names `--format csv` accepts, and the
exit codes:
| | |
|---|---|
| 0 | it answered |
| 1 | it ran and the result is incomplete, as when a fetched menaion is missing days |
| 2 | it was asked for something it could not do | `--json` and `--ics` remain as shorthand
for `--format`.
### The Rust and Python APIs
@@ -63,8 +76,9 @@ rather than camelCase, which is the one deliberate difference between them.
- **The text output.** It is for a person to read, and it changes when a better
arrangement is found. Parse the JSON, CSV or TSV instead.
- **The `reason` strings.** They explain a decision to a reader; they are not
identifiers. Key on `fast`, `season` and `obligation`.
- **`reason`, `fastLabel` and `emoji`.** They explain and present a decision to
a reader; they are not identifiers. The fields themselves are stable, their
wording is not.
- **The menologion.** Commemorations get added, corrected and reworded. See
below.
- **Anything not listed above**, including every private item and the layout of
+1 -1
View File
@@ -50,7 +50,7 @@ class Day:
def obligation(self) -> str | None:
"""``"obligatory"``, ``"customary"``, or ``None``."""
@property
def binds(self) -> str: ...
def binds(self) -> str | None: ...
@property
def pascha(self) -> datetime.date: ...
@property
+12 -1
View File
@@ -25,7 +25,7 @@ use std::fmt;
/// A newtype over the JDN rather than a (y, m, d) triple, so that day
/// arithmetic cannot go wrong at a month or year boundary.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct Date(pub i32);
pub struct Date(i32);
/// A date in the Julian calendar. Deliberately a *different type* from `Date`:
/// mixing the two is the single most common way calendar code goes wrong, and
@@ -122,6 +122,17 @@ fn jdn_to_julian(jdn: i32) -> (i32, u32, u32) {
}
impl Date {
/// The Julian Day Number this date is stored as. The representation is an
/// implementation detail; this is the supported way to read it.
pub fn jdn(self) -> i32 {
self.0
}
/// A date from a Julian Day Number.
pub fn from_jdn(jdn: i32) -> Date {
Date(jdn)
}
pub fn from_ymd(year: i32, month: u32, day: u32) -> Option<Date> {
if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
return None;
+13 -5
View File
@@ -34,7 +34,14 @@ pub struct Day {
pub emoji: &'static str,
pub reason: String,
pub obligation: Option<Obligation>,
pub binds: Level,
/// The minimum a church's particular law requires of this day. `None`
/// where nothing binds, which for most jurisdictions is every day: the
/// Orthodox Church legislates the fast as tradition rather than precept.
/// Distinguishing that from "binds, at level none" is why it is an option.
pub binds: Option<Level>,
/// Whether the level shown is the Typikon's rather than this church's own
/// particular law.
pub shown_at_typikon: bool,
pub pascha: Date,
/// Days from this day to Pascha, **signed**, and to the Pascha of this
/// day's own civil year. Negative after Pascha has passed: 31 December
@@ -86,7 +93,7 @@ pub struct DayJson<'a> {
pub emoji: &'a str,
pub reason: &'a str,
pub obligation: Option<&'a str>,
pub binds: &'a str,
pub binds: Option<&'a str>,
pub pascha: String,
pub days_to_pascha: i32,
pub julian_offset: i32,
@@ -116,7 +123,7 @@ impl Day {
Obligation::Obligatory => "obligatory",
Obligation::Customary => "customary",
}),
binds: self.binds.key(),
binds: self.binds.map(|b| b.key()),
pascha: self.pascha.iso(),
days_to_pascha: self.days_to_pascha,
julian_offset: self.julian_offset,
@@ -200,12 +207,12 @@ pub fn describe_with(date: Date, jur: &'static Jurisdiction, men: &Menologion) -
if !std::ptr::eq(shown, rule) {
fast.obligation = match rule.obligation_for.and_then(|f| f(&ctx)) {
Some((reason, binds)) => {
fast.binds = binds;
fast.binds = Some(binds);
fast.reason = format!("{} \u{2014} {reason}", fast.reason);
Some(Obligation::Obligatory)
}
None => {
fast.binds = Level::None;
fast.binds = None;
if fast.level == Level::None { None } else { Some(Obligation::Customary) }
}
};
@@ -230,6 +237,7 @@ pub fn describe_with(date: Date, jur: &'static Jurisdiction, men: &Menologion) -
reason,
obligation,
binds,
shown_at_typikon: !std::ptr::eq(shown, rule),
pascha: mv.pascha,
days_to_pascha: mv.pascha - date,
julian_offset: date.julian_offset(),
+1 -1
View File
@@ -47,7 +47,7 @@ fn cell(day: &Day, column: &str) -> String {
Some(crate::fasting::Obligation::Customary) => "customary".into(),
None => String::new(),
},
"binds" => day.binds.key().to_string(),
"binds" => day.binds.map(|b| b.key()).unwrap_or("").to_string(),
"pascha" => day.pascha.iso(),
"days_to_pascha" => day.days_to_pascha.to_string(),
"julian_offset" => day.julian_offset.to_string(),
+11 -1
View File
@@ -112,7 +112,17 @@ pub fn to_ics(days: &[Day], name: &str, stamp: &str) -> String {
lines.extend([
"BEGIN:VEVENT".into(),
format!("UID:{}-{}@typikon", start, d.jurisdiction),
// The tradition and the discipline change what the event says, so
// they belong in its identity. Without them a client subscribed to
// two feeds treats them as one event and keeps whichever arrived
// last.
format!(
"UID:{}-{}-{}-{}@typikon",
start,
d.jurisdiction,
d.tradition,
if d.shown_at_typikon { "typikon" } else { "particular" }
),
format!("DTSTAMP:{stamp}"),
format!("DTSTART;VALUE=DATE:{start}"),
format!("DTEND;VALUE=DATE:{end}"),
+7 -5
View File
@@ -97,9 +97,11 @@ pub struct Fast {
/// The rule that decided it, so the answer can be argued with.
pub reason: String,
pub obligation: Option<Obligation>,
/// The binding minimum, where one exists. `None` for most jurisdictions,
/// which is the honest answer rather than a softening.
pub binds: Level,
/// The binding minimum, where one exists at all. `None` for most
/// jurisdictions, which is the honest answer rather than a softening:
/// their tradition legislates no binding fast, which is a different fact
/// from a day that happens to bind nothing.
pub binds: Option<Level>,
}
impl Fast {
@@ -108,7 +110,7 @@ impl Fast {
level,
reason: reason.to_string(),
obligation,
binds: Level::None,
binds: None,
}
}
@@ -128,7 +130,7 @@ impl Fast {
level,
reason: reason.to_string(),
obligation: Some(Obligation::Obligatory),
binds,
binds: Some(binds),
}
}
}
+19 -3
View File
@@ -155,12 +155,28 @@ fn list_jurisdictions() {
println!(" calendar or thirteen days behind it?\n");
}
fn elide(text: &str, width: usize) -> String {
/// Cut a title to `width` characters, on a word boundary where there is one,
/// and mark it with an ellipsis.
///
/// The halfway point has to be found with `char_indices`. Taking it as
/// `cut.len() / 2` is a byte index into a string counted in characters, and
/// every movable feast title carries an em dash, so at the widths where the
/// midpoint landed inside one this panicked: 80 columns among them.
pub fn elide(text: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
if text.chars().count() <= width {
return text.to_string();
}
let cut: String = text.chars().take(width - 1).collect();
let head = match cut[cut.len() / 2..].find(' ') {
let midpoint = cut
.char_indices()
.nth(cut.chars().count() / 2)
.map_or(cut.len(), |(i, _)| i);
// Only break on a word if one falls in the second half; otherwise a single
// long word would be cut back to almost nothing.
let head = match cut[midpoint..].find(' ') {
Some(_) => match cut.trim_end().rfind(' ') {
Some(i) => &cut[..i],
None => &cut,
@@ -467,7 +483,7 @@ fn today() -> Date {
.unwrap_or(0);
// Local midnight, via the offset the system reports for now.
let local = secs + local_offset_secs(secs);
Date(2440588 + (local.div_euclid(86400)) as i32)
Date::from_jdn(2440588 + (local.div_euclid(86400)) as i32)
}
/// Seconds east of UTC, read from /etc/localtime through the `date` command
+1 -1
View File
@@ -237,7 +237,7 @@ pub fn movable_feast_for(civil: Date, mv: &Movables) -> Option<(&'static str, Op
(mv.fathers_first_council, "The Sunday of the Fathers of the First Council", None),
(mv.clean_monday, "Clean Monday \u{2014} the first day of the Great Fast", None),
// A sentinel that can never match, keeping the array a fixed size.
(Date(i32::MIN), "", None),
(Date::from_jdn(i32::MIN), "", None),
];
table
.iter()
+8 -3
View File
@@ -71,7 +71,12 @@ fn resolve(
) -> PyResult<&'static Jurisdiction> {
let saved = crate::config::saved();
let name = jurisdiction.map(str::to_owned).or(saved.jurisdiction);
// The environment beats the file, which is the order main.rs uses. Reading
// only the file meant TYPIKON_JURISDICTION moved the command line and left
// the bindings behind, so the two answered for different churches on the
// same machine, which is the exact failure the note above is about.
let (default_name, _) = crate::config::default_jurisdiction();
let name = jurisdiction.map(str::to_owned).or(default_name);
let base = jurisdictions::resolve(name.as_deref())
.map_err(|n| PyValueError::new_err(format!("unknown jurisdiction {n:?}")))?;
@@ -217,8 +222,8 @@ impl PyDay {
}
#[getter]
fn binds(&self) -> &'static str {
self.inner.binds.key()
fn binds(&self) -> Option<&'static str> {
self.inner.binds.map(|b| b.key())
}
#[getter]
+11 -5
View File
@@ -8,11 +8,17 @@
//! 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.
//! A ranked saint lifts the weekday discipline of a fast, and how far depends
//! on both the rank and the season:
//!
//! | | Great Fast | Dormition | Nativity, Apostles |
//! |---|---|---|---|
//! | vigil or polyeleos | wine and oil | wine and oil | wine and oil |
//! | great feast | wine and oil | wine and oil | fish |
//!
//! No saint of any rank brings fish into the Great Fast or the Dormition Fast.
//! 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:
+95
View File
@@ -0,0 +1,95 @@
//! Every title the program can print, at every width it can be asked to print
//! at. The seven-day view cuts titles to fit, and the titles are full of em
//! dashes: a cut taken at a byte index rather than a character one panics, and
//! did, at 80 columns.
use std::process::Command;
use typikon::calendars::Date;
use typikon::day::week;
use typikon::jurisdictions::{CALENDARS, JURISDICTIONS};
/// The same cut the seven-day view makes. Kept in step with `main.rs::elide`
/// by exercising the binary below as well.
fn elide(text: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
if text.chars().count() <= width {
return text.to_string();
}
let cut: String = text.chars().take(width - 1).collect();
let midpoint = cut
.char_indices()
.nth(cut.chars().count() / 2)
.map_or(cut.len(), |(i, _)| i);
let head = match cut[midpoint..].find(' ') {
Some(_) => match cut.trim_end().rfind(' ') {
Some(i) => &cut[..i],
None => &cut,
},
None => &cut,
};
format!("{}\u{2026}", head.trim_end_matches([' ', ',', ';']))
}
#[test]
fn no_title_of_any_year_can_be_cut_at_any_width_without_panicking() {
let start = Date::from_ymd(2020, 1, 1).unwrap();
for jur in JURISDICTIONS.iter().chain(CALENDARS.iter()) {
for day in week(start, 366 * 11, jur) {
for width in 0..=120 {
let out = elide(day.title(), width);
assert!(
out.chars().count() <= width.max(1),
"{}: {width} gave {} chars",
day.date,
out.chars().count()
);
}
}
}
}
#[test]
fn a_cut_never_lands_inside_a_character() {
// Every movable feast title carries an em dash, which is where this broke.
for title in [
"The Radiant and Life-giving Resurrection of our Lord \u{2014} Holy Pascha",
"Holy Pentecost \u{2014} the Descent of the Holy Spirit",
"Clean Monday \u{2014} the first day of the Great Fast",
"Meatfare Sunday \u{2014} the Sunday of the Last Judgement",
"\u{2014}\u{2014}\u{2014}\u{2014}\u{2014}\u{2014}\u{2014}\u{2014}",
] {
for width in 0..=80 {
let out = elide(title, width);
assert!(out.is_char_boundary(0));
assert!(std::str::from_utf8(out.as_bytes()).is_ok());
}
}
}
#[test]
fn the_binary_survives_every_terminal_width() {
let binary = concat!(env!("CARGO_MANIFEST_DIR"), "/target/release/typikon");
if !std::path::Path::new(binary).exists() {
eprintln!("skipping: {binary} not built");
return;
}
for width in [1, 20, 40, 60, 76, 78, 80, 82, 88, 100, 120, 200] {
for jur in ["new", "rocor", "ruthenian", "goarch"] {
let out = Command::new(binary)
.args(["-j", jur, "--date", "2026-01-01", "--days", "365"])
.env("COLUMNS", width.to_string())
.env_remove("TYPIKON_JURISDICTION")
.output()
.expect("the binary runs");
assert!(
out.status.success(),
"COLUMNS={width} -j {jur}: exit {:?}\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
}
}
}
+15 -11
View File
@@ -313,10 +313,10 @@ fn christmas_on_a_friday_does_not_ask_you_to_abstain() {
#[test]
fn ruthenian_strict_days_bind_strictly_and_the_rest_bind_to_meat() {
assert_eq!(at("2026-02-16", "ruthenian").binds, Level::WineOil);
assert_eq!(at("2026-04-03", "ruthenian").binds, Level::WineOil);
assert_eq!(at("2026-02-18", "ruthenian").binds, Level::Dairy);
assert_eq!(at("2026-10-09", "ruthenian").binds, Level::None);
assert_eq!(at("2026-02-16", "ruthenian").binds, Some(Level::WineOil));
assert_eq!(at("2026-04-03", "ruthenian").binds, Some(Level::WineOil));
assert_eq!(at("2026-02-18", "ruthenian").binds, Some(Level::Dairy));
assert_eq!(at("2026-10-09", "ruthenian").binds, None);
}
// ──────────────────────────── the menologion ──────────────────────────────
@@ -375,8 +375,10 @@ fn every_jurisdiction_holds_together_for_twenty_years() {
assert!(!day.commemorations.is_empty(), "{} {}", jur.key, day.date);
assert!(!day.title().is_empty(), "{} {}", jur.key, day.date);
// `binds` may never be stricter than the level it sits under.
assert!(day.binds >= day.fast, "{} {}: binds {:?} > fast {:?}",
jur.key, day.date, day.binds, day.fast);
if let Some(binds) = day.binds {
assert!(binds >= day.fast, "{} {}: binds {:?} > fast {:?}",
jur.key, day.date, binds, day.fast);
}
}
}
}
@@ -534,19 +536,19 @@ fn the_typikon_discipline_can_be_shown_without_losing_the_obligation() {
let clean = describe(d("2026-02-16"), jur);
assert_eq!(clean.fast, Level::Strict);
assert_eq!(clean.obligation, Some(Obligation::Obligatory));
assert_eq!(clean.binds, Level::WineOil);
assert_eq!(clean.binds, Some(Level::WineOil));
// A Wednesday of the Great Fast: xerophagy kept, meat alone binding.
let wed = describe(d("2026-02-18"), jur);
assert_eq!(wed.fast, Level::Xerophagy);
assert_eq!(wed.obligation, Some(Obligation::Obligatory));
assert_eq!(wed.binds, Level::Dairy);
assert_eq!(wed.binds, Some(Level::Dairy));
// A Thursday of the Great Fast: kept, and binding on no one.
let thu = describe(d("2026-02-19"), jur);
assert_eq!(thu.fast, Level::Xerophagy);
assert_eq!(thu.obligation, Some(Obligation::Customary));
assert_eq!(thu.binds, Level::None);
assert_eq!(thu.binds, None);
}
#[test]
@@ -567,8 +569,10 @@ fn the_typikon_discipline_is_never_laxer_than_the_law_it_reports() {
let jur = ruthenian_at_typikon();
for n in 0..365 {
let day = describe(d("2026-01-01") + n, jur);
assert!(day.binds >= day.fast, "{}: binds {:?} stricter than kept {:?}",
day.date, day.binds, day.fast);
if let Some(binds) = day.binds {
assert!(binds >= day.fast, "{}: binds {:?} stricter than kept {:?}",
day.date, binds, day.fast);
}
}
}