#[cfg(feature = "alloc")]
use alloc::string::String;
use core::fmt;
use crate::error::{Code, Result, TimeError};
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub enum Kind {
Derived,
Legacy,
}
impl Kind {
pub const fn marker(self) -> &'static str {
match self {
Kind::Derived => "-d",
Kind::Legacy => "",
}
}
pub const fn is_derived(self) -> bool {
matches!(self, Kind::Derived)
}
pub const fn warning(self) -> Option<crate::error::Warning> {
match self {
Kind::Derived => None,
Kind::Legacy => Some(crate::error::Warning::W0005),
}
}
}
impl fmt::Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Kind::Derived => "derived",
Kind::Legacy => "legacy",
})
}
}
pub trait CalendarIdentity {
fn id(&self) -> &str;
fn kind(&self) -> Kind;
fn revision(&self) -> Option<u32> {
None
}
}
pub fn require_derived(c: &dyn CalendarIdentity) -> Result<()> {
if c.kind().is_derived() {
Ok(())
} else {
Err(TimeError::with_context(
Code::E0065,
"this operation requires a calendar derived under Rule K; a legacy \
calendar is declared table data and cannot substitute for one",
))
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct CalendarQualifier<'a> {
id: &'a str,
kind: Kind,
revision: Option<u32>,
}
impl<'a> CalendarQualifier<'a> {
pub const fn derived(id: &'a str, revision: u32) -> Self {
CalendarQualifier {
id,
kind: Kind::Derived,
revision: Some(revision),
}
}
pub const fn legacy(id: &'a str) -> Self {
CalendarQualifier {
id,
kind: Kind::Legacy,
revision: None,
}
}
pub const fn id(&self) -> &'a str {
self.id
}
pub const fn kind(&self) -> Kind {
self.kind
}
pub const fn revision(&self) -> Option<u32> {
self.revision
}
pub const fn attach<T>(self, value: T) -> Qualified<'a, T> {
Qualified {
qualifier: self,
value,
}
}
}
impl fmt::Display for CalendarQualifier<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.id)?;
if let Some(r) = self.revision {
write!(f, "/{r}")?;
}
Ok(())
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Qualified<'a, T> {
qualifier: CalendarQualifier<'a>,
value: T,
}
impl<'a, T> Qualified<'a, T> {
pub const fn qualifier(&self) -> &CalendarQualifier<'a> {
&self.qualifier
}
pub const fn value(&self) -> &T {
&self.value
}
pub fn into_unqualified(self) -> T {
self.value
}
pub const fn warning(&self) -> Option<crate::error::Warning> {
self.qualifier.kind.warning()
}
}
impl<T: fmt::Display> fmt::Display for Qualified<'_, T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.qualifier, self.value)
}
}
pub fn is_valid_calendar_id(id: &str) -> bool {
let mut bytes = id.bytes();
match bytes.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => return false,
}
bytes.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-')
}
#[cfg(feature = "alloc")]
pub fn split_qualified(s: &str) -> Result<(String, Option<u32>, Kind, &str)> {
use alloc::string::ToString;
let Some((head, body)) = s.split_once(':') else {
return Err(TimeError::with_context(
Code::E0007,
"a local calendar rendering must carry its calendar id and kind (§6.6)",
));
};
let head = head.trim();
if head.is_empty() {
return Err(TimeError::with_context(Code::E0007, "empty calendar id"));
}
let (id, revision) = match head.split_once('/') {
None => (head, None),
Some((i, r)) => {
let n: u32 = r
.parse()
.map_err(|_| TimeError::with_context(Code::E0007, "malformed anchor revision"))?;
(i, Some(n))
}
};
if !is_valid_calendar_id(id) {
return Err(TimeError::with_context(
Code::E0007,
"malformed calendar id: expected a lowercase letter followed by \
lowercase letters, digits or hyphens (§6.6). A rendering whose body \
contains a colon must still be qualified.",
));
}
let kind = if id.ends_with("-d") {
Kind::Derived
} else {
Kind::Legacy
};
if kind.is_derived() && revision.is_none() {
return Err(TimeError::with_context(
Code::E0007,
"a derived calendar rendering must state its anchor revision (Rule J.5)",
));
}
Ok((id.to_string(), revision, kind, body.trim()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Warning;
struct FakeLegacy;
impl CalendarIdentity for FakeLegacy {
fn id(&self) -> &str {
"earth-civil"
}
fn kind(&self) -> Kind {
Kind::Legacy
}
}
struct FakeDerived;
impl CalendarIdentity for FakeDerived {
fn id(&self) -> &str {
"earth-d"
}
fn kind(&self) -> Kind {
Kind::Derived
}
fn revision(&self) -> Option<u32> {
Some(1)
}
}
#[test]
fn rendering_always_states_the_calendar() {
let q = CalendarQualifier::legacy("earth-civil").attach("2026-07-29T00:00:00Z");
assert_eq!(q.to_string(), "earth-civil: 2026-07-29T00:00:00Z");
let q = CalendarQualifier::derived("earth-d", 1).attach("2026-208.4137");
assert_eq!(q.to_string(), "earth-d/1: 2026-208.4137");
let q = CalendarQualifier::derived("mars-d", 1).attach("0212-334.0918");
assert_eq!(q.to_string(), "mars-d/1: 0212-334.0918");
}
#[test]
fn legacy_renderings_carry_w0005() {
let q = CalendarQualifier::legacy("earth-civil").attach("x");
assert_eq!(q.warning(), Some(Warning::W0005));
let q = CalendarQualifier::derived("earth-d", 1).attach("x");
assert_eq!(q.warning(), None);
assert_eq!(Kind::Legacy.warning(), Some(Warning::W0005));
assert_eq!(Kind::Derived.warning(), None);
}
#[test]
fn require_derived_rejects_legacy() {
assert!(require_derived(&FakeDerived).is_ok());
let e = require_derived(&FakeLegacy).unwrap_err();
assert_eq!(e.code, Code::E0065);
assert_eq!(e.code.exit_code(), 7);
}
#[cfg(feature = "alloc")]
#[test]
fn unqualified_input_is_e0007() {
for bad in [
"2026-208.4137",
": something",
" : x",
"2026-07-29T00:00:00Z",
"12:34:56",
"Earth-Civil: x",
"earth_civil: x",
"earth civil: x",
] {
let e = split_qualified(bad).unwrap_err();
assert_eq!(e.code, Code::E0007, "input {bad:?} should be rejected");
}
}
#[test]
fn calendar_id_grammar() {
for good in ["earth-d", "earth-civil", "mars-d", "titan-d", "a", "x1-y2"] {
assert!(is_valid_calendar_id(good), "{good} should be valid");
}
for bad in ["", "2026", "2026-07-29T00", "Earth", "earth_civil", "-d", "earth d"] {
assert!(!is_valid_calendar_id(bad), "{bad} should be invalid");
}
}
#[cfg(feature = "alloc")]
#[test]
fn a_body_containing_colons_still_parses() {
let (id, _, kind, body) = split_qualified("earth-civil: 2026-07-29T00:00:00Z").unwrap();
assert_eq!(id, "earth-civil");
assert_eq!(kind, Kind::Legacy);
assert_eq!(body, "2026-07-29T00:00:00Z");
}
#[cfg(feature = "alloc")]
#[test]
fn qualified_input_round_trips() {
let (id, rev, kind, body) = split_qualified("earth-civil: 2026-07-29T00:00:00Z").unwrap();
assert_eq!(id, "earth-civil");
assert_eq!(rev, None);
assert_eq!(kind, Kind::Legacy);
assert_eq!(body, "2026-07-29T00:00:00Z");
let (id, rev, kind, body) = split_qualified("earth-d/1: 2026-208.4137").unwrap();
assert_eq!(id, "earth-d");
assert_eq!(rev, Some(1));
assert_eq!(kind, Kind::Derived);
assert_eq!(body, "2026-208.4137");
}
#[cfg(feature = "alloc")]
#[test]
fn a_derived_rendering_must_state_its_anchor_revision() {
let e = split_qualified("earth-d: 2026-208.4137").unwrap_err();
assert_eq!(e.code, Code::E0007);
assert!(split_qualified("earth-d/3: x").is_ok());
assert!(split_qualified("earth-civil: x").is_ok());
}
#[test]
fn discarding_the_qualifier_is_explicit() {
let q = CalendarQualifier::legacy("earth-civil").attach(42);
assert_eq!(*q.value(), 42);
assert_eq!(q.qualifier().id(), "earth-civil");
assert_eq!(q.qualifier().kind(), Kind::Legacy);
assert_eq!(q.into_unqualified(), 42);
}
}