Skip to main content

ucal_core/
qualified.rs

1//! Calendar-qualified renderings (§6.6, §13.4).
2//!
3//! # The qualifier is not optional, and not a convention
4//!
5//! §6.6: emitting a local calendar rendering without its id and kind is
6//! `UCAL-E0007`. A rule of that shape can be satisfied two ways — by discipline,
7//! or by construction — and only the second survives contact with a codebase.
8//!
9//! So there is no type here that renders a local calendar value on its own.
10//! [`Qualified`] is the only thing that implements [`core::fmt::Display`] for one,
11//! and it cannot be built without a [`CalendarQualifier`]. A caller who wants a
12//! string must state which calendar produced it and whether that calendar was
13//! *derived* (Rule K) or *legacy* (§8.6). §13.4 puts [`Kind`] in core for exactly
14//! this reason: every rendering path, in every crate, has to route through it.
15//!
16//! # Why the distinction is load-bearing
17//!
18//! Failure mode F9 is "Earth becomes the template rather than an instance". A
19//! legacy calendar is a declared table — irregular month lengths, a seven-day
20//! week with no astronomical period behind it, an intercalation rule that is not
21//! a continued-fraction convergent. A derived calendar is a consequence of a
22//! body's periods and nothing else. Presenting the two without distinction would
23//! let the first pass for the second, which is precisely the confusion Rule K
24//! exists to prevent.
25
26#[cfg(feature = "alloc")]
27use alloc::string::String;
28use core::fmt;
29
30use crate::error::{Code, Result, TimeError};
31
32/// Whether a calendar is a Rule K derivation or declared legacy data.
33///
34/// Lives in core so that no rendering path anywhere in the workspace can avoid
35/// stating it (§13.4).
36#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
37pub enum Kind {
38    /// Produced by the single derivation mechanism of Rule K: units from the
39    /// body's periods, intercalation from continued fractions, grouping from a
40    /// declared satellite. Nothing about it is a table.
41    Derived,
42    /// Declared table data preserved for interoperation (§8.6). Outside Rule K,
43    /// and marked as such in every output.
44    Legacy,
45}
46
47impl Kind {
48    /// The suffix convention of §6.6: `-d` marks a derivation.
49    pub const fn marker(self) -> &'static str {
50        match self {
51            Kind::Derived => "-d",
52            Kind::Legacy => "",
53        }
54    }
55
56    /// Whether this kind is a Rule K derivation.
57    pub const fn is_derived(self) -> bool {
58        matches!(self, Kind::Derived)
59    }
60
61    /// The warning that accompanies a value from this kind of calendar, if any.
62    ///
63    /// A legacy value carries `UCAL-W0005` on request (§8.6).
64    pub const fn warning(self) -> Option<crate::error::Warning> {
65        match self {
66            Kind::Derived => None,
67            Kind::Legacy => Some(crate::error::Warning::W0005),
68        }
69    }
70}
71
72impl fmt::Display for Kind {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        f.write_str(match self {
75            Kind::Derived => "derived",
76            Kind::Legacy => "legacy",
77        })
78    }
79}
80
81/// Anything that can say which calendar it is and what kind.
82///
83/// Both trait objects and concrete calendars implement it, so a runtime check is
84/// available where the type is erased — see [`require_derived`].
85pub trait CalendarIdentity {
86    /// The calendar's id, e.g. `"earth-civil"` or `"earth-d"`.
87    fn id(&self) -> &str;
88    /// Derived or legacy.
89    fn kind(&self) -> Kind;
90    /// The anchor revision, for a derived calendar (Rule J.5). Legacy calendars
91    /// have no anchor and return `None`.
92    fn revision(&self) -> Option<u32> {
93        None
94    }
95}
96
97/// Reject a legacy calendar where Rule K requires a derived one (`UCAL-E0065`).
98///
99/// The primary defence is the type system: `LegacyCalendar` and `BodyCalendar`
100/// are distinct traits with no blanket conversion, so the mistake usually cannot
101/// be written. This is the fallback for erased types, where the compiler no
102/// longer knows which is which.
103pub fn require_derived(c: &dyn CalendarIdentity) -> Result<()> {
104    if c.kind().is_derived() {
105        Ok(())
106    } else {
107        Err(TimeError::with_context(
108            Code::E0065,
109            "this operation requires a calendar derived under Rule K; a legacy \
110             calendar is declared table data and cannot substitute for one",
111        ))
112    }
113}
114
115/// The `id`, `kind` and optional revision that every local rendering must carry.
116#[derive(Clone, Copy, PartialEq, Eq, Debug)]
117pub struct CalendarQualifier<'a> {
118    id: &'a str,
119    kind: Kind,
120    revision: Option<u32>,
121}
122
123impl<'a> CalendarQualifier<'a> {
124    /// A qualifier for a derived calendar, with the anchor revision that produced
125    /// the value (Rule J.5 — renderings carry it so values from different
126    /// revisions are never silently compared).
127    pub const fn derived(id: &'a str, revision: u32) -> Self {
128        CalendarQualifier {
129            id,
130            kind: Kind::Derived,
131            revision: Some(revision),
132        }
133    }
134
135    /// A qualifier for a legacy calendar. There is no revision, because there is
136    /// no anchor — a legacy calendar is a table, not a determination.
137    pub const fn legacy(id: &'a str) -> Self {
138        CalendarQualifier {
139            id,
140            kind: Kind::Legacy,
141            revision: None,
142        }
143    }
144
145    /// The calendar id.
146    pub const fn id(&self) -> &'a str {
147        self.id
148    }
149
150    /// Derived or legacy.
151    pub const fn kind(&self) -> Kind {
152        self.kind
153    }
154
155    /// The anchor revision, if this is a derived calendar.
156    pub const fn revision(&self) -> Option<u32> {
157        self.revision
158    }
159
160    /// Attach a value, producing something that can be rendered.
161    pub const fn attach<T>(self, value: T) -> Qualified<'a, T> {
162        Qualified {
163            qualifier: self,
164            value,
165        }
166    }
167}
168
169impl fmt::Display for CalendarQualifier<'_> {
170    /// `earth-d/1` or `earth-civil` (§6.6).
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        f.write_str(self.id)?;
173        if let Some(r) = self.revision {
174            write!(f, "/{r}")?;
175        }
176        Ok(())
177    }
178}
179
180/// A local calendar value together with the qualifier §6.6 requires.
181///
182/// This is the only way to render one. There is deliberately no `Display` on the
183/// field structs themselves, and no `From<Fields> for String`, so an unqualified
184/// rendering cannot be produced by accident — see `tests/compile_fail/`.
185#[derive(Clone, Copy, PartialEq, Eq, Debug)]
186pub struct Qualified<'a, T> {
187    qualifier: CalendarQualifier<'a>,
188    value: T,
189}
190
191impl<'a, T> Qualified<'a, T> {
192    /// The qualifier.
193    pub const fn qualifier(&self) -> &CalendarQualifier<'a> {
194        &self.qualifier
195    }
196
197    /// The underlying value.
198    pub const fn value(&self) -> &T {
199        &self.value
200    }
201
202    /// Consume, yielding the value. Named so that discarding the qualifier is a
203    /// visible act rather than a coercion.
204    pub fn into_unqualified(self) -> T {
205        self.value
206    }
207
208    /// The warning this rendering carries, if any (`UCAL-W0005` for legacy).
209    pub const fn warning(&self) -> Option<crate::error::Warning> {
210        self.qualifier.kind.warning()
211    }
212}
213
214impl<T: fmt::Display> fmt::Display for Qualified<'_, T> {
215    /// `earth-civil: 2026-07-29T00:00:00Z`, `earth-d/1: 2026-208.4137` (§6.6).
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        write!(f, "{}: {}", self.qualifier, self.value)
218    }
219}
220
221/// Whether a string is a well-formed calendar id.
222///
223/// §6.6 gives examples — `earth-d`, `earth-civil`, `mars-d` — but does not define
224/// the grammar, and without one the notation is ambiguous: the *body* of a
225/// rendering may contain colons (`earth-civil: 2026-07-29T00:00:00Z`), so a naive
226/// split at the first colon happily produces a "calendar id" of `2026-07-29T00`.
227///
228/// The grammar adopted here is the narrowest that admits every id the RFC uses:
229/// a lowercase letter, then lowercase letters, digits and hyphens. Requiring a
230/// leading letter is what disambiguates a qualifier from a date, since every date
231/// form in this specification begins with a digit. See `spec/SPEC-DELTAS.md`
232/// D-A9.
233pub fn is_valid_calendar_id(id: &str) -> bool {
234    let mut bytes = id.bytes();
235    match bytes.next() {
236        Some(c) if c.is_ascii_lowercase() => {}
237        _ => return false,
238    }
239    bytes.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-')
240}
241
242/// Split a qualified rendering into its qualifier and body.
243///
244/// `UCAL-E0007` when the qualifier is absent or malformed — the parse-side half
245/// of §6.6's requirement. The kind is inferred from the id: `-d` marks a Rule K
246/// derivation, anything else is legacy.
247#[cfg(feature = "alloc")]
248pub fn split_qualified(s: &str) -> Result<(String, Option<u32>, Kind, &str)> {
249    use alloc::string::ToString;
250    let Some((head, body)) = s.split_once(':') else {
251        return Err(TimeError::with_context(
252            Code::E0007,
253            "a local calendar rendering must carry its calendar id and kind (§6.6)",
254        ));
255    };
256    let head = head.trim();
257    if head.is_empty() {
258        return Err(TimeError::with_context(Code::E0007, "empty calendar id"));
259    }
260    let (id, revision) = match head.split_once('/') {
261        None => (head, None),
262        Some((i, r)) => {
263            let n: u32 = r
264                .parse()
265                .map_err(|_| TimeError::with_context(Code::E0007, "malformed anchor revision"))?;
266            (i, Some(n))
267        }
268    };
269    if !is_valid_calendar_id(id) {
270        return Err(TimeError::with_context(
271            Code::E0007,
272            "malformed calendar id: expected a lowercase letter followed by \
273             lowercase letters, digits or hyphens (§6.6). A rendering whose body \
274             contains a colon must still be qualified.",
275        ));
276    }
277    let kind = if id.ends_with("-d") {
278        Kind::Derived
279    } else {
280        Kind::Legacy
281    };
282    if kind.is_derived() && revision.is_none() {
283        return Err(TimeError::with_context(
284            Code::E0007,
285            "a derived calendar rendering must state its anchor revision (Rule J.5)",
286        ));
287    }
288    Ok((id.to_string(), revision, kind, body.trim()))
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::error::Warning;
295
296    struct FakeLegacy;
297    impl CalendarIdentity for FakeLegacy {
298        fn id(&self) -> &str {
299            "earth-civil"
300        }
301        fn kind(&self) -> Kind {
302            Kind::Legacy
303        }
304    }
305
306    struct FakeDerived;
307    impl CalendarIdentity for FakeDerived {
308        fn id(&self) -> &str {
309            "earth-d"
310        }
311        fn kind(&self) -> Kind {
312            Kind::Derived
313        }
314        fn revision(&self) -> Option<u32> {
315            Some(1)
316        }
317    }
318
319    #[test]
320    fn rendering_always_states_the_calendar() {
321        let q = CalendarQualifier::legacy("earth-civil").attach("2026-07-29T00:00:00Z");
322        assert_eq!(q.to_string(), "earth-civil: 2026-07-29T00:00:00Z");
323
324        let q = CalendarQualifier::derived("earth-d", 1).attach("2026-208.4137");
325        assert_eq!(q.to_string(), "earth-d/1: 2026-208.4137");
326
327        let q = CalendarQualifier::derived("mars-d", 1).attach("0212-334.0918");
328        assert_eq!(q.to_string(), "mars-d/1: 0212-334.0918");
329    }
330
331    #[test]
332    fn legacy_renderings_carry_w0005() {
333        let q = CalendarQualifier::legacy("earth-civil").attach("x");
334        assert_eq!(q.warning(), Some(Warning::W0005));
335        let q = CalendarQualifier::derived("earth-d", 1).attach("x");
336        assert_eq!(q.warning(), None);
337        assert_eq!(Kind::Legacy.warning(), Some(Warning::W0005));
338        assert_eq!(Kind::Derived.warning(), None);
339    }
340
341    #[test]
342    fn require_derived_rejects_legacy() {
343        // UCAL-E0065, the runtime half of the guarantee.
344        assert!(require_derived(&FakeDerived).is_ok());
345        let e = require_derived(&FakeLegacy).unwrap_err();
346        assert_eq!(e.code, Code::E0065);
347        assert_eq!(e.code.exit_code(), 7);
348    }
349
350    #[cfg(feature = "alloc")]
351    #[test]
352    fn unqualified_input_is_e0007() {
353        for bad in [
354            // No qualifier at all.
355            "2026-208.4137",
356            ": something",
357            "  : x",
358            // The subtle one: a bare civil timestamp *contains* colons, so a
359            // naive split finds a "qualifier" of `2026-07-29T00`. Requiring the
360            // id to begin with a lowercase letter is what rejects it.
361            "2026-07-29T00:00:00Z",
362            "12:34:56",
363            // Ids may not contain uppercase, underscores or spaces.
364            "Earth-Civil: x",
365            "earth_civil: x",
366            "earth civil: x",
367        ] {
368            let e = split_qualified(bad).unwrap_err();
369            assert_eq!(e.code, Code::E0007, "input {bad:?} should be rejected");
370        }
371    }
372
373    #[test]
374    fn calendar_id_grammar() {
375        for good in ["earth-d", "earth-civil", "mars-d", "titan-d", "a", "x1-y2"] {
376            assert!(is_valid_calendar_id(good), "{good} should be valid");
377        }
378        for bad in ["", "2026", "2026-07-29T00", "Earth", "earth_civil", "-d", "earth d"] {
379            assert!(!is_valid_calendar_id(bad), "{bad} should be invalid");
380        }
381    }
382
383    #[cfg(feature = "alloc")]
384    #[test]
385    fn a_body_containing_colons_still_parses() {
386        // The whole point of the grammar: the body is free to contain colons.
387        let (id, _, kind, body) = split_qualified("earth-civil: 2026-07-29T00:00:00Z").unwrap();
388        assert_eq!(id, "earth-civil");
389        assert_eq!(kind, Kind::Legacy);
390        assert_eq!(body, "2026-07-29T00:00:00Z");
391    }
392
393    #[cfg(feature = "alloc")]
394    #[test]
395    fn qualified_input_round_trips() {
396        let (id, rev, kind, body) = split_qualified("earth-civil: 2026-07-29T00:00:00Z").unwrap();
397        assert_eq!(id, "earth-civil");
398        assert_eq!(rev, None);
399        assert_eq!(kind, Kind::Legacy);
400        assert_eq!(body, "2026-07-29T00:00:00Z");
401
402        let (id, rev, kind, body) = split_qualified("earth-d/1: 2026-208.4137").unwrap();
403        assert_eq!(id, "earth-d");
404        assert_eq!(rev, Some(1));
405        assert_eq!(kind, Kind::Derived);
406        assert_eq!(body, "2026-208.4137");
407    }
408
409    #[cfg(feature = "alloc")]
410    #[test]
411    fn a_derived_rendering_must_state_its_anchor_revision() {
412        // Rule J.5: revisions are carried so values from different determinations
413        // are never silently compared.
414        let e = split_qualified("earth-d: 2026-208.4137").unwrap_err();
415        assert_eq!(e.code, Code::E0007);
416        assert!(split_qualified("earth-d/3: x").is_ok());
417        // A legacy calendar has no anchor, so none is required.
418        assert!(split_qualified("earth-civil: x").is_ok());
419    }
420
421    #[test]
422    fn discarding_the_qualifier_is_explicit() {
423        let q = CalendarQualifier::legacy("earth-civil").attach(42);
424        assert_eq!(*q.value(), 42);
425        assert_eq!(q.qualifier().id(), "earth-civil");
426        assert_eq!(q.qualifier().kind(), Kind::Legacy);
427        // Named, not a Deref or an Into.
428        assert_eq!(q.into_unqualified(), 42);
429    }
430}