diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index ca5d0cf..0b3c74b 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c53aa73..f8ae9f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index d5f1e07..dbab64a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/versioning.md b/docs/versioning.md index f87b04a..0026212 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -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 diff --git a/python/typikon/_typikon.pyi b/python/typikon/_typikon.pyi index 52b20eb..77778ea 100644 --- a/python/typikon/_typikon.pyi +++ b/python/typikon/_typikon.pyi @@ -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 diff --git a/src/calendars.rs b/src/calendars.rs index 82fab89..49abae4 100644 --- a/src/calendars.rs +++ b/src/calendars.rs @@ -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 { if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) { return None; diff --git a/src/day.rs b/src/day.rs index cf00d69..46b2523 100644 --- a/src/day.rs +++ b/src/day.rs @@ -34,7 +34,14 @@ pub struct Day { pub emoji: &'static str, pub reason: String, pub obligation: Option, - 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, + /// 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(), diff --git a/src/export/csv.rs b/src/export/csv.rs index 8b45f83..d7a37cf 100644 --- a/src/export/csv.rs +++ b/src/export/csv.rs @@ -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(), diff --git a/src/export/ics.rs b/src/export/ics.rs index 7e10977..4e33ee6 100644 --- a/src/export/ics.rs +++ b/src/export/ics.rs @@ -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}"), diff --git a/src/fasting.rs b/src/fasting.rs index 3a70958..e967fc4 100644 --- a/src/fasting.rs +++ b/src/fasting.rs @@ -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, - /// 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, } 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), } } } diff --git a/src/main.rs b/src/main.rs index 8d53f73..9766a60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 diff --git a/src/menologion.rs b/src/menologion.rs index f021325..00a1026 100644 --- a/src/menologion.rs +++ b/src/menologion.rs @@ -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() diff --git a/src/python.rs b/src/python.rs index 8283d3d..0c9213c 100644 --- a/src/python.rs +++ b/src/python.rs @@ -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] diff --git a/src/rules/orthodox.rs b/src/rules/orthodox.rs index 4d56cdb..5faa5ab 100644 --- a/src/rules/orthodox.rs +++ b/src/rules/orthodox.rs @@ -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: diff --git a/tests/elide.rs b/tests/elide.rs new file mode 100644 index 0000000..9c311d8 --- /dev/null +++ b/tests/elide.rs @@ -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) + ); + } + } +} diff --git a/tests/typikon.rs b/tests/typikon.rs index 18e7126..b76ff0d 100644 --- a/tests/typikon.rs +++ b/tests/typikon.rs @@ -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); + } } }