1#[cfg(feature = "alloc")]
27use alloc::string::String;
28use core::fmt;
29
30use crate::error::{Code, Result, TimeError};
31
32#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
37pub enum Kind {
38 Derived,
42 Legacy,
45}
46
47impl Kind {
48 pub const fn marker(self) -> &'static str {
50 match self {
51 Kind::Derived => "-d",
52 Kind::Legacy => "",
53 }
54 }
55
56 pub const fn is_derived(self) -> bool {
58 matches!(self, Kind::Derived)
59 }
60
61 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
81pub trait CalendarIdentity {
86 fn id(&self) -> &str;
88 fn kind(&self) -> Kind;
90 fn revision(&self) -> Option<u32> {
93 None
94 }
95}
96
97pub 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#[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 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 pub const fn legacy(id: &'a str) -> Self {
138 CalendarQualifier {
139 id,
140 kind: Kind::Legacy,
141 revision: None,
142 }
143 }
144
145 pub const fn id(&self) -> &'a str {
147 self.id
148 }
149
150 pub const fn kind(&self) -> Kind {
152 self.kind
153 }
154
155 pub const fn revision(&self) -> Option<u32> {
157 self.revision
158 }
159
160 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 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#[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 pub const fn qualifier(&self) -> &CalendarQualifier<'a> {
194 &self.qualifier
195 }
196
197 pub const fn value(&self) -> &T {
199 &self.value
200 }
201
202 pub fn into_unqualified(self) -> T {
205 self.value
206 }
207
208 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 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217 write!(f, "{}: {}", self.qualifier, self.value)
218 }
219}
220
221pub 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#[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 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 "2026-208.4137",
356 ": something",
357 " : x",
358 "2026-07-29T00:00:00Z",
362 "12:34:56",
363 "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 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 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 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 assert_eq!(q.into_unqualified(), 42);
429 }
430}