lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

calendar.rs (121733B)


      1 #[cfg(not(feature = "std"))]
      2 use alloc::{
      3     format,
      4     string::{String, ToString},
      5     vec,
      6     vec::Vec,
      7 };
      8 use core::{fmt, ops::RangeInclusive, str::FromStr};
      9 
     10 use crate::id::{
     11     AddressableCoordinate, AddressableCoordinateParts, DTag, EventId, RelayUrl, parse_public_key,
     12 };
     13 use crate::media::AuthoredImage;
     14 use crate::wire::v1::{
     15     DEFAULT_CONTENT_MAX_BYTES, DEFAULT_TAG_ELEMENT_MAX_BYTES, DEFAULT_TAG_MAX_COUNT,
     16     DEFAULT_TAG_TOTAL_MAX_BYTES,
     17 };
     18 use radroots_blossom::BlobUrl;
     19 use radroots_identity::PublicKey;
     20 use url_nostd::Url;
     21 
     22 pub const RADROOTS_CALENDAR_SECONDS_PER_DAY: u64 = 86_400;
     23 pub const RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS: u64 = 366;
     24 pub const RADROOTS_CALENDAR_MAX_PARTICIPANTS: usize = DEFAULT_TAG_MAX_COUNT - 16;
     25 
     26 #[cfg_attr(
     27     any(feature = "serde", test),
     28     derive(serde::Serialize, serde::Deserialize)
     29 )]
     30 #[derive(Clone, Debug, Default, PartialEq, Eq)]
     31 pub struct CalendarParticipant {
     32     pub pubkey: String,
     33     pub relay: Option<String>,
     34     pub role: Option<String>,
     35 }
     36 
     37 #[cfg_attr(
     38     any(feature = "serde", test),
     39     derive(serde::Serialize, serde::Deserialize)
     40 )]
     41 #[cfg_attr(any(feature = "serde", test), serde(rename_all = "snake_case"))]
     42 #[derive(Clone, Debug, PartialEq, Eq)]
     43 pub enum CalendarEventRsvpStatus {
     44     Accepted,
     45     Declined,
     46     Tentative,
     47 }
     48 
     49 #[cfg_attr(
     50     any(feature = "serde", test),
     51     derive(serde::Serialize, serde::Deserialize)
     52 )]
     53 #[cfg_attr(any(feature = "serde", test), serde(rename_all = "snake_case"))]
     54 #[derive(Clone, Debug, PartialEq, Eq)]
     55 pub enum CalendarEventFreeBusy {
     56     Free,
     57     Busy,
     58 }
     59 
     60 #[non_exhaustive]
     61 #[derive(Clone, Debug, PartialEq, Eq)]
     62 pub enum CalendarEventError {
     63     InvalidIdentifier,
     64     InvalidCalendarUid,
     65     InvalidEventReference,
     66     InvalidRevisionReference,
     67     InvalidAuthorReference,
     68     DuplicateEventReference,
     69     AuthorHintMismatch,
     70     DeclinedFreeBusyForbidden,
     71     InvalidTitle,
     72     InvalidText(&'static str),
     73     InvalidUrl(&'static str),
     74     InvalidGeohash,
     75     InvalidTimeZone,
     76     InvalidParticipant {
     77         index: usize,
     78     },
     79     TooManyParticipants {
     80         max: usize,
     81         actual: usize,
     82     },
     83     ContentTooLarge {
     84         max: usize,
     85         actual: usize,
     86     },
     87     TagElementTooLarge {
     88         field: &'static str,
     89         max: usize,
     90         actual: usize,
     91     },
     92     TagCountExceeded {
     93         max: usize,
     94         actual: usize,
     95     },
     96     TagBytesExceeded {
     97         max: usize,
     98         actual: usize,
     99     },
    100     InvalidDate,
    101     InvalidRange,
    102     CoveredDayLimitExceeded {
    103         max: u64,
    104         actual: u64,
    105     },
    106 }
    107 
    108 impl CalendarEventError {
    109     pub const fn code(&self) -> &'static str {
    110         match self {
    111             Self::InvalidIdentifier => "invalid_identifier",
    112             Self::InvalidCalendarUid => "invalid_calendar_uid",
    113             Self::InvalidEventReference => "invalid_event_reference",
    114             Self::InvalidRevisionReference => "invalid_revision_reference",
    115             Self::InvalidAuthorReference => "invalid_author_reference",
    116             Self::DuplicateEventReference => "duplicate_event_reference",
    117             Self::AuthorHintMismatch => "author_hint_mismatch",
    118             Self::DeclinedFreeBusyForbidden => "declined_free_busy_forbidden",
    119             Self::InvalidTitle => "invalid_title",
    120             Self::InvalidText(_) => "invalid_text",
    121             Self::InvalidUrl(_) => "invalid_url",
    122             Self::InvalidGeohash => "invalid_geohash",
    123             Self::InvalidTimeZone => "invalid_time_zone",
    124             Self::InvalidParticipant { .. } => "invalid_participant",
    125             Self::TooManyParticipants { .. } => "too_many_participants",
    126             Self::ContentTooLarge { .. } => "content_too_large",
    127             Self::TagElementTooLarge { .. } => "tag_element_too_large",
    128             Self::TagCountExceeded { .. } => "tag_count_exceeded",
    129             Self::TagBytesExceeded { .. } => "tag_bytes_exceeded",
    130             Self::InvalidDate => "invalid_date",
    131             Self::InvalidRange => "invalid_range",
    132             Self::CoveredDayLimitExceeded { .. } => "covered_day_limit_exceeded",
    133         }
    134     }
    135 }
    136 
    137 impl fmt::Display for CalendarEventError {
    138     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    139         match self {
    140             Self::InvalidIdentifier => f.write_str("calendar identifier is invalid"),
    141             Self::InvalidCalendarUid => f.write_str(
    142                 "calendar UID must be an unpadded 22-character base64url encoding of 128 bits",
    143             ),
    144             Self::InvalidEventReference => f.write_str("calendar event reference is invalid"),
    145             Self::InvalidRevisionReference => {
    146                 f.write_str("calendar event revision reference is invalid")
    147             }
    148             Self::InvalidAuthorReference => {
    149                 f.write_str("calendar event author reference is invalid")
    150             }
    151             Self::DuplicateEventReference => {
    152                 f.write_str("calendar event references must have unique coordinates")
    153             }
    154             Self::AuthorHintMismatch => {
    155                 f.write_str("calendar event author hint does not match the event coordinate")
    156             }
    157             Self::DeclinedFreeBusyForbidden => {
    158                 f.write_str("an authored declined RSVP cannot carry free/busy state")
    159             }
    160             Self::InvalidTitle => f.write_str("calendar title must be canonical visible text"),
    161             Self::InvalidText(field) => write!(f, "calendar {field} must be visible text"),
    162             Self::InvalidUrl(field) => write!(f, "calendar {field} is not a valid URI"),
    163             Self::InvalidGeohash => f.write_str("calendar geohash is invalid"),
    164             Self::InvalidTimeZone => {
    165                 f.write_str("calendar time zone is not a canonical IANA identifier")
    166             }
    167             Self::InvalidParticipant { index } => {
    168                 write!(f, "calendar participant at index {index} is invalid")
    169             }
    170             Self::TooManyParticipants { max, actual } => write!(
    171                 f,
    172                 "calendar participant count {actual} exceeds maximum {max}"
    173             ),
    174             Self::ContentTooLarge { max, actual } => {
    175                 write!(f, "calendar content size {actual} exceeds maximum {max}")
    176             }
    177             Self::TagElementTooLarge { field, max, actual } => write!(
    178                 f,
    179                 "calendar {field} size {actual} exceeds tag element maximum {max}"
    180             ),
    181             Self::TagCountExceeded { max, actual } => {
    182                 write!(f, "calendar tag count {actual} exceeds maximum {max}")
    183             }
    184             Self::TagBytesExceeded { max, actual } => {
    185                 write!(f, "calendar tag bytes {actual} exceed maximum {max}")
    186             }
    187             Self::InvalidDate => f.write_str("calendar date is not a valid Gregorian date"),
    188             Self::InvalidRange => f.write_str("calendar end must be greater than start"),
    189             Self::CoveredDayLimitExceeded { max, actual } => write!(
    190                 f,
    191                 "calendar interval covers {actual} UTC days, exceeding the {max}-day limit"
    192             ),
    193         }
    194     }
    195 }
    196 
    197 #[cfg(feature = "std")]
    198 impl std::error::Error for CalendarEventError {}
    199 
    200 /// A syntax-valid 128-bit calendar identifier encoded as unpadded base64url.
    201 ///
    202 /// This type validates the identifier's entropy shape, not uniqueness. The
    203 /// authoring runtime must still generate a fresh value for every calendar or
    204 /// RSVP identity it creates.
    205 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    206 pub struct CalendarUid(String);
    207 
    208 impl CalendarUid {
    209     pub fn parse(value: impl AsRef<str>) -> Result<Self, CalendarEventError> {
    210         let value = value.as_ref();
    211         let valid_alphabet = value
    212             .bytes()
    213             .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
    214         let valid_final_quantum = value
    215             .as_bytes()
    216             .last()
    217             .is_some_and(|byte| matches!(byte, b'A' | b'Q' | b'g' | b'w'));
    218         if [value.len() == 22, valid_alphabet, valid_final_quantum] != [true; 3] {
    219             return Err(CalendarEventError::InvalidCalendarUid);
    220         }
    221         Ok(Self(value.to_string()))
    222     }
    223 
    224     pub fn as_str(&self) -> &str {
    225         &self.0
    226     }
    227 
    228     pub fn into_string(self) -> String {
    229         self.0
    230     }
    231 }
    232 
    233 impl fmt::Display for CalendarUid {
    234     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    235         f.write_str(self.as_str())
    236     }
    237 }
    238 
    239 impl AsRef<str> for CalendarUid {
    240     fn as_ref(&self) -> &str {
    241         self.as_str()
    242     }
    243 }
    244 
    245 impl FromStr for CalendarUid {
    246     type Err = CalendarEventError;
    247 
    248     fn from_str(value: &str) -> Result<Self, Self::Err> {
    249         Self::parse(value)
    250     }
    251 }
    252 
    253 impl TryFrom<&str> for CalendarUid {
    254     type Error = CalendarEventError;
    255 
    256     fn try_from(value: &str) -> Result<Self, Self::Error> {
    257         Self::parse(value)
    258     }
    259 }
    260 
    261 impl TryFrom<String> for CalendarUid {
    262     type Error = CalendarEventError;
    263 
    264     fn try_from(value: String) -> Result<Self, Self::Error> {
    265         Self::parse(value)
    266     }
    267 }
    268 
    269 #[cfg(any(feature = "serde", test))]
    270 impl serde::Serialize for CalendarUid {
    271     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    272     where
    273         S: serde::Serializer,
    274     {
    275         serializer.serialize_str(self.as_str())
    276     }
    277 }
    278 
    279 /// A raw-preserving NIP-52 address reference to a kind-31922 or kind-31923 event.
    280 #[derive(Clone, Debug, PartialEq, Eq)]
    281 pub struct CalendarEventReference {
    282     coordinate: AddressableCoordinate,
    283     kind: u32,
    284     author: PublicKey,
    285     d_tag: DTag,
    286     relay: Option<String>,
    287 }
    288 
    289 impl CalendarEventReference {
    290     pub fn parse(
    291         coordinate: impl AsRef<str>,
    292         relay: Option<&str>,
    293     ) -> Result<Self, CalendarEventError> {
    294         let coordinate = AddressableCoordinate::parse(coordinate.as_ref())
    295             .map_err(|_| CalendarEventError::InvalidEventReference)?;
    296         let parts = AddressableCoordinateParts::parse(coordinate.as_str())
    297             .map_err(|_| CalendarEventError::InvalidEventReference)?;
    298         if !matches!(
    299             parts.kind,
    300             crate::envelope::kind::KIND_CALENDAR_DATE_EVENT
    301                 | crate::envelope::kind::KIND_CALENDAR_TIME_EVENT
    302         ) {
    303             return Err(CalendarEventError::InvalidEventReference);
    304         }
    305         let relay =
    306             parse_calendar_reference_relay(relay, CalendarEventError::InvalidEventReference)?;
    307         Ok(Self {
    308             coordinate,
    309             kind: parts.kind,
    310             author: parts.pubkey,
    311             d_tag: parts.d_tag,
    312             relay,
    313         })
    314     }
    315 
    316     pub fn coordinate(&self) -> &AddressableCoordinate {
    317         &self.coordinate
    318     }
    319 
    320     pub const fn kind(&self) -> u32 {
    321         self.kind
    322     }
    323 
    324     pub fn author(&self) -> &PublicKey {
    325         &self.author
    326     }
    327 
    328     pub fn d_tag(&self) -> &DTag {
    329         &self.d_tag
    330     }
    331 
    332     pub fn relay(&self) -> Option<&str> {
    333         self.relay.as_deref()
    334     }
    335 
    336     pub fn is_canonical(&self) -> bool {
    337         // Nostr does not define relay URL normalization. Strict admission validates the
    338         // lowercase ws/wss syntax while preserving the caller's host, port, path, and query.
    339         [
    340             self.coordinate.as_str() == format!("{}:{}:{}", self.kind, self.author, self.d_tag),
    341             self.relay()
    342                 .is_none_or(|relay| RelayUrl::parse(relay).is_ok()),
    343         ] == [true; 2]
    344     }
    345 
    346     fn has_same_coordinate(&self, other: &Self) -> bool {
    347         [
    348             self.kind == other.kind,
    349             self.author == other.author,
    350             self.d_tag == other.d_tag,
    351         ] == [true; 3]
    352     }
    353 }
    354 
    355 /// A raw-preserving event-id reference to a specific NIP-52 event revision.
    356 #[derive(Clone, Debug, PartialEq, Eq)]
    357 pub struct CalendarEventRevisionReference {
    358     raw_event_id: String,
    359     event_id: EventId,
    360     relay: Option<String>,
    361 }
    362 
    363 impl CalendarEventRevisionReference {
    364     pub fn parse(
    365         event_id: impl AsRef<str>,
    366         relay: Option<&str>,
    367     ) -> Result<Self, CalendarEventError> {
    368         let raw_event_id = event_id.as_ref();
    369         let event_id = EventId::parse(raw_event_id)
    370             .map_err(|_| CalendarEventError::InvalidRevisionReference)?;
    371         let relay =
    372             parse_calendar_reference_relay(relay, CalendarEventError::InvalidRevisionReference)?;
    373         Ok(Self {
    374             raw_event_id: raw_event_id.to_string(),
    375             event_id,
    376             relay,
    377         })
    378     }
    379 
    380     pub fn raw_event_id(&self) -> &str {
    381         &self.raw_event_id
    382     }
    383 
    384     pub fn event_id(&self) -> &EventId {
    385         &self.event_id
    386     }
    387 
    388     pub fn relay(&self) -> Option<&str> {
    389         self.relay.as_deref()
    390     }
    391 
    392     pub fn is_canonical(&self) -> bool {
    393         // Relay hints use strict Radroots syntax; their raw URL spelling is not normalized.
    394         self.raw_event_id == self.event_id.to_hex()
    395             && self
    396                 .relay()
    397                 .is_none_or(|relay| RelayUrl::parse(relay).is_ok())
    398     }
    399 }
    400 
    401 /// A raw-preserving optional RSVP author hint. This reference has no role field.
    402 #[derive(Clone, Debug, PartialEq, Eq)]
    403 pub struct CalendarEventAuthorReference {
    404     raw_pubkey: String,
    405     pubkey: PublicKey,
    406     relay: Option<String>,
    407 }
    408 
    409 impl CalendarEventAuthorReference {
    410     pub fn parse(pubkey: impl AsRef<str>, relay: Option<&str>) -> Result<Self, CalendarEventError> {
    411         let raw_pubkey = pubkey.as_ref();
    412         let pubkey =
    413             parse_public_key(raw_pubkey).map_err(|_| CalendarEventError::InvalidAuthorReference)?;
    414         let relay =
    415             parse_calendar_reference_relay(relay, CalendarEventError::InvalidAuthorReference)?;
    416         Ok(Self {
    417             raw_pubkey: raw_pubkey.to_string(),
    418             pubkey,
    419             relay,
    420         })
    421     }
    422 
    423     pub fn raw_pubkey(&self) -> &str {
    424         &self.raw_pubkey
    425     }
    426 
    427     pub fn pubkey(&self) -> &PublicKey {
    428         &self.pubkey
    429     }
    430 
    431     pub fn relay(&self) -> Option<&str> {
    432         self.relay.as_deref()
    433     }
    434 
    435     pub fn is_canonical(&self) -> bool {
    436         // Relay hints use strict Radroots syntax; their raw URL spelling is not normalized.
    437         [
    438             self.raw_pubkey == self.pubkey.to_hex(),
    439             self.relay()
    440                 .is_none_or(|relay| RelayUrl::parse(relay).is_ok()),
    441         ] == [true; 2]
    442     }
    443 }
    444 
    445 /// A canonical IANA Time Zone Database identifier.
    446 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    447 pub struct IanaTimeZoneId(String);
    448 
    449 impl IanaTimeZoneId {
    450     pub fn parse(value: impl AsRef<str>) -> Result<Self, CalendarEventError> {
    451         let value = value.as_ref();
    452         let Some((canonical, _)) = jiff_tzdb::get(value) else {
    453             return Err(CalendarEventError::InvalidTimeZone);
    454         };
    455         if [
    456             canonical == value,
    457             canonical_calendar_tag_text_is_valid(value),
    458         ] != [true; 2]
    459         {
    460             return Err(CalendarEventError::InvalidTimeZone);
    461         }
    462         Ok(Self(value.into()))
    463     }
    464 
    465     pub fn as_str(&self) -> &str {
    466         &self.0
    467     }
    468 }
    469 
    470 impl fmt::Display for IanaTimeZoneId {
    471     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    472         f.write_str(self.as_str())
    473     }
    474 }
    475 
    476 impl AsRef<str> for IanaTimeZoneId {
    477     fn as_ref(&self) -> &str {
    478         self.as_str()
    479     }
    480 }
    481 
    482 impl FromStr for IanaTimeZoneId {
    483     type Err = CalendarEventError;
    484 
    485     fn from_str(value: &str) -> Result<Self, Self::Err> {
    486         Self::parse(value)
    487     }
    488 }
    489 
    490 #[cfg(any(feature = "serde", test))]
    491 impl serde::Serialize for IanaTimeZoneId {
    492     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    493     where
    494         S: serde::Serializer,
    495     {
    496         serializer.serialize_str(self.as_str())
    497     }
    498 }
    499 
    500 #[cfg(any(feature = "serde", test))]
    501 impl<'de> serde::Deserialize<'de> for IanaTimeZoneId {
    502     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    503     where
    504         D: serde::Deserializer<'de>,
    505     {
    506         let value = <String as serde::Deserialize>::deserialize(deserializer)?;
    507         Self::parse(value).map_err(serde::de::Error::custom)
    508     }
    509 }
    510 
    511 /// A structurally valid absolute URI preserved exactly as it appeared on wire.
    512 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    513 pub struct CalendarUri(String);
    514 
    515 impl CalendarUri {
    516     pub fn parse(value: impl AsRef<str>) -> Result<Self, CalendarEventError> {
    517         let value = value.as_ref();
    518         let valid = [
    519             value.trim() == value,
    520             !value.chars().any(|character| {
    521                 [character.is_whitespace(), character.is_control()].contains(&true)
    522             }),
    523             value.len() <= DEFAULT_TAG_ELEMENT_MAX_BYTES,
    524             Url::parse(value).is_ok(),
    525         ];
    526         if valid != [true; 4] {
    527             return Err(CalendarEventError::InvalidUrl("URI"));
    528         }
    529         Ok(Self(value.into()))
    530     }
    531 
    532     pub fn as_str(&self) -> &str {
    533         &self.0
    534     }
    535 }
    536 
    537 impl AsRef<str> for CalendarUri {
    538     fn as_ref(&self) -> &str {
    539         self.as_str()
    540     }
    541 }
    542 
    543 impl FromStr for CalendarUri {
    544     type Err = CalendarEventError;
    545 
    546     fn from_str(value: &str) -> Result<Self, Self::Err> {
    547         Self::parse(value)
    548     }
    549 }
    550 
    551 impl fmt::Display for CalendarUri {
    552     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    553         f.write_str(self.as_str())
    554     }
    555 }
    556 
    557 #[cfg(any(feature = "serde", test))]
    558 impl serde::Serialize for CalendarUri {
    559     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    560     where
    561         S: serde::Serializer,
    562     {
    563         serializer.serialize_str(self.as_str())
    564     }
    565 }
    566 
    567 #[cfg(any(feature = "serde", test))]
    568 impl<'de> serde::Deserialize<'de> for CalendarUri {
    569     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    570     where
    571         D: serde::Deserializer<'de>,
    572     {
    573         let value = <String as serde::Deserialize>::deserialize(deserializer)?;
    574         Self::parse(value).map_err(serde::de::Error::custom)
    575     }
    576 }
    577 
    578 /// Strict authored representation of a NIP-52 kind-31924 calendar collection.
    579 #[derive(Clone, Debug, PartialEq, Eq)]
    580 pub struct AuthoredCalendar {
    581     uid: CalendarUid,
    582     title: String,
    583     content: String,
    584     event_references: Vec<CalendarEventReference>,
    585     list_description: Option<String>,
    586     image: Option<AuthoredImage>,
    587 }
    588 
    589 impl AuthoredCalendar {
    590     pub fn new(
    591         uid: CalendarUid,
    592         title: impl Into<String>,
    593         content: impl Into<String>,
    594         event_references: Vec<CalendarEventReference>,
    595     ) -> Result<Self, CalendarEventError> {
    596         let title = validated_title(title.into())?;
    597         let content = content.into();
    598         validate_calendar_content(&content)?;
    599         validate_strict_calendar_event_references(&event_references)?;
    600         let authored = Self {
    601             uid,
    602             title,
    603             content,
    604             event_references,
    605             list_description: None,
    606             image: None,
    607         };
    608         authored.validate_budget()?;
    609         Ok(authored)
    610     }
    611 
    612     pub fn with_list_description(
    613         mut self,
    614         value: impl Into<String>,
    615     ) -> Result<Self, CalendarEventError> {
    616         let value = value.into();
    617         validate_canonical_calendar_tag_text(&value, "description")?;
    618         self.list_description = Some(value);
    619         self.validate_budget()?;
    620         Ok(self)
    621     }
    622 
    623     pub fn with_image(mut self, image: AuthoredImage) -> Result<Self, CalendarEventError> {
    624         self.image = Some(image);
    625         self.validate_budget()?;
    626         Ok(self)
    627     }
    628 
    629     pub fn uid(&self) -> &CalendarUid {
    630         &self.uid
    631     }
    632 
    633     pub fn title(&self) -> &str {
    634         &self.title
    635     }
    636 
    637     pub fn content(&self) -> &str {
    638         &self.content
    639     }
    640 
    641     pub fn event_references(&self) -> &[CalendarEventReference] {
    642         &self.event_references
    643     }
    644 
    645     pub fn list_description(&self) -> Option<&str> {
    646         self.list_description.as_deref()
    647     }
    648 
    649     pub fn image(&self) -> Option<&AuthoredImage> {
    650         self.image.as_ref()
    651     }
    652 
    653     fn validate_budget(&self) -> Result<(), CalendarEventError> {
    654         validate_calendar_collection_budget(
    655             self.uid.as_str(),
    656             &self.title,
    657             &self.content,
    658             &self.event_references,
    659             self.list_description.as_deref(),
    660             self.image
    661                 .as_ref()
    662                 .map(|image| image.descriptor().url().as_str()),
    663         )
    664     }
    665 }
    666 
    667 /// Constructor input for the tolerant, raw-preserving kind-31924 parse layer.
    668 #[derive(Clone, Debug, PartialEq, Eq)]
    669 pub struct ParsedNip52CalendarParts {
    670     pub d_tag: String,
    671     pub title: String,
    672     pub content: String,
    673     pub event_references: Vec<CalendarEventReference>,
    674     pub list_description: Option<String>,
    675     pub image: Option<CalendarUri>,
    676 }
    677 
    678 /// Structurally valid kind-31924 data. Canonical admission is a separate step.
    679 #[derive(Clone, Debug, PartialEq, Eq)]
    680 pub struct ParsedNip52Calendar {
    681     d_tag: String,
    682     title: String,
    683     content: String,
    684     event_references: Vec<CalendarEventReference>,
    685     list_description: Option<String>,
    686     image: Option<CalendarUri>,
    687 }
    688 
    689 impl ParsedNip52Calendar {
    690     pub fn try_new(parts: ParsedNip52CalendarParts) -> Result<Self, CalendarEventError> {
    691         validate_calendar_tag_text(&parts.d_tag, "d")?;
    692         validate_calendar_tag_text(&parts.title, "title")?;
    693         validate_calendar_content(&parts.content)?;
    694         if let Some(description) = parts.list_description.as_deref() {
    695             validate_calendar_tag_text(description, "description")?;
    696         }
    697         validate_calendar_collection_budget(
    698             &parts.d_tag,
    699             &parts.title,
    700             &parts.content,
    701             &parts.event_references,
    702             parts.list_description.as_deref(),
    703             parts.image.as_ref().map(CalendarUri::as_str),
    704         )?;
    705         Ok(Self {
    706             d_tag: parts.d_tag,
    707             title: parts.title,
    708             content: parts.content,
    709             event_references: parts.event_references,
    710             list_description: parts.list_description,
    711             image: parts.image,
    712         })
    713     }
    714 
    715     pub fn d_tag(&self) -> &str {
    716         &self.d_tag
    717     }
    718 
    719     pub fn title(&self) -> &str {
    720         &self.title
    721     }
    722 
    723     pub fn content(&self) -> &str {
    724         &self.content
    725     }
    726 
    727     pub fn event_references(&self) -> &[CalendarEventReference] {
    728         &self.event_references
    729     }
    730 
    731     pub fn list_description(&self) -> Option<&str> {
    732         self.list_description.as_deref()
    733     }
    734 
    735     pub fn image(&self) -> Option<&CalendarUri> {
    736         self.image.as_ref()
    737     }
    738 }
    739 
    740 /// Canonical Radroots admission of a parsed kind-31924 calendar collection.
    741 #[derive(Clone, Debug, PartialEq, Eq)]
    742 pub struct AdmittedCalendar {
    743     parsed: ParsedNip52Calendar,
    744     uid: CalendarUid,
    745     blossom_image: Option<BlobUrl>,
    746 }
    747 
    748 impl AdmittedCalendar {
    749     pub fn try_from_parsed(parsed: ParsedNip52Calendar) -> Result<Self, CalendarAdmissionError> {
    750         let uid = CalendarUid::parse(parsed.d_tag())
    751             .map_err(|_| CalendarAdmissionError::NonCanonicalField("d"))?;
    752         if !canonical_calendar_tag_text_is_valid(parsed.title()) {
    753             return Err(CalendarAdmissionError::NonCanonicalField("title"));
    754         }
    755         if parsed
    756             .list_description()
    757             .is_some_and(|description| !canonical_calendar_tag_text_is_valid(description))
    758         {
    759             return Err(CalendarAdmissionError::NonCanonicalField("description"));
    760         }
    761         if parsed
    762             .event_references()
    763             .iter()
    764             .any(|reference| !reference.is_canonical())
    765         {
    766             return Err(CalendarAdmissionError::NonCanonicalField("event_reference"));
    767         }
    768         if calendar_event_references_have_duplicates(parsed.event_references()) {
    769             return Err(CalendarAdmissionError::DuplicateEventReference);
    770         }
    771         let blossom_image = parsed
    772             .image()
    773             .map(|image| {
    774                 BlobUrl::parse(image.as_str()).map_err(|_| CalendarAdmissionError::NonBlossomImage)
    775             })
    776             .transpose()?;
    777         Ok(Self {
    778             parsed,
    779             uid,
    780             blossom_image,
    781         })
    782     }
    783 
    784     pub fn parsed(&self) -> &ParsedNip52Calendar {
    785         &self.parsed
    786     }
    787 
    788     pub fn uid(&self) -> &CalendarUid {
    789         &self.uid
    790     }
    791 
    792     pub fn title(&self) -> &str {
    793         self.parsed.title()
    794     }
    795 
    796     pub fn content(&self) -> &str {
    797         self.parsed.content()
    798     }
    799 
    800     pub fn event_references(&self) -> &[CalendarEventReference] {
    801         self.parsed.event_references()
    802     }
    803 
    804     pub fn list_description(&self) -> Option<&str> {
    805         self.parsed.list_description()
    806     }
    807 
    808     pub fn blossom_image(&self) -> Option<&BlobUrl> {
    809         self.blossom_image.as_ref()
    810     }
    811 }
    812 
    813 #[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))]
    814 #[derive(Clone, Debug, PartialEq, Eq)]
    815 pub struct CalendarRequest {
    816     calendar: AddressableCoordinate,
    817     relay: Option<String>,
    818 }
    819 
    820 impl CalendarRequest {
    821     pub fn new(calendar: impl AsRef<str>, relay: Option<&str>) -> Result<Self, CalendarEventError> {
    822         let calendar = AddressableCoordinate::parse(calendar.as_ref())
    823             .map_err(|_| CalendarEventError::InvalidUrl("calendar request"))?;
    824         let parts = crate::id::AddressableCoordinateParts::parse(calendar.as_str())
    825             .map_err(|_| CalendarEventError::InvalidUrl("calendar request"))?;
    826         if parts.kind != crate::envelope::kind::KIND_CALENDAR {
    827             return Err(CalendarEventError::InvalidUrl("calendar request"));
    828         }
    829         let relay = relay
    830             .map(|value| {
    831                 if calendar_relay_url_is_valid(value) {
    832                     Ok(value.to_string())
    833                 } else {
    834                     Err(CalendarEventError::InvalidUrl("calendar request relay"))
    835                 }
    836             })
    837             .transpose()?;
    838         Ok(Self { calendar, relay })
    839     }
    840 
    841     pub fn calendar(&self) -> &AddressableCoordinate {
    842         &self.calendar
    843     }
    844 
    845     pub fn relay(&self) -> Option<&str> {
    846         self.relay.as_deref()
    847     }
    848 
    849     pub fn is_canonical(&self) -> bool {
    850         let Ok(parts) = crate::id::AddressableCoordinateParts::parse(self.calendar.as_str()) else {
    851             return false;
    852         };
    853         [
    854             self.calendar.as_str() == format!("{}:{}:{}", parts.kind, parts.pubkey, parts.d_tag),
    855             self.relay
    856                 .as_deref()
    857                 .is_none_or(|relay| RelayUrl::parse(relay).is_ok()),
    858         ] == [true; 2]
    859     }
    860 }
    861 
    862 /// A canonical Gregorian calendar date in `YYYY-MM-DD` form.
    863 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    864 pub struct CalendarDate(String);
    865 
    866 impl CalendarDate {
    867     pub fn parse(value: &str) -> Result<Self, CalendarEventError> {
    868         let bytes = value.as_bytes();
    869         if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
    870             return Err(CalendarEventError::InvalidDate);
    871         }
    872         let year = parse_date_digits(&bytes[0..4])?;
    873         let month = parse_date_digits(&bytes[5..7])?;
    874         let day = parse_date_digits(&bytes[8..10])?;
    875         if year == 0 || month == 0 || month > 12 || day == 0 || day > days_in_month(year, month) {
    876             return Err(CalendarEventError::InvalidDate);
    877         }
    878         Ok(Self(value.into()))
    879     }
    880 
    881     pub fn as_str(&self) -> &str {
    882         &self.0
    883     }
    884 }
    885 
    886 impl fmt::Display for CalendarDate {
    887     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    888         f.write_str(self.as_str())
    889     }
    890 }
    891 
    892 impl AsRef<str> for CalendarDate {
    893     fn as_ref(&self) -> &str {
    894         self.as_str()
    895     }
    896 }
    897 
    898 impl FromStr for CalendarDate {
    899     type Err = CalendarEventError;
    900 
    901     fn from_str(value: &str) -> Result<Self, Self::Err> {
    902         Self::parse(value)
    903     }
    904 }
    905 
    906 #[cfg(any(feature = "serde", test))]
    907 impl serde::Serialize for CalendarDate {
    908     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    909     where
    910         S: serde::Serializer,
    911     {
    912         serializer.serialize_str(self.as_str())
    913     }
    914 }
    915 
    916 #[cfg(any(feature = "serde", test))]
    917 impl<'de> serde::Deserialize<'de> for CalendarDate {
    918     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    919     where
    920         D: serde::Deserializer<'de>,
    921     {
    922         let value = <String as serde::Deserialize>::deserialize(deserializer)?;
    923         Self::parse(&value).map_err(serde::de::Error::custom)
    924     }
    925 }
    926 
    927 /// Strict authored NIP-52 kind-31922 event.
    928 ///
    929 /// Authored images can only enter through a byte-verified image descriptor.
    930 /// The model cannot represent the obsolete uppercase-`D` date list.
    931 #[derive(Clone, Debug, PartialEq, Eq)]
    932 pub struct AuthoredCalendarDateEvent {
    933     d_tag: DTag,
    934     title: String,
    935     start: CalendarDate,
    936     description: Option<String>,
    937     end: Option<CalendarDate>,
    938     locations: Vec<String>,
    939     geohash: Option<String>,
    940     summary: Option<String>,
    941     image: Option<AuthoredImage>,
    942     participants: Option<Vec<CalendarParticipant>>,
    943     categories: Vec<String>,
    944     references: Vec<CalendarUri>,
    945     calendar_requests: Vec<CalendarRequest>,
    946 }
    947 
    948 impl AuthoredCalendarDateEvent {
    949     pub fn new(
    950         d_tag: impl AsRef<str>,
    951         title: impl Into<String>,
    952         start: CalendarDate,
    953     ) -> Result<Self, CalendarEventError> {
    954         let d_tag =
    955             DTag::parse(d_tag.as_ref()).map_err(|_| CalendarEventError::InvalidIdentifier)?;
    956         let title = validated_title(title.into())?;
    957         Ok(Self {
    958             d_tag,
    959             title,
    960             start,
    961             description: None,
    962             end: None,
    963             locations: Vec::new(),
    964             geohash: None,
    965             summary: None,
    966             image: None,
    967             participants: None,
    968             categories: Vec::new(),
    969             references: Vec::new(),
    970             calendar_requests: Vec::new(),
    971         })
    972     }
    973 
    974     pub fn with_end(mut self, end: CalendarDate) -> Result<Self, CalendarEventError> {
    975         if end <= self.start {
    976             return Err(CalendarEventError::InvalidRange);
    977         }
    978         self.end = Some(end);
    979         self.validate_budget()?;
    980         Ok(self)
    981     }
    982 
    983     pub fn with_description(
    984         mut self,
    985         value: impl Into<String>,
    986     ) -> Result<Self, CalendarEventError> {
    987         let value = value.into();
    988         validate_calendar_content(&value)?;
    989         self.description = Some(value);
    990         self.validate_budget()?;
    991         Ok(self)
    992     }
    993 
    994     pub fn with_locations(mut self, value: Vec<String>) -> Result<Self, CalendarEventError> {
    995         validate_authored_calendar_locations(&value)?;
    996         self.locations = value;
    997         self.validate_budget()?;
    998         Ok(self)
    999     }
   1000 
   1001     pub fn with_geohash(mut self, value: impl Into<String>) -> Result<Self, CalendarEventError> {
   1002         let value = value.into();
   1003         if !canonical_calendar_geohash_is_valid(&value) {
   1004             return Err(CalendarEventError::InvalidGeohash);
   1005         }
   1006         self.geohash = Some(value);
   1007         self.validate_budget()?;
   1008         Ok(self)
   1009     }
   1010 
   1011     pub fn with_summary(mut self, value: impl Into<String>) -> Result<Self, CalendarEventError> {
   1012         let value = value.into();
   1013         validate_canonical_calendar_tag_text(&value, "summary")?;
   1014         self.summary = Some(value);
   1015         self.validate_budget()?;
   1016         Ok(self)
   1017     }
   1018 
   1019     pub fn with_image(mut self, value: AuthoredImage) -> Result<Self, CalendarEventError> {
   1020         self.image = Some(value);
   1021         self.validate_budget()?;
   1022         Ok(self)
   1023     }
   1024 
   1025     pub fn with_participants(
   1026         mut self,
   1027         value: Vec<CalendarParticipant>,
   1028     ) -> Result<Self, CalendarEventError> {
   1029         validate_authored_calendar_participants(&value)?;
   1030         self.participants = Some(value);
   1031         self.validate_budget()?;
   1032         Ok(self)
   1033     }
   1034 
   1035     pub fn with_categories(mut self, value: Vec<String>) -> Result<Self, CalendarEventError> {
   1036         validate_authored_calendar_categories(&value)?;
   1037         self.categories = value;
   1038         self.validate_budget()?;
   1039         Ok(self)
   1040     }
   1041 
   1042     pub fn with_references(mut self, value: Vec<CalendarUri>) -> Result<Self, CalendarEventError> {
   1043         self.references = value;
   1044         self.validate_budget()?;
   1045         Ok(self)
   1046     }
   1047 
   1048     pub fn with_calendar_requests(
   1049         mut self,
   1050         value: Vec<CalendarRequest>,
   1051     ) -> Result<Self, CalendarEventError> {
   1052         validate_authored_calendar_requests(&value)?;
   1053         self.calendar_requests = value;
   1054         self.validate_budget()?;
   1055         Ok(self)
   1056     }
   1057 
   1058     pub fn d_tag(&self) -> &DTag {
   1059         &self.d_tag
   1060     }
   1061 
   1062     pub fn title(&self) -> &str {
   1063         &self.title
   1064     }
   1065 
   1066     pub fn start(&self) -> &CalendarDate {
   1067         &self.start
   1068     }
   1069 
   1070     pub fn description(&self) -> Option<&str> {
   1071         self.description.as_deref()
   1072     }
   1073 
   1074     pub fn end(&self) -> Option<&CalendarDate> {
   1075         self.end.as_ref()
   1076     }
   1077 
   1078     pub fn locations(&self) -> &[String] {
   1079         &self.locations
   1080     }
   1081 
   1082     pub fn geohash(&self) -> Option<&str> {
   1083         self.geohash.as_deref()
   1084     }
   1085 
   1086     pub fn summary(&self) -> Option<&str> {
   1087         self.summary.as_deref()
   1088     }
   1089 
   1090     pub fn image(&self) -> Option<&AuthoredImage> {
   1091         self.image.as_ref()
   1092     }
   1093 
   1094     pub fn participants(&self) -> Option<&Vec<CalendarParticipant>> {
   1095         self.participants.as_ref()
   1096     }
   1097 
   1098     pub fn categories(&self) -> &[String] {
   1099         &self.categories
   1100     }
   1101 
   1102     pub fn references(&self) -> &[CalendarUri] {
   1103         &self.references
   1104     }
   1105 
   1106     pub fn calendar_requests(&self) -> &[CalendarRequest] {
   1107         &self.calendar_requests
   1108     }
   1109 
   1110     fn validate_budget(&self) -> Result<(), CalendarEventError> {
   1111         validate_authored_calendar_budget(self.description(), authored_date_tags_for_budget(self))
   1112     }
   1113 }
   1114 
   1115 /// Strict authored NIP-52 kind-31923 event.
   1116 ///
   1117 /// Covered uppercase-`D` values are derived from the timestamp range and are
   1118 /// never accepted from callers.
   1119 #[derive(Clone, Debug, PartialEq, Eq)]
   1120 pub struct AuthoredCalendarTimeEvent {
   1121     d_tag: DTag,
   1122     title: String,
   1123     start: u64,
   1124     description: Option<String>,
   1125     end: Option<u64>,
   1126     start_tzid: Option<IanaTimeZoneId>,
   1127     end_tzid: Option<IanaTimeZoneId>,
   1128     locations: Vec<String>,
   1129     geohash: Option<String>,
   1130     summary: Option<String>,
   1131     image: Option<AuthoredImage>,
   1132     participants: Option<Vec<CalendarParticipant>>,
   1133     categories: Vec<String>,
   1134     references: Vec<CalendarUri>,
   1135     calendar_requests: Vec<CalendarRequest>,
   1136 }
   1137 
   1138 impl AuthoredCalendarTimeEvent {
   1139     pub fn new(
   1140         d_tag: impl AsRef<str>,
   1141         title: impl Into<String>,
   1142         start: u64,
   1143     ) -> Result<Self, CalendarEventError> {
   1144         let d_tag =
   1145             DTag::parse(d_tag.as_ref()).map_err(|_| CalendarEventError::InvalidIdentifier)?;
   1146         let title = validated_title(title.into())?;
   1147         covered_utc_days(start, None)?;
   1148         Ok(Self {
   1149             d_tag,
   1150             title,
   1151             start,
   1152             description: None,
   1153             end: None,
   1154             start_tzid: None,
   1155             end_tzid: None,
   1156             locations: Vec::new(),
   1157             geohash: None,
   1158             summary: None,
   1159             image: None,
   1160             participants: None,
   1161             categories: Vec::new(),
   1162             references: Vec::new(),
   1163             calendar_requests: Vec::new(),
   1164         })
   1165     }
   1166 
   1167     pub fn with_end(mut self, end: u64) -> Result<Self, CalendarEventError> {
   1168         covered_utc_days(self.start, Some(end))?;
   1169         self.end = Some(end);
   1170         self.validate_budget()?;
   1171         Ok(self)
   1172     }
   1173 
   1174     pub fn with_description(
   1175         mut self,
   1176         value: impl Into<String>,
   1177     ) -> Result<Self, CalendarEventError> {
   1178         let value = value.into();
   1179         validate_calendar_content(&value)?;
   1180         self.description = Some(value);
   1181         self.validate_budget()?;
   1182         Ok(self)
   1183     }
   1184 
   1185     pub fn with_start_tzid(mut self, value: impl AsRef<str>) -> Result<Self, CalendarEventError> {
   1186         self.start_tzid = Some(IanaTimeZoneId::parse(value)?);
   1187         self.validate_budget()?;
   1188         Ok(self)
   1189     }
   1190 
   1191     pub fn with_end_tzid(mut self, value: impl AsRef<str>) -> Result<Self, CalendarEventError> {
   1192         self.end_tzid = Some(IanaTimeZoneId::parse(value)?);
   1193         self.validate_budget()?;
   1194         Ok(self)
   1195     }
   1196 
   1197     pub fn with_locations(mut self, value: Vec<String>) -> Result<Self, CalendarEventError> {
   1198         validate_authored_calendar_locations(&value)?;
   1199         self.locations = value;
   1200         self.validate_budget()?;
   1201         Ok(self)
   1202     }
   1203 
   1204     pub fn with_geohash(mut self, value: impl Into<String>) -> Result<Self, CalendarEventError> {
   1205         let value = value.into();
   1206         if !canonical_calendar_geohash_is_valid(&value) {
   1207             return Err(CalendarEventError::InvalidGeohash);
   1208         }
   1209         self.geohash = Some(value);
   1210         self.validate_budget()?;
   1211         Ok(self)
   1212     }
   1213 
   1214     pub fn with_summary(mut self, value: impl Into<String>) -> Result<Self, CalendarEventError> {
   1215         let value = value.into();
   1216         validate_canonical_calendar_tag_text(&value, "summary")?;
   1217         self.summary = Some(value);
   1218         self.validate_budget()?;
   1219         Ok(self)
   1220     }
   1221 
   1222     pub fn with_image(mut self, value: AuthoredImage) -> Result<Self, CalendarEventError> {
   1223         self.image = Some(value);
   1224         self.validate_budget()?;
   1225         Ok(self)
   1226     }
   1227 
   1228     pub fn with_participants(
   1229         mut self,
   1230         value: Vec<CalendarParticipant>,
   1231     ) -> Result<Self, CalendarEventError> {
   1232         validate_authored_calendar_participants(&value)?;
   1233         self.participants = Some(value);
   1234         self.validate_budget()?;
   1235         Ok(self)
   1236     }
   1237 
   1238     pub fn with_categories(mut self, value: Vec<String>) -> Result<Self, CalendarEventError> {
   1239         validate_authored_calendar_categories(&value)?;
   1240         self.categories = value;
   1241         self.validate_budget()?;
   1242         Ok(self)
   1243     }
   1244 
   1245     pub fn with_references(mut self, value: Vec<CalendarUri>) -> Result<Self, CalendarEventError> {
   1246         self.references = value;
   1247         self.validate_budget()?;
   1248         Ok(self)
   1249     }
   1250 
   1251     pub fn with_calendar_requests(
   1252         mut self,
   1253         value: Vec<CalendarRequest>,
   1254     ) -> Result<Self, CalendarEventError> {
   1255         validate_authored_calendar_requests(&value)?;
   1256         self.calendar_requests = value;
   1257         self.validate_budget()?;
   1258         Ok(self)
   1259     }
   1260 
   1261     pub fn d_tag(&self) -> &DTag {
   1262         &self.d_tag
   1263     }
   1264 
   1265     pub fn title(&self) -> &str {
   1266         &self.title
   1267     }
   1268 
   1269     pub const fn start(&self) -> u64 {
   1270         self.start
   1271     }
   1272 
   1273     pub fn description(&self) -> Option<&str> {
   1274         self.description.as_deref()
   1275     }
   1276 
   1277     pub const fn end(&self) -> Option<u64> {
   1278         self.end
   1279     }
   1280 
   1281     pub fn start_tzid(&self) -> Option<&IanaTimeZoneId> {
   1282         self.start_tzid.as_ref()
   1283     }
   1284 
   1285     pub fn end_tzid(&self) -> Option<&IanaTimeZoneId> {
   1286         self.end_tzid.as_ref()
   1287     }
   1288 
   1289     pub fn effective_end_tzid(&self) -> Option<&IanaTimeZoneId> {
   1290         self.end_tzid.as_ref().or(self.start_tzid.as_ref())
   1291     }
   1292 
   1293     pub fn locations(&self) -> &[String] {
   1294         &self.locations
   1295     }
   1296 
   1297     pub fn geohash(&self) -> Option<&str> {
   1298         self.geohash.as_deref()
   1299     }
   1300 
   1301     pub fn summary(&self) -> Option<&str> {
   1302         self.summary.as_deref()
   1303     }
   1304 
   1305     pub fn image(&self) -> Option<&AuthoredImage> {
   1306         self.image.as_ref()
   1307     }
   1308 
   1309     pub fn participants(&self) -> Option<&Vec<CalendarParticipant>> {
   1310         self.participants.as_ref()
   1311     }
   1312 
   1313     pub fn categories(&self) -> &[String] {
   1314         &self.categories
   1315     }
   1316 
   1317     pub fn references(&self) -> &[CalendarUri] {
   1318         &self.references
   1319     }
   1320 
   1321     pub fn calendar_requests(&self) -> &[CalendarRequest] {
   1322         &self.calendar_requests
   1323     }
   1324 
   1325     fn validate_budget(&self) -> Result<(), CalendarEventError> {
   1326         validate_authored_calendar_budget(self.description(), authored_time_tags_for_budget(self)?)
   1327     }
   1328 }
   1329 
   1330 #[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))]
   1331 #[derive(Clone, Debug, PartialEq, Eq)]
   1332 pub struct ParsedNip52CalendarCommon {
   1333     d_tag: String,
   1334     title: String,
   1335     description: Option<String>,
   1336     locations: Vec<String>,
   1337     geohash: Option<String>,
   1338     summary: Option<String>,
   1339     image: Option<CalendarUri>,
   1340     participants: Vec<CalendarParticipant>,
   1341     categories: Vec<String>,
   1342     references: Vec<CalendarUri>,
   1343     calendar_requests: Vec<CalendarRequest>,
   1344     legacy_name: Option<String>,
   1345 }
   1346 
   1347 #[derive(Clone, Debug, PartialEq, Eq)]
   1348 pub struct ParsedNip52CalendarCommonParts {
   1349     pub d_tag: String,
   1350     pub title: String,
   1351     pub description: Option<String>,
   1352     pub locations: Vec<String>,
   1353     pub geohash: Option<String>,
   1354     pub summary: Option<String>,
   1355     pub image: Option<CalendarUri>,
   1356     pub participants: Vec<CalendarParticipant>,
   1357     pub categories: Vec<String>,
   1358     pub references: Vec<CalendarUri>,
   1359     pub calendar_requests: Vec<CalendarRequest>,
   1360     pub legacy_name: Option<String>,
   1361 }
   1362 
   1363 impl ParsedNip52CalendarCommon {
   1364     pub fn try_new(parts: ParsedNip52CalendarCommonParts) -> Result<Self, CalendarEventError> {
   1365         validate_calendar_tag_text(&parts.d_tag, "d")?;
   1366         validate_calendar_tag_text(&parts.title, "title")?;
   1367         if let Some(description) = parts.description.as_deref() {
   1368             validate_calendar_content(description)?;
   1369         }
   1370         for location in &parts.locations {
   1371             validate_calendar_tag_text(location, "location")?;
   1372         }
   1373         if let Some(geohash) = parts.geohash.as_deref()
   1374             && !calendar_geohash_is_valid(geohash)
   1375         {
   1376             return Err(CalendarEventError::InvalidGeohash);
   1377         }
   1378         if let Some(summary) = parts.summary.as_deref() {
   1379             validate_calendar_tag_text(summary, "summary")?;
   1380         }
   1381         validate_inbound_calendar_participants(&parts.participants)?;
   1382         for category in &parts.categories {
   1383             validate_calendar_tag_text(category, "category")?;
   1384         }
   1385         if let Some(legacy_name) = parts.legacy_name.as_deref() {
   1386             validate_calendar_tag_text(legacy_name, "legacy name")?;
   1387         }
   1388         Ok(Self {
   1389             d_tag: parts.d_tag,
   1390             title: parts.title,
   1391             description: parts.description,
   1392             locations: parts.locations,
   1393             geohash: parts.geohash,
   1394             summary: parts.summary,
   1395             image: parts.image,
   1396             participants: parts.participants,
   1397             categories: parts.categories,
   1398             references: parts.references,
   1399             calendar_requests: parts.calendar_requests,
   1400             legacy_name: parts.legacy_name,
   1401         })
   1402     }
   1403 
   1404     pub fn d_tag(&self) -> &str {
   1405         &self.d_tag
   1406     }
   1407 
   1408     pub fn title(&self) -> &str {
   1409         &self.title
   1410     }
   1411 
   1412     pub fn description(&self) -> Option<&str> {
   1413         self.description.as_deref()
   1414     }
   1415 
   1416     pub fn locations(&self) -> &[String] {
   1417         &self.locations
   1418     }
   1419 
   1420     pub fn geohash(&self) -> Option<&str> {
   1421         self.geohash.as_deref()
   1422     }
   1423 
   1424     pub fn summary(&self) -> Option<&str> {
   1425         self.summary.as_deref()
   1426     }
   1427 
   1428     pub fn image(&self) -> Option<&CalendarUri> {
   1429         self.image.as_ref()
   1430     }
   1431 
   1432     pub fn participants(&self) -> &[CalendarParticipant] {
   1433         &self.participants
   1434     }
   1435 
   1436     pub fn categories(&self) -> &[String] {
   1437         &self.categories
   1438     }
   1439 
   1440     pub fn references(&self) -> &[CalendarUri] {
   1441         &self.references
   1442     }
   1443 
   1444     pub fn calendar_requests(&self) -> &[CalendarRequest] {
   1445         &self.calendar_requests
   1446     }
   1447 
   1448     pub fn legacy_name(&self) -> Option<&str> {
   1449         self.legacy_name.as_deref()
   1450     }
   1451 }
   1452 
   1453 #[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))]
   1454 #[derive(Clone, Debug, PartialEq, Eq)]
   1455 pub struct ParsedNip52CalendarDateEvent {
   1456     common: ParsedNip52CalendarCommon,
   1457     start: CalendarDate,
   1458     end: Option<CalendarDate>,
   1459     extension_day_tags: Vec<Vec<String>>,
   1460 }
   1461 
   1462 impl ParsedNip52CalendarDateEvent {
   1463     pub fn try_new(
   1464         common: ParsedNip52CalendarCommon,
   1465         start: CalendarDate,
   1466         end: Option<CalendarDate>,
   1467         extension_day_tags: Vec<Vec<String>>,
   1468     ) -> Result<Self, CalendarEventError> {
   1469         if end.as_ref().is_some_and(|end| end <= &start) {
   1470             return Err(CalendarEventError::InvalidRange);
   1471         }
   1472         Ok(Self {
   1473             common,
   1474             start,
   1475             end,
   1476             extension_day_tags,
   1477         })
   1478     }
   1479 
   1480     pub fn common(&self) -> &ParsedNip52CalendarCommon {
   1481         &self.common
   1482     }
   1483 
   1484     pub fn start(&self) -> &CalendarDate {
   1485         &self.start
   1486     }
   1487 
   1488     pub fn end(&self) -> Option<&CalendarDate> {
   1489         self.end.as_ref()
   1490     }
   1491 
   1492     pub fn extension_day_tags(&self) -> &[Vec<String>] {
   1493         &self.extension_day_tags
   1494     }
   1495 }
   1496 
   1497 #[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))]
   1498 #[derive(Clone, Debug, PartialEq, Eq)]
   1499 pub struct ObservedUtcDay {
   1500     wire_value: String,
   1501     index: u64,
   1502 }
   1503 
   1504 impl ObservedUtcDay {
   1505     pub fn parse(value: impl Into<String>) -> Result<Self, CalendarEventError> {
   1506         let wire_value = value.into();
   1507         if wire_value.is_empty() || !wire_value.bytes().all(|byte| byte.is_ascii_digit()) {
   1508             return Err(CalendarEventError::InvalidText("D"));
   1509         }
   1510         let index = wire_value
   1511             .parse()
   1512             .map_err(|_| CalendarEventError::InvalidText("D"))?;
   1513         Ok(Self { wire_value, index })
   1514     }
   1515 
   1516     pub fn wire_value(&self) -> &str {
   1517         &self.wire_value
   1518     }
   1519 
   1520     pub const fn index(&self) -> u64 {
   1521         self.index
   1522     }
   1523 
   1524     pub fn is_canonical(&self) -> bool {
   1525         self.wire_value == self.index.to_string()
   1526     }
   1527 }
   1528 
   1529 #[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))]
   1530 #[derive(Clone, Debug, PartialEq, Eq)]
   1531 pub struct ParsedNip52CalendarTimeEvent {
   1532     common: ParsedNip52CalendarCommon,
   1533     start_wire: String,
   1534     start: u64,
   1535     end_wire: Option<String>,
   1536     end: Option<u64>,
   1537     observed_day_indices: Vec<ObservedUtcDay>,
   1538     start_tzid: Option<IanaTimeZoneId>,
   1539     end_tzid: Option<IanaTimeZoneId>,
   1540 }
   1541 
   1542 impl ParsedNip52CalendarTimeEvent {
   1543     #[allow(clippy::too_many_arguments)]
   1544     pub fn try_new(
   1545         common: ParsedNip52CalendarCommon,
   1546         start_wire: String,
   1547         start: u64,
   1548         end_wire: Option<String>,
   1549         end: Option<u64>,
   1550         observed_day_indices: Vec<ObservedUtcDay>,
   1551         start_tzid: Option<IanaTimeZoneId>,
   1552         end_tzid: Option<IanaTimeZoneId>,
   1553     ) -> Result<Self, CalendarEventError> {
   1554         if parse_calendar_decimal(&start_wire) != Some(start)
   1555             || end_wire
   1556                 .as_deref()
   1557                 .zip(end)
   1558                 .is_some_and(|(wire, end)| parse_calendar_decimal(wire) != Some(end))
   1559             || end_wire.is_some() != end.is_some()
   1560         {
   1561             return Err(CalendarEventError::InvalidRange);
   1562         }
   1563         if end.is_some_and(|end| end <= start) {
   1564             return Err(CalendarEventError::InvalidRange);
   1565         }
   1566         let first = start / RADROOTS_CALENDAR_SECONDS_PER_DAY;
   1567         let last = end
   1568             .map(|end| (end - 1) / RADROOTS_CALENDAR_SECONDS_PER_DAY)
   1569             .unwrap_or(first);
   1570         if observed_day_indices.is_empty()
   1571             || observed_day_indices
   1572                 .iter()
   1573                 .any(|day| !(first..=last).contains(&day.index))
   1574         {
   1575             return Err(CalendarEventError::InvalidRange);
   1576         }
   1577         Ok(Self {
   1578             common,
   1579             start_wire,
   1580             start,
   1581             end_wire,
   1582             end,
   1583             observed_day_indices,
   1584             start_tzid,
   1585             end_tzid,
   1586         })
   1587     }
   1588 
   1589     pub fn common(&self) -> &ParsedNip52CalendarCommon {
   1590         &self.common
   1591     }
   1592 
   1593     pub fn start_wire(&self) -> &str {
   1594         &self.start_wire
   1595     }
   1596 
   1597     pub const fn start(&self) -> u64 {
   1598         self.start
   1599     }
   1600 
   1601     pub fn end_wire(&self) -> Option<&str> {
   1602         self.end_wire.as_deref()
   1603     }
   1604 
   1605     pub const fn end(&self) -> Option<u64> {
   1606         self.end
   1607     }
   1608 
   1609     pub fn observed_day_indices(&self) -> &[ObservedUtcDay] {
   1610         &self.observed_day_indices
   1611     }
   1612 
   1613     pub fn start_tzid(&self) -> Option<&IanaTimeZoneId> {
   1614         self.start_tzid.as_ref()
   1615     }
   1616 
   1617     pub fn end_tzid(&self) -> Option<&IanaTimeZoneId> {
   1618         self.end_tzid.as_ref()
   1619     }
   1620 
   1621     pub fn effective_end_tzid(&self) -> Option<&IanaTimeZoneId> {
   1622         self.end_tzid.as_ref().or(self.start_tzid.as_ref())
   1623     }
   1624 }
   1625 
   1626 #[non_exhaustive]
   1627 #[derive(Clone, Debug, PartialEq, Eq)]
   1628 pub enum CalendarAdmissionError {
   1629     NonCanonicalField(&'static str),
   1630     DuplicateEventReference,
   1631     AuthorHintMismatch,
   1632     ForbiddenDateDayIndex,
   1633     IncompleteDayCoverage,
   1634     CoveredDayLimitExceeded { max: u64, actual: u64 },
   1635     NonBlossomImage,
   1636 }
   1637 
   1638 impl CalendarAdmissionError {
   1639     pub const fn code(&self) -> &'static str {
   1640         match self {
   1641             Self::NonCanonicalField(_) => "non_canonical_field",
   1642             Self::DuplicateEventReference => "duplicate_event_reference",
   1643             Self::AuthorHintMismatch => "author_hint_mismatch",
   1644             Self::ForbiddenDateDayIndex => "forbidden_date_day_index",
   1645             Self::IncompleteDayCoverage => "incomplete_day_coverage",
   1646             Self::CoveredDayLimitExceeded { .. } => "covered_day_limit_exceeded",
   1647             Self::NonBlossomImage => "non_blossom_image",
   1648         }
   1649     }
   1650 }
   1651 
   1652 impl fmt::Display for CalendarAdmissionError {
   1653     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
   1654         match self {
   1655             Self::NonCanonicalField(field) => write!(f, "calendar {field} is not canonical"),
   1656             Self::DuplicateEventReference => {
   1657                 f.write_str("calendar event references must have unique coordinates")
   1658             }
   1659             Self::AuthorHintMismatch => {
   1660                 f.write_str("calendar event author hint does not match the event coordinate")
   1661             }
   1662             Self::ForbiddenDateDayIndex => {
   1663                 f.write_str("calendar date event carries a forbidden uppercase-D extension")
   1664             }
   1665             Self::IncompleteDayCoverage => {
   1666                 f.write_str("calendar time event lacks exact ordered UTC-day coverage")
   1667             }
   1668             Self::CoveredDayLimitExceeded { max, actual } => write!(
   1669                 f,
   1670                 "calendar interval covers {actual} UTC days, exceeding the {max}-day admission limit"
   1671             ),
   1672             Self::NonBlossomImage => {
   1673                 f.write_str("calendar image is not a structural Blossom hash-path URL")
   1674             }
   1675         }
   1676     }
   1677 }
   1678 
   1679 #[cfg(feature = "std")]
   1680 impl std::error::Error for CalendarAdmissionError {}
   1681 
   1682 #[derive(Clone, Debug, PartialEq, Eq)]
   1683 pub struct AdmittedCalendarDateEvent {
   1684     parsed: ParsedNip52CalendarDateEvent,
   1685     d_tag: DTag,
   1686     blossom_image: Option<BlobUrl>,
   1687 }
   1688 
   1689 impl AdmittedCalendarDateEvent {
   1690     pub fn try_from_parsed(
   1691         parsed: ParsedNip52CalendarDateEvent,
   1692     ) -> Result<Self, CalendarAdmissionError> {
   1693         if !parsed.extension_day_tags.is_empty() {
   1694             return Err(CalendarAdmissionError::ForbiddenDateDayIndex);
   1695         }
   1696         validate_admitted_calendar_common(&parsed.common)?;
   1697         let d_tag = admitted_d_tag(&parsed.common)?;
   1698         let blossom_image = admitted_blossom_image(&parsed.common)?;
   1699         Ok(Self {
   1700             parsed,
   1701             d_tag,
   1702             blossom_image,
   1703         })
   1704     }
   1705 
   1706     pub fn parsed(&self) -> &ParsedNip52CalendarDateEvent {
   1707         &self.parsed
   1708     }
   1709 
   1710     pub fn d_tag(&self) -> &DTag {
   1711         &self.d_tag
   1712     }
   1713 
   1714     pub fn blossom_image(&self) -> Option<&BlobUrl> {
   1715         self.blossom_image.as_ref()
   1716     }
   1717 }
   1718 
   1719 #[derive(Clone, Debug, PartialEq, Eq)]
   1720 pub struct AdmittedCalendarTimeEvent {
   1721     parsed: ParsedNip52CalendarTimeEvent,
   1722     d_tag: DTag,
   1723     covered_utc_days: Vec<u64>,
   1724     blossom_image: Option<BlobUrl>,
   1725 }
   1726 
   1727 impl AdmittedCalendarTimeEvent {
   1728     pub fn try_from_parsed(
   1729         parsed: ParsedNip52CalendarTimeEvent,
   1730     ) -> Result<Self, CalendarAdmissionError> {
   1731         validate_admitted_calendar_common(&parsed.common)?;
   1732         let d_tag = admitted_d_tag(&parsed.common)?;
   1733         if [
   1734             parsed.start_wire == parsed.start.to_string(),
   1735             !parsed
   1736                 .end_wire
   1737                 .as_deref()
   1738                 .zip(parsed.end)
   1739                 .is_some_and(|(wire, end)| wire != end.to_string()),
   1740         ] != [true; 2]
   1741         {
   1742             return Err(CalendarAdmissionError::NonCanonicalField("timestamp"));
   1743         }
   1744         let expected = covered_utc_days(parsed.start, parsed.end).map_err(|error| match error {
   1745             CalendarEventError::CoveredDayLimitExceeded { max, actual } => {
   1746                 CalendarAdmissionError::CoveredDayLimitExceeded { max, actual }
   1747             }
   1748             _ => CalendarAdmissionError::IncompleteDayCoverage,
   1749         })?;
   1750         let covered_utc_days = expected.clone().collect::<Vec<_>>();
   1751         if parsed.observed_day_indices.len() != covered_utc_days.len()
   1752             || parsed
   1753                 .observed_day_indices
   1754                 .iter()
   1755                 .zip(&covered_utc_days)
   1756                 .any(|(observed, expected)| !observed.is_canonical() || observed.index != *expected)
   1757         {
   1758             return Err(CalendarAdmissionError::IncompleteDayCoverage);
   1759         }
   1760         let blossom_image = admitted_blossom_image(&parsed.common)?;
   1761         Ok(Self {
   1762             parsed,
   1763             d_tag,
   1764             covered_utc_days,
   1765             blossom_image,
   1766         })
   1767     }
   1768 
   1769     pub fn parsed(&self) -> &ParsedNip52CalendarTimeEvent {
   1770         &self.parsed
   1771     }
   1772 
   1773     pub fn d_tag(&self) -> &DTag {
   1774         &self.d_tag
   1775     }
   1776 
   1777     pub fn covered_utc_days(&self) -> &[u64] {
   1778         &self.covered_utc_days
   1779     }
   1780 
   1781     pub fn blossom_image(&self) -> Option<&BlobUrl> {
   1782         self.blossom_image.as_ref()
   1783     }
   1784 }
   1785 
   1786 pub fn covered_utc_days(
   1787     start: u64,
   1788     end: Option<u64>,
   1789 ) -> Result<RangeInclusive<u64>, CalendarEventError> {
   1790     if end.is_some_and(|end| end <= start) {
   1791         return Err(CalendarEventError::InvalidRange);
   1792     }
   1793     let first = start / RADROOTS_CALENDAR_SECONDS_PER_DAY;
   1794     let last = end
   1795         .map(|end| (end - 1) / RADROOTS_CALENDAR_SECONDS_PER_DAY)
   1796         .unwrap_or(first);
   1797     let actual = last - first + 1;
   1798     if actual > RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS {
   1799         return Err(CalendarEventError::CoveredDayLimitExceeded {
   1800             max: RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS,
   1801             actual,
   1802         });
   1803     }
   1804     Ok(first..=last)
   1805 }
   1806 
   1807 fn validated_title(value: String) -> Result<String, CalendarEventError> {
   1808     if !canonical_calendar_tag_text_is_valid(&value) {
   1809         return Err(CalendarEventError::InvalidTitle);
   1810     }
   1811     Ok(value)
   1812 }
   1813 
   1814 fn parse_calendar_decimal(value: &str) -> Option<u64> {
   1815     if [
   1816         !value.is_empty(),
   1817         value.bytes().all(|byte| byte.is_ascii_digit()),
   1818     ] != [true; 2]
   1819     {
   1820         return None;
   1821     }
   1822     value.parse().ok()
   1823 }
   1824 
   1825 pub fn calendar_tag_text_is_valid(value: &str) -> bool {
   1826     [
   1827         !value.trim().is_empty(),
   1828         !value.chars().any(char::is_control),
   1829         value.len() <= DEFAULT_TAG_ELEMENT_MAX_BYTES,
   1830     ] == [true; 3]
   1831 }
   1832 
   1833 pub fn canonical_calendar_tag_text_is_valid(value: &str) -> bool {
   1834     [calendar_tag_text_is_valid(value), value.trim() == value] == [true; 2]
   1835 }
   1836 
   1837 pub fn calendar_geohash_is_valid(value: &str) -> bool {
   1838     [
   1839         !value.is_empty(),
   1840         value.len() <= 12,
   1841         value.bytes().all(|byte| {
   1842             matches!(
   1843                 byte.to_ascii_lowercase(),
   1844                 b'0'..=b'9'
   1845                     | b'b'
   1846                     | b'c'
   1847                     | b'd'
   1848                     | b'e'
   1849                     | b'f'
   1850                     | b'g'
   1851                     | b'h'
   1852                     | b'j'
   1853                     | b'k'
   1854                     | b'm'
   1855                     | b'n'
   1856                     | b'p'
   1857                     | b'q'
   1858                     | b'r'
   1859                     | b's'
   1860                     | b't'
   1861                     | b'u'
   1862                     | b'v'
   1863                     | b'w'
   1864                     | b'x'
   1865                     | b'y'
   1866                     | b'z'
   1867             )
   1868         }),
   1869     ] == [true; 3]
   1870 }
   1871 
   1872 pub fn canonical_calendar_geohash_is_valid(value: &str) -> bool {
   1873     [
   1874         calendar_geohash_is_valid(value),
   1875         value.bytes().all(|byte| !byte.is_ascii_uppercase()),
   1876     ] == [true; 2]
   1877 }
   1878 
   1879 pub fn calendar_relay_url_is_valid(value: &str) -> bool {
   1880     if [
   1881         !value.is_empty(),
   1882         !value
   1883             .chars()
   1884             .any(|character| [character.is_control(), character.is_whitespace()].contains(&true)),
   1885     ] != [true; 2]
   1886     {
   1887         return false;
   1888     }
   1889     let Some((scheme, remainder)) = value.split_once("://") else {
   1890         return false;
   1891     };
   1892     if ![
   1893         scheme.eq_ignore_ascii_case("ws"),
   1894         scheme.eq_ignore_ascii_case("wss"),
   1895     ]
   1896     .contains(&true)
   1897     {
   1898         return false;
   1899     }
   1900     let Ok(parsed) = Url::parse(value) else {
   1901         return false;
   1902     };
   1903     let authority = remainder.split(['/', '?', '#']).next().unwrap_or(remainder);
   1904     [
   1905         !authority.is_empty(),
   1906         matches!(parsed.scheme(), "ws" | "wss"),
   1907         parsed.host_str().is_some_and(|host| !host.is_empty()),
   1908         parsed.username().is_empty(),
   1909         parsed.password().is_none(),
   1910         parsed.fragment().is_none(),
   1911         parsed.port() != Some(0),
   1912         !authority.contains('@'),
   1913     ] == [true; 8]
   1914 }
   1915 
   1916 fn parse_calendar_reference_relay(
   1917     relay: Option<&str>,
   1918     error: CalendarEventError,
   1919 ) -> Result<Option<String>, CalendarEventError> {
   1920     relay
   1921         .map(|relay| {
   1922             if calendar_relay_url_is_valid(relay) {
   1923                 Ok(relay.to_string())
   1924             } else {
   1925                 Err(error.clone())
   1926             }
   1927         })
   1928         .transpose()
   1929 }
   1930 
   1931 fn calendar_event_references_have_duplicates(references: &[CalendarEventReference]) -> bool {
   1932     references.iter().enumerate().any(|(index, reference)| {
   1933         references
   1934             .iter()
   1935             .skip(index + 1)
   1936             .any(|candidate| reference.has_same_coordinate(candidate))
   1937     })
   1938 }
   1939 
   1940 fn validate_strict_calendar_event_references(
   1941     references: &[CalendarEventReference],
   1942 ) -> Result<(), CalendarEventError> {
   1943     if references.iter().any(|reference| !reference.is_canonical()) {
   1944         return Err(CalendarEventError::InvalidEventReference);
   1945     }
   1946     if calendar_event_references_have_duplicates(references) {
   1947         return Err(CalendarEventError::DuplicateEventReference);
   1948     }
   1949     Ok(())
   1950 }
   1951 
   1952 fn validate_strict_calendar_author_hint(
   1953     event_reference: &CalendarEventReference,
   1954     author_hint: &CalendarEventAuthorReference,
   1955 ) -> Result<(), CalendarEventError> {
   1956     if !author_hint.is_canonical() {
   1957         return Err(CalendarEventError::InvalidAuthorReference);
   1958     }
   1959     if author_hint.pubkey() != event_reference.author() {
   1960         return Err(CalendarEventError::AuthorHintMismatch);
   1961     }
   1962     Ok(())
   1963 }
   1964 
   1965 fn validate_calendar_tag_text(value: &str, field: &'static str) -> Result<(), CalendarEventError> {
   1966     if !calendar_tag_text_is_valid(value) {
   1967         return Err(if value.len() > DEFAULT_TAG_ELEMENT_MAX_BYTES {
   1968             CalendarEventError::TagElementTooLarge {
   1969                 field,
   1970                 max: DEFAULT_TAG_ELEMENT_MAX_BYTES,
   1971                 actual: value.len(),
   1972             }
   1973         } else {
   1974             CalendarEventError::InvalidText(field)
   1975         });
   1976     }
   1977     Ok(())
   1978 }
   1979 
   1980 fn validate_canonical_calendar_tag_text(
   1981     value: &str,
   1982     field: &'static str,
   1983 ) -> Result<(), CalendarEventError> {
   1984     validate_calendar_tag_text(value, field)?;
   1985     if value.trim() != value {
   1986         return Err(CalendarEventError::InvalidText(field));
   1987     }
   1988     Ok(())
   1989 }
   1990 
   1991 fn validate_calendar_content(value: &str) -> Result<(), CalendarEventError> {
   1992     if value.len() > DEFAULT_CONTENT_MAX_BYTES {
   1993         return Err(CalendarEventError::ContentTooLarge {
   1994             max: DEFAULT_CONTENT_MAX_BYTES,
   1995             actual: value.len(),
   1996         });
   1997     }
   1998     Ok(())
   1999 }
   2000 
   2001 fn validate_authored_calendar_locations(locations: &[String]) -> Result<(), CalendarEventError> {
   2002     for location in locations {
   2003         validate_canonical_calendar_tag_text(location, "location")?;
   2004     }
   2005     Ok(())
   2006 }
   2007 
   2008 fn validate_authored_calendar_requests(
   2009     requests: &[CalendarRequest],
   2010 ) -> Result<(), CalendarEventError> {
   2011     if requests.iter().any(|request| !request.is_canonical()) {
   2012         return Err(CalendarEventError::InvalidUrl("calendar request"));
   2013     }
   2014     Ok(())
   2015 }
   2016 
   2017 fn validate_inbound_calendar_participants(
   2018     participants: &[CalendarParticipant],
   2019 ) -> Result<(), CalendarEventError> {
   2020     for (index, participant) in participants.iter().enumerate() {
   2021         if parse_public_key(&participant.pubkey).is_err()
   2022             || participant
   2023                 .relay
   2024                 .as_deref()
   2025                 .is_some_and(|relay| !calendar_relay_url_is_valid(relay))
   2026             || participant
   2027                 .role
   2028                 .as_deref()
   2029                 .is_some_and(|role| !calendar_tag_text_is_valid(role))
   2030         {
   2031             return Err(CalendarEventError::InvalidParticipant { index });
   2032         }
   2033     }
   2034     Ok(())
   2035 }
   2036 
   2037 fn validate_authored_calendar_participants(
   2038     participants: &[CalendarParticipant],
   2039 ) -> Result<(), CalendarEventError> {
   2040     if participants.len() > RADROOTS_CALENDAR_MAX_PARTICIPANTS {
   2041         return Err(CalendarEventError::TooManyParticipants {
   2042             max: RADROOTS_CALENDAR_MAX_PARTICIPANTS,
   2043             actual: participants.len(),
   2044         });
   2045     }
   2046     validate_inbound_calendar_participants(participants)?;
   2047     for (index, participant) in participants.iter().enumerate() {
   2048         let pubkey = parse_public_key(&participant.pubkey)
   2049             .map_err(|_| CalendarEventError::InvalidParticipant { index })?;
   2050         if pubkey.to_hex() != participant.pubkey
   2051             || participant
   2052                 .relay
   2053                 .as_deref()
   2054                 .is_some_and(|relay| RelayUrl::parse(relay).is_err())
   2055             || participant
   2056                 .role
   2057                 .as_deref()
   2058                 .is_some_and(|role| !canonical_calendar_tag_text_is_valid(role))
   2059         {
   2060             return Err(CalendarEventError::InvalidParticipant { index });
   2061         }
   2062     }
   2063     Ok(())
   2064 }
   2065 
   2066 fn validate_authored_calendar_categories(categories: &[String]) -> Result<(), CalendarEventError> {
   2067     for category in categories {
   2068         validate_canonical_calendar_tag_text(category, "category")?;
   2069     }
   2070     Ok(())
   2071 }
   2072 
   2073 fn validate_admitted_calendar_common(
   2074     common: &ParsedNip52CalendarCommon,
   2075 ) -> Result<(), CalendarAdmissionError> {
   2076     let canonical = canonical_calendar_tag_text_is_valid(&common.title)
   2077         && common
   2078             .locations
   2079             .iter()
   2080             .all(|value| canonical_calendar_tag_text_is_valid(value))
   2081         && common
   2082             .summary
   2083             .as_deref()
   2084             .is_none_or(canonical_calendar_tag_text_is_valid)
   2085         && common
   2086             .geohash
   2087             .as_deref()
   2088             .is_none_or(canonical_calendar_geohash_is_valid)
   2089         && common
   2090             .categories
   2091             .iter()
   2092             .all(|value| canonical_calendar_tag_text_is_valid(value))
   2093         && common
   2094             .legacy_name
   2095             .as_deref()
   2096             .is_none_or(canonical_calendar_tag_text_is_valid)
   2097         && common
   2098             .calendar_requests
   2099             .iter()
   2100             .all(CalendarRequest::is_canonical);
   2101     if !canonical {
   2102         return Err(CalendarAdmissionError::NonCanonicalField("metadata"));
   2103     }
   2104     validate_authored_calendar_participants(&common.participants)
   2105         .map_err(|_| CalendarAdmissionError::NonCanonicalField("participant"))?;
   2106     Ok(())
   2107 }
   2108 
   2109 fn admitted_blossom_image(
   2110     common: &ParsedNip52CalendarCommon,
   2111 ) -> Result<Option<BlobUrl>, CalendarAdmissionError> {
   2112     common
   2113         .image
   2114         .as_ref()
   2115         .map(|image| {
   2116             BlobUrl::parse(image.as_str()).map_err(|_| CalendarAdmissionError::NonBlossomImage)
   2117         })
   2118         .transpose()
   2119 }
   2120 
   2121 fn admitted_d_tag(common: &ParsedNip52CalendarCommon) -> Result<DTag, CalendarAdmissionError> {
   2122     let d_tag =
   2123         DTag::parse(&common.d_tag).map_err(|_| CalendarAdmissionError::NonCanonicalField("d"))?;
   2124     if d_tag.as_str() != common.d_tag {
   2125         return Err(CalendarAdmissionError::NonCanonicalField("d"));
   2126     }
   2127     Ok(d_tag)
   2128 }
   2129 
   2130 fn authored_date_tags_for_budget(event: &AuthoredCalendarDateEvent) -> Vec<Vec<String>> {
   2131     let mut tags = vec![
   2132         budget_tag("d", event.d_tag().as_str()),
   2133         budget_tag("title", event.title()),
   2134         budget_tag("start", event.start().as_str()),
   2135     ];
   2136     if let Some(end) = event.end() {
   2137         tags.push(budget_tag("end", end.as_str()));
   2138     }
   2139     append_authored_common_tags_for_budget(
   2140         &mut tags,
   2141         event.locations(),
   2142         event.geohash(),
   2143         event.summary(),
   2144         event.image(),
   2145         event.participants(),
   2146         event.categories(),
   2147         event.references(),
   2148         event.calendar_requests(),
   2149     );
   2150     tags
   2151 }
   2152 
   2153 fn authored_time_tags_for_budget(
   2154     event: &AuthoredCalendarTimeEvent,
   2155 ) -> Result<Vec<Vec<String>>, CalendarEventError> {
   2156     let mut tags = vec![
   2157         budget_tag("d", event.d_tag().as_str()),
   2158         budget_tag("title", event.title()),
   2159         budget_tag("start", &event.start().to_string()),
   2160     ];
   2161     if let Some(end) = event.end() {
   2162         tags.push(budget_tag("end", &end.to_string()));
   2163     }
   2164     for day in covered_utc_days(event.start(), event.end())? {
   2165         tags.push(budget_tag("D", &day.to_string()));
   2166     }
   2167     if let Some(tzid) = event.start_tzid() {
   2168         tags.push(budget_tag("start_tzid", tzid.as_str()));
   2169     }
   2170     if let Some(tzid) = event.end_tzid() {
   2171         tags.push(budget_tag("end_tzid", tzid.as_str()));
   2172     }
   2173     append_authored_common_tags_for_budget(
   2174         &mut tags,
   2175         event.locations(),
   2176         event.geohash(),
   2177         event.summary(),
   2178         event.image(),
   2179         event.participants(),
   2180         event.categories(),
   2181         event.references(),
   2182         event.calendar_requests(),
   2183     );
   2184     Ok(tags)
   2185 }
   2186 
   2187 #[allow(clippy::too_many_arguments)]
   2188 fn append_authored_common_tags_for_budget(
   2189     tags: &mut Vec<Vec<String>>,
   2190     locations: &[String],
   2191     geohash: Option<&str>,
   2192     summary: Option<&str>,
   2193     image: Option<&AuthoredImage>,
   2194     participants: Option<&Vec<CalendarParticipant>>,
   2195     categories: &[String],
   2196     references: &[CalendarUri],
   2197     calendar_requests: &[CalendarRequest],
   2198 ) {
   2199     tags.extend(
   2200         locations
   2201             .iter()
   2202             .map(|location| budget_tag("location", location)),
   2203     );
   2204     if let Some(geohash) = geohash {
   2205         tags.push(budget_tag("g", geohash));
   2206     }
   2207     if let Some(summary) = summary {
   2208         tags.push(budget_tag("summary", summary));
   2209     }
   2210     if let Some(image) = image {
   2211         tags.push(budget_tag("image", image.descriptor().url().as_str()));
   2212     }
   2213     if let Some(participants) = participants {
   2214         for participant in participants {
   2215             let mut tag = vec!["p".to_string(), participant.pubkey.clone()];
   2216             if let Some(role) = participant.role.as_ref() {
   2217                 tag.push(participant.relay.clone().unwrap_or_default());
   2218                 tag.push(role.clone());
   2219             } else if let Some(relay) = participant.relay.as_ref() {
   2220                 tag.push(relay.clone());
   2221             }
   2222             tags.push(tag);
   2223         }
   2224     }
   2225     tags.extend(categories.iter().map(|category| budget_tag("t", category)));
   2226     tags.extend(
   2227         references
   2228             .iter()
   2229             .map(|reference| budget_tag("r", reference.as_str())),
   2230     );
   2231     for request in calendar_requests {
   2232         let mut tag = budget_tag("a", request.calendar().as_str());
   2233         if let Some(relay) = request.relay() {
   2234             tag.push(relay.to_string());
   2235         }
   2236         tags.push(tag);
   2237     }
   2238 }
   2239 
   2240 fn budget_tag(key: &str, value: &str) -> Vec<String> {
   2241     vec![key.to_string(), value.to_string()]
   2242 }
   2243 
   2244 fn validate_calendar_collection_budget(
   2245     d_tag: &str,
   2246     title: &str,
   2247     content: &str,
   2248     event_references: &[CalendarEventReference],
   2249     list_description: Option<&str>,
   2250     image: Option<&str>,
   2251 ) -> Result<(), CalendarEventError> {
   2252     let mut tags = vec![budget_tag("d", d_tag), budget_tag("title", title)];
   2253     for reference in event_references {
   2254         let mut tag = budget_tag("a", reference.coordinate().as_str());
   2255         if let Some(relay) = reference.relay() {
   2256             tag.push(relay.to_string());
   2257         }
   2258         tags.push(tag);
   2259     }
   2260     if let Some(description) = list_description {
   2261         tags.push(budget_tag("description", description));
   2262     }
   2263     if let Some(image) = image {
   2264         tags.push(budget_tag("image", image));
   2265     }
   2266     validate_authored_calendar_budget(Some(content), tags)
   2267 }
   2268 
   2269 #[allow(clippy::too_many_arguments)]
   2270 fn validate_calendar_rsvp_budget(
   2271     d_tag: &str,
   2272     event_reference: &CalendarEventReference,
   2273     revision_reference: Option<&CalendarEventRevisionReference>,
   2274     status: &CalendarEventRsvpStatus,
   2275     free_busy: Option<&CalendarEventFreeBusy>,
   2276     author_hint: Option<&CalendarEventAuthorReference>,
   2277     note: Option<&str>,
   2278 ) -> Result<(), CalendarEventError> {
   2279     let mut event_tag = budget_tag("a", event_reference.coordinate().as_str());
   2280     if let Some(relay) = event_reference.relay() {
   2281         event_tag.push(relay.to_string());
   2282     }
   2283     let mut tags = vec![
   2284         budget_tag("d", d_tag),
   2285         event_tag,
   2286         budget_tag("status", calendar_rsvp_status_wire_value(status)),
   2287     ];
   2288     if let Some(reference) = revision_reference {
   2289         let mut tag = budget_tag("e", reference.raw_event_id());
   2290         if let Some(relay) = reference.relay() {
   2291             tag.push(relay.to_string());
   2292         }
   2293         tags.push(tag);
   2294     }
   2295     if let Some(free_busy) = free_busy {
   2296         tags.push(budget_tag("fb", calendar_free_busy_wire_value(free_busy)));
   2297     }
   2298     if let Some(author_hint) = author_hint {
   2299         let mut tag = budget_tag("p", author_hint.raw_pubkey());
   2300         if let Some(relay) = author_hint.relay() {
   2301             tag.push(relay.to_string());
   2302         }
   2303         tags.push(tag);
   2304     }
   2305     validate_authored_calendar_budget(note, tags)
   2306 }
   2307 
   2308 fn calendar_rsvp_status_wire_value(status: &CalendarEventRsvpStatus) -> &'static str {
   2309     match status {
   2310         CalendarEventRsvpStatus::Accepted => "accepted",
   2311         CalendarEventRsvpStatus::Declined => "declined",
   2312         CalendarEventRsvpStatus::Tentative => "tentative",
   2313     }
   2314 }
   2315 
   2316 fn calendar_free_busy_wire_value(free_busy: &CalendarEventFreeBusy) -> &'static str {
   2317     match free_busy {
   2318         CalendarEventFreeBusy::Free => "free",
   2319         CalendarEventFreeBusy::Busy => "busy",
   2320     }
   2321 }
   2322 
   2323 fn validate_authored_calendar_budget(
   2324     description: Option<&str>,
   2325     tags: Vec<Vec<String>>,
   2326 ) -> Result<(), CalendarEventError> {
   2327     if let Some(description) = description {
   2328         validate_calendar_content(description)?;
   2329     }
   2330     if tags.len() > DEFAULT_TAG_MAX_COUNT {
   2331         return Err(CalendarEventError::TagCountExceeded {
   2332             max: DEFAULT_TAG_MAX_COUNT,
   2333             actual: tags.len(),
   2334         });
   2335     }
   2336     let mut total = 0usize;
   2337     for tag in &tags {
   2338         for value in tag {
   2339             if value.len() > DEFAULT_TAG_ELEMENT_MAX_BYTES {
   2340                 return Err(CalendarEventError::TagElementTooLarge {
   2341                     field: "tag",
   2342                     max: DEFAULT_TAG_ELEMENT_MAX_BYTES,
   2343                     actual: value.len(),
   2344                 });
   2345             }
   2346             total = total.saturating_add(value.len());
   2347         }
   2348     }
   2349     if total > DEFAULT_TAG_TOTAL_MAX_BYTES {
   2350         return Err(CalendarEventError::TagBytesExceeded {
   2351             max: DEFAULT_TAG_TOTAL_MAX_BYTES,
   2352             actual: total,
   2353         });
   2354     }
   2355     Ok(())
   2356 }
   2357 
   2358 fn parse_date_digits(bytes: &[u8]) -> Result<u16, CalendarEventError> {
   2359     bytes.iter().try_fold(0_u16, |value, byte| {
   2360         if byte.is_ascii_digit() {
   2361             Ok(value * 10 + u16::from(byte - b'0'))
   2362         } else {
   2363             Err(CalendarEventError::InvalidDate)
   2364         }
   2365     })
   2366 }
   2367 
   2368 const fn days_in_month(year: u16, month: u16) -> u16 {
   2369     match month {
   2370         1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
   2371         4 | 6 | 9 | 11 => 30,
   2372         2 if is_leap_year(year) => 29,
   2373         2 => 28,
   2374         _ => 0,
   2375     }
   2376 }
   2377 
   2378 const fn is_leap_year(year: u16) -> bool {
   2379     year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
   2380 }
   2381 
   2382 /// Strict authored representation of a NIP-52 kind-31925 RSVP.
   2383 #[derive(Clone, Debug, PartialEq, Eq)]
   2384 pub struct AuthoredCalendarEventRsvp {
   2385     uid: CalendarUid,
   2386     event_reference: CalendarEventReference,
   2387     revision_reference: Option<CalendarEventRevisionReference>,
   2388     status: CalendarEventRsvpStatus,
   2389     free_busy: Option<CalendarEventFreeBusy>,
   2390     author_hint: Option<CalendarEventAuthorReference>,
   2391     note: Option<String>,
   2392 }
   2393 
   2394 impl AuthoredCalendarEventRsvp {
   2395     pub fn new(
   2396         uid: CalendarUid,
   2397         event_reference: CalendarEventReference,
   2398         status: CalendarEventRsvpStatus,
   2399     ) -> Result<Self, CalendarEventError> {
   2400         if !event_reference.is_canonical() {
   2401             return Err(CalendarEventError::InvalidEventReference);
   2402         }
   2403         let authored = Self {
   2404             uid,
   2405             event_reference,
   2406             revision_reference: None,
   2407             status,
   2408             free_busy: None,
   2409             author_hint: None,
   2410             note: None,
   2411         };
   2412         authored.validate_budget()?;
   2413         Ok(authored)
   2414     }
   2415 
   2416     pub fn with_revision_reference(
   2417         mut self,
   2418         revision_reference: CalendarEventRevisionReference,
   2419     ) -> Result<Self, CalendarEventError> {
   2420         if !revision_reference.is_canonical() {
   2421             return Err(CalendarEventError::InvalidRevisionReference);
   2422         }
   2423         self.revision_reference = Some(revision_reference);
   2424         self.validate_budget()?;
   2425         Ok(self)
   2426     }
   2427 
   2428     pub fn with_free_busy(
   2429         mut self,
   2430         free_busy: CalendarEventFreeBusy,
   2431     ) -> Result<Self, CalendarEventError> {
   2432         if self.status == CalendarEventRsvpStatus::Declined {
   2433             return Err(CalendarEventError::DeclinedFreeBusyForbidden);
   2434         }
   2435         self.free_busy = Some(free_busy);
   2436         self.validate_budget()?;
   2437         Ok(self)
   2438     }
   2439 
   2440     pub fn with_author_hint(
   2441         mut self,
   2442         author_hint: CalendarEventAuthorReference,
   2443     ) -> Result<Self, CalendarEventError> {
   2444         validate_strict_calendar_author_hint(&self.event_reference, &author_hint)?;
   2445         self.author_hint = Some(author_hint);
   2446         self.validate_budget()?;
   2447         Ok(self)
   2448     }
   2449 
   2450     pub fn with_note(mut self, note: impl Into<String>) -> Result<Self, CalendarEventError> {
   2451         let note = note.into();
   2452         validate_calendar_content(&note)?;
   2453         self.note = (!note.is_empty()).then_some(note);
   2454         self.validate_budget()?;
   2455         Ok(self)
   2456     }
   2457 
   2458     pub fn uid(&self) -> &CalendarUid {
   2459         &self.uid
   2460     }
   2461 
   2462     pub fn event_reference(&self) -> &CalendarEventReference {
   2463         &self.event_reference
   2464     }
   2465 
   2466     pub fn revision_reference(&self) -> Option<&CalendarEventRevisionReference> {
   2467         self.revision_reference.as_ref()
   2468     }
   2469 
   2470     pub fn status(&self) -> &CalendarEventRsvpStatus {
   2471         &self.status
   2472     }
   2473 
   2474     pub fn observed_free_busy(&self) -> Option<&CalendarEventFreeBusy> {
   2475         self.free_busy.as_ref()
   2476     }
   2477 
   2478     pub fn effective_free_busy(&self) -> Option<&CalendarEventFreeBusy> {
   2479         self.free_busy.as_ref()
   2480     }
   2481 
   2482     pub fn author_hint(&self) -> Option<&CalendarEventAuthorReference> {
   2483         self.author_hint.as_ref()
   2484     }
   2485 
   2486     pub fn note(&self) -> Option<&str> {
   2487         self.note.as_deref()
   2488     }
   2489 
   2490     fn validate_budget(&self) -> Result<(), CalendarEventError> {
   2491         validate_calendar_rsvp_budget(
   2492             self.uid.as_str(),
   2493             &self.event_reference,
   2494             self.revision_reference.as_ref(),
   2495             &self.status,
   2496             self.free_busy.as_ref(),
   2497             self.author_hint.as_ref(),
   2498             self.note.as_deref(),
   2499         )
   2500     }
   2501 }
   2502 
   2503 /// Constructor input for the tolerant, raw-preserving kind-31925 parse layer.
   2504 #[derive(Clone, Debug, PartialEq, Eq)]
   2505 pub struct ParsedNip52CalendarEventRsvpParts {
   2506     pub d_tag: String,
   2507     pub event_reference: CalendarEventReference,
   2508     pub revision_reference: Option<CalendarEventRevisionReference>,
   2509     pub status: CalendarEventRsvpStatus,
   2510     pub observed_free_busy: Option<CalendarEventFreeBusy>,
   2511     pub author_hint: Option<CalendarEventAuthorReference>,
   2512     pub note: Option<String>,
   2513 }
   2514 
   2515 /// Structurally valid kind-31925 data. Canonical admission is a separate step.
   2516 #[derive(Clone, Debug, PartialEq, Eq)]
   2517 pub struct ParsedNip52CalendarEventRsvp {
   2518     d_tag: String,
   2519     event_reference: CalendarEventReference,
   2520     revision_reference: Option<CalendarEventRevisionReference>,
   2521     status: CalendarEventRsvpStatus,
   2522     observed_free_busy: Option<CalendarEventFreeBusy>,
   2523     author_hint: Option<CalendarEventAuthorReference>,
   2524     note: Option<String>,
   2525 }
   2526 
   2527 impl ParsedNip52CalendarEventRsvp {
   2528     pub fn try_new(parts: ParsedNip52CalendarEventRsvpParts) -> Result<Self, CalendarEventError> {
   2529         validate_calendar_tag_text(&parts.d_tag, "d")?;
   2530         if let Some(note) = parts.note.as_deref() {
   2531             validate_calendar_content(note)?;
   2532         }
   2533         validate_calendar_rsvp_budget(
   2534             &parts.d_tag,
   2535             &parts.event_reference,
   2536             parts.revision_reference.as_ref(),
   2537             &parts.status,
   2538             parts.observed_free_busy.as_ref(),
   2539             parts.author_hint.as_ref(),
   2540             parts.note.as_deref(),
   2541         )?;
   2542         Ok(Self {
   2543             d_tag: parts.d_tag,
   2544             event_reference: parts.event_reference,
   2545             revision_reference: parts.revision_reference,
   2546             status: parts.status,
   2547             observed_free_busy: parts.observed_free_busy,
   2548             author_hint: parts.author_hint,
   2549             note: parts.note,
   2550         })
   2551     }
   2552 
   2553     pub fn d_tag(&self) -> &str {
   2554         &self.d_tag
   2555     }
   2556 
   2557     pub fn event_reference(&self) -> &CalendarEventReference {
   2558         &self.event_reference
   2559     }
   2560 
   2561     pub fn revision_reference(&self) -> Option<&CalendarEventRevisionReference> {
   2562         self.revision_reference.as_ref()
   2563     }
   2564 
   2565     pub fn status(&self) -> &CalendarEventRsvpStatus {
   2566         &self.status
   2567     }
   2568 
   2569     pub fn observed_free_busy(&self) -> Option<&CalendarEventFreeBusy> {
   2570         self.observed_free_busy.as_ref()
   2571     }
   2572 
   2573     pub fn effective_free_busy(&self) -> Option<&CalendarEventFreeBusy> {
   2574         if self.status == CalendarEventRsvpStatus::Declined {
   2575             None
   2576         } else {
   2577             self.observed_free_busy.as_ref()
   2578         }
   2579     }
   2580 
   2581     pub fn author_hint(&self) -> Option<&CalendarEventAuthorReference> {
   2582         self.author_hint.as_ref()
   2583     }
   2584 
   2585     pub fn note(&self) -> Option<&str> {
   2586         self.note.as_deref()
   2587     }
   2588 }
   2589 
   2590 /// Canonical Radroots admission of a parsed kind-31925 RSVP.
   2591 #[derive(Clone, Debug, PartialEq, Eq)]
   2592 pub struct AdmittedCalendarEventRsvp {
   2593     parsed: ParsedNip52CalendarEventRsvp,
   2594     uid: CalendarUid,
   2595 }
   2596 
   2597 impl AdmittedCalendarEventRsvp {
   2598     pub fn try_from_parsed(
   2599         parsed: ParsedNip52CalendarEventRsvp,
   2600     ) -> Result<Self, CalendarAdmissionError> {
   2601         let uid = CalendarUid::parse(parsed.d_tag())
   2602             .map_err(|_| CalendarAdmissionError::NonCanonicalField("d"))?;
   2603         if !parsed.event_reference().is_canonical() {
   2604             return Err(CalendarAdmissionError::NonCanonicalField("event_reference"));
   2605         }
   2606         if parsed
   2607             .revision_reference()
   2608             .is_some_and(|reference| !reference.is_canonical())
   2609         {
   2610             return Err(CalendarAdmissionError::NonCanonicalField(
   2611                 "revision_reference",
   2612             ));
   2613         }
   2614         if let Some(author_hint) = parsed.author_hint() {
   2615             if !author_hint.is_canonical() {
   2616                 return Err(CalendarAdmissionError::NonCanonicalField("author_hint"));
   2617             }
   2618             if author_hint.pubkey() != parsed.event_reference().author() {
   2619                 return Err(CalendarAdmissionError::AuthorHintMismatch);
   2620             }
   2621         }
   2622         Ok(Self { parsed, uid })
   2623     }
   2624 
   2625     pub fn parsed(&self) -> &ParsedNip52CalendarEventRsvp {
   2626         &self.parsed
   2627     }
   2628 
   2629     pub fn uid(&self) -> &CalendarUid {
   2630         &self.uid
   2631     }
   2632 
   2633     pub fn event_reference(&self) -> &CalendarEventReference {
   2634         self.parsed.event_reference()
   2635     }
   2636 
   2637     pub fn revision_reference(&self) -> Option<&CalendarEventRevisionReference> {
   2638         self.parsed.revision_reference()
   2639     }
   2640 
   2641     pub fn status(&self) -> &CalendarEventRsvpStatus {
   2642         self.parsed.status()
   2643     }
   2644 
   2645     pub fn observed_free_busy(&self) -> Option<&CalendarEventFreeBusy> {
   2646         self.parsed.observed_free_busy()
   2647     }
   2648 
   2649     pub fn effective_free_busy(&self) -> Option<&CalendarEventFreeBusy> {
   2650         self.parsed.effective_free_busy()
   2651     }
   2652 
   2653     pub fn author_hint(&self) -> Option<&CalendarEventAuthorReference> {
   2654         self.parsed.author_hint()
   2655     }
   2656 
   2657     pub fn note(&self) -> Option<&str> {
   2658         self.parsed.note()
   2659     }
   2660 }
   2661 
   2662 #[cfg(test)]
   2663 #[cfg_attr(coverage_nightly, coverage(off))]
   2664 mod tests {
   2665     use super::*;
   2666     use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256};
   2667 
   2668     #[test]
   2669     fn authored_date_event_validates_each_optional_field_at_construction() {
   2670         let event = AuthoredCalendarDateEvent::new(
   2671             "market-day",
   2672             "Market day",
   2673             CalendarDate::parse("2026-06-20").unwrap(),
   2674         )
   2675         .unwrap()
   2676         .with_description("Farm stand pickup window.")
   2677         .unwrap()
   2678         .with_locations(vec!["Farm stand".to_string()])
   2679         .unwrap()
   2680         .with_geohash("c23nb62w20st")
   2681         .unwrap()
   2682         .with_summary("Weekly pickup")
   2683         .unwrap()
   2684         .with_categories(vec!["farmers-market".to_string(), "vegetables".to_string()])
   2685         .unwrap();
   2686 
   2687         assert_eq!(event.d_tag().as_str(), "market-day");
   2688         assert_eq!(event.start().as_str(), "2026-06-20");
   2689         assert_eq!(event.summary(), Some("Weekly pickup"));
   2690         assert_eq!(event.categories(), ["farmers-market", "vegetables"]);
   2691 
   2692         let error = AuthoredCalendarDateEvent::new(
   2693             "market-day",
   2694             " Market day ",
   2695             CalendarDate::parse("2026-06-20").unwrap(),
   2696         )
   2697         .unwrap_err();
   2698         assert_eq!(error, CalendarEventError::InvalidTitle);
   2699 
   2700         let error = AuthoredCalendarDateEvent::new(
   2701             "market-day",
   2702             "Market day",
   2703             CalendarDate::parse("2026-06-20").unwrap(),
   2704         )
   2705         .unwrap()
   2706         .with_geohash("C23NB62W20ST")
   2707         .unwrap_err();
   2708         assert_eq!(error, CalendarEventError::InvalidGeohash);
   2709     }
   2710 
   2711     #[test]
   2712     fn authored_time_event_uses_exact_iana_ids_and_end_timezone_fallback() {
   2713         assert_eq!(jiff_tzdb::VERSION, Some("2026c"));
   2714 
   2715         let event = AuthoredCalendarTimeEvent::new("wash-pack", "Wash pack shift", 1_781_895_600)
   2716             .unwrap()
   2717             .with_end(1_781_899_200)
   2718             .unwrap()
   2719             .with_description("Pack CSA shares before pickup.")
   2720             .unwrap()
   2721             .with_start_tzid("America/Vancouver")
   2722             .unwrap()
   2723             .with_participants(vec![CalendarParticipant {
   2724                 pubkey: "a".repeat(64),
   2725                 relay: None,
   2726                 role: Some("host".to_string()),
   2727             }])
   2728             .unwrap();
   2729 
   2730         assert_eq!(event.start(), 1_781_895_600);
   2731         assert_eq!(event.end(), Some(1_781_899_200));
   2732         assert_eq!(
   2733             event.start_tzid().map(IanaTimeZoneId::as_str),
   2734             Some("America/Vancouver")
   2735         );
   2736         assert_eq!(event.end_tzid(), None);
   2737         assert_eq!(
   2738             event.effective_end_tzid().map(IanaTimeZoneId::as_str),
   2739             Some("America/Vancouver")
   2740         );
   2741         assert_eq!(event.participants().map(Vec::len), Some(1));
   2742 
   2743         for invalid in ["america/vancouver", "America/Imaginary", " UTC ", ""] {
   2744             assert_eq!(
   2745                 IanaTimeZoneId::parse(invalid),
   2746                 Err(CalendarEventError::InvalidTimeZone),
   2747                 "{invalid}"
   2748             );
   2749         }
   2750     }
   2751 
   2752     #[test]
   2753     fn calendar_dates_validate_gregorian_semantics_and_strict_ranges() {
   2754         for valid in ["0001-01-01", "2000-02-29", "2024-02-29", "9999-12-31"] {
   2755             assert_eq!(CalendarDate::parse(valid).unwrap().as_str(), valid);
   2756         }
   2757         for invalid in [
   2758             "0000-01-01",
   2759             "1900-02-29",
   2760             "2026-02-29",
   2761             "2026-02-30",
   2762             "2026-13-01",
   2763             "2026-00-01",
   2764             "2026-06-00",
   2765             "2026-6-20",
   2766             "+2026-06-20",
   2767             "2026/06-20",
   2768             "2026-06/20",
   2769         ] {
   2770             assert_eq!(
   2771                 CalendarDate::parse(invalid),
   2772                 Err(CalendarEventError::InvalidDate),
   2773                 "{invalid}"
   2774             );
   2775         }
   2776 
   2777         let start = CalendarDate::parse("2026-06-20").unwrap();
   2778         let event = AuthoredCalendarDateEvent::new("market-day", "Market", start.clone()).unwrap();
   2779         assert_eq!(
   2780             event
   2781                 .clone()
   2782                 .with_end(CalendarDate::parse("2026-06-20").unwrap()),
   2783             Err(CalendarEventError::InvalidRange)
   2784         );
   2785         assert_eq!(
   2786             event.with_end(CalendarDate::parse("2026-06-19").unwrap()),
   2787             Err(CalendarEventError::InvalidRange)
   2788         );
   2789     }
   2790 
   2791     #[test]
   2792     fn covered_utc_days_are_exclusive_end_and_bounded() {
   2793         assert_eq!(
   2794             covered_utc_days(86_399, None).unwrap().collect::<Vec<_>>(),
   2795             vec![0]
   2796         );
   2797         assert_eq!(
   2798             covered_utc_days(86_399, Some(86_401))
   2799                 .unwrap()
   2800                 .collect::<Vec<_>>(),
   2801             vec![0, 1]
   2802         );
   2803         assert_eq!(
   2804             covered_utc_days(0, Some(86_400))
   2805                 .unwrap()
   2806                 .collect::<Vec<_>>(),
   2807             vec![0]
   2808         );
   2809         assert_eq!(
   2810             covered_utc_days(10, Some(10)),
   2811             Err(CalendarEventError::InvalidRange)
   2812         );
   2813         assert_eq!(
   2814             covered_utc_days(10, Some(9)),
   2815             Err(CalendarEventError::InvalidRange)
   2816         );
   2817         assert_eq!(
   2818             covered_utc_days(0, Some(367 * RADROOTS_CALENDAR_SECONDS_PER_DAY)),
   2819             Err(CalendarEventError::CoveredDayLimitExceeded {
   2820                 max: RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS,
   2821                 actual: 367,
   2822             })
   2823         );
   2824     }
   2825 
   2826     #[test]
   2827     fn baseline_wire_types_preserve_noncanonical_but_structural_values() {
   2828         let uri = CalendarUri::parse("ipfs://bafybeigdyrzt/calendar.webp").unwrap();
   2829         assert_eq!(uri.as_str(), "ipfs://bafybeigdyrzt/calendar.webp");
   2830 
   2831         let observed = ObservedUtcDay::parse("00042").unwrap();
   2832         assert_eq!(observed.wire_value(), "00042");
   2833         assert_eq!(observed.index(), 42);
   2834         assert!(!observed.is_canonical());
   2835 
   2836         assert_eq!(
   2837             CalendarUri::parse("not an absolute URI"),
   2838             Err(CalendarEventError::InvalidUrl("URI"))
   2839         );
   2840         assert_eq!(
   2841             ObservedUtcDay::parse("+42"),
   2842             Err(CalendarEventError::InvalidText("D"))
   2843         );
   2844     }
   2845 
   2846     #[test]
   2847     fn authored_models_enforce_content_and_participant_limits() {
   2848         let event = AuthoredCalendarDateEvent::new(
   2849             "market-day",
   2850             "Market day",
   2851             CalendarDate::parse("2026-06-20").unwrap(),
   2852         )
   2853         .unwrap();
   2854         let oversized = "x".repeat(DEFAULT_CONTENT_MAX_BYTES + 1);
   2855         assert_eq!(
   2856             event.clone().with_description(oversized),
   2857             Err(CalendarEventError::ContentTooLarge {
   2858                 max: DEFAULT_CONTENT_MAX_BYTES,
   2859                 actual: DEFAULT_CONTENT_MAX_BYTES + 1,
   2860             })
   2861         );
   2862 
   2863         let participant = CalendarParticipant {
   2864             pubkey: "a".repeat(64),
   2865             relay: None,
   2866             role: None,
   2867         };
   2868         assert_eq!(
   2869             event.with_participants(vec![participant; RADROOTS_CALENDAR_MAX_PARTICIPANTS + 1]),
   2870             Err(CalendarEventError::TooManyParticipants {
   2871                 max: RADROOTS_CALENDAR_MAX_PARTICIPANTS,
   2872                 actual: RADROOTS_CALENDAR_MAX_PARTICIPANTS + 1,
   2873             })
   2874         );
   2875     }
   2876 
   2877     #[test]
   2878     fn calendar_uid_requires_exact_unpadded_128_bit_base64url_shape() {
   2879         for valid in [
   2880             "AAAAAAAAAAAAAAAAAAAAAA",
   2881             "AAAAAAAAAAAAAAAAAAAAAQ",
   2882             "_____________________w",
   2883         ] {
   2884             assert_eq!(CalendarUid::parse(valid).unwrap().as_str(), valid);
   2885         }
   2886         for invalid in [
   2887             "AAAAAAAAAAAAAAAAAAAAA",
   2888             "AAAAAAAAAAAAAAAAAAAAAAA",
   2889             "AAAAAAAAAAAAAAAAAAAAA=",
   2890             "AAAAAAAAAAAAAAAAAAAAA+",
   2891             "AAAAAAAAAAAAAAAAAAAAAB",
   2892         ] {
   2893             assert_eq!(
   2894                 CalendarUid::parse(invalid),
   2895                 Err(CalendarEventError::InvalidCalendarUid),
   2896                 "{invalid}"
   2897             );
   2898         }
   2899     }
   2900 
   2901     #[test]
   2902     fn calendar_references_preserve_structural_wire_values_and_expose_canonicality() {
   2903         let uppercase_author = "A".repeat(64);
   2904         let event = CalendarEventReference::parse(
   2905             format!("031923:{uppercase_author}:wash-pack"),
   2906             Some("WSS://Relay.Example/events"),
   2907         )
   2908         .unwrap();
   2909         assert_eq!(
   2910             event.coordinate().as_str(),
   2911             format!("031923:{uppercase_author}:wash-pack")
   2912         );
   2913         assert_eq!(event.author().to_hex(), "a".repeat(64));
   2914         assert_eq!(event.relay(), Some("WSS://Relay.Example/events"));
   2915         assert!(!event.is_canonical());
   2916 
   2917         let revision =
   2918             CalendarEventRevisionReference::parse("B".repeat(64), Some("WSS://revision.example"))
   2919                 .unwrap();
   2920         assert_eq!(revision.raw_event_id(), "B".repeat(64));
   2921         assert_eq!(revision.event_id().to_hex(), "b".repeat(64));
   2922         assert!(!revision.is_canonical());
   2923 
   2924         let author = CalendarEventAuthorReference::parse(
   2925             uppercase_author.clone(),
   2926             Some("WSS://author.example"),
   2927         )
   2928         .unwrap();
   2929         assert_eq!(author.raw_pubkey(), uppercase_author);
   2930         assert_eq!(author.pubkey().to_hex(), "a".repeat(64));
   2931         assert!(!author.is_canonical());
   2932 
   2933         assert_eq!(
   2934             CalendarEventReference::parse(format!("31924:{}:not-an-event", "a".repeat(64)), None,),
   2935             Err(CalendarEventError::InvalidEventReference)
   2936         );
   2937     }
   2938 
   2939     #[test]
   2940     fn calendar_collection_layers_keep_baseline_tolerance_out_of_strict_states() {
   2941         let uid = CalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAA").unwrap();
   2942         let event = canonical_event_reference("wash-pack");
   2943 
   2944         let empty = AuthoredCalendar::new(
   2945             uid.clone(),
   2946             "Farm calendar",
   2947             "Shared farm operations schedule.",
   2948             Vec::new(),
   2949         )
   2950         .unwrap();
   2951         assert!(empty.event_references().is_empty());
   2952 
   2953         assert_eq!(
   2954             AuthoredCalendar::new(
   2955                 uid.clone(),
   2956                 "Farm calendar",
   2957                 "",
   2958                 vec![event.clone(), event.clone()],
   2959             ),
   2960             Err(CalendarEventError::DuplicateEventReference)
   2961         );
   2962 
   2963         let noncanonical =
   2964             CalendarEventReference::parse(format!("031923:{}:wash-pack", "A".repeat(64)), None)
   2965                 .unwrap();
   2966         assert_eq!(
   2967             AuthoredCalendar::new(uid.clone(), "Farm calendar", "", vec![noncanonical],),
   2968             Err(CalendarEventError::InvalidEventReference)
   2969         );
   2970 
   2971         let parsed = ParsedNip52Calendar::try_new(ParsedNip52CalendarParts {
   2972             d_tag: uid.to_string(),
   2973             title: " Farm calendar ".to_string(),
   2974             content: "Shared farm operations schedule.".to_string(),
   2975             event_references: vec![event.clone(), event],
   2976             list_description: Some(" Local operations ".to_string()),
   2977             image: None,
   2978         })
   2979         .unwrap();
   2980         assert_eq!(parsed.event_references().len(), 2);
   2981         assert_eq!(
   2982             AdmittedCalendar::try_from_parsed(parsed),
   2983             Err(CalendarAdmissionError::NonCanonicalField("title"))
   2984         );
   2985 
   2986         let parsed = ParsedNip52Calendar::try_new(ParsedNip52CalendarParts {
   2987             d_tag: uid.to_string(),
   2988             title: "Farm calendar".to_string(),
   2989             content: String::new(),
   2990             event_references: Vec::new(),
   2991             list_description: None,
   2992             image: None,
   2993         })
   2994         .unwrap();
   2995         assert!(
   2996             AdmittedCalendar::try_from_parsed(parsed)
   2997                 .unwrap()
   2998                 .event_references()
   2999                 .is_empty()
   3000         );
   3001     }
   3002 
   3003     #[test]
   3004     fn calendar_admission_requires_unique_refs_and_structural_blossom_images() {
   3005         let uid = "AAAAAAAAAAAAAAAAAAAAAA";
   3006         let event = canonical_event_reference("market-day");
   3007         let duplicate = ParsedNip52Calendar::try_new(ParsedNip52CalendarParts {
   3008             d_tag: uid.to_string(),
   3009             title: "Market calendar".to_string(),
   3010             content: String::new(),
   3011             event_references: vec![event.clone(), event.clone()],
   3012             list_description: None,
   3013             image: None,
   3014         })
   3015         .unwrap();
   3016         let error = AdmittedCalendar::try_from_parsed(duplicate).unwrap_err();
   3017         assert_eq!(error, CalendarAdmissionError::DuplicateEventReference);
   3018         assert_eq!(error.code(), "duplicate_event_reference");
   3019 
   3020         let ordinary_image = ParsedNip52Calendar::try_new(ParsedNip52CalendarParts {
   3021             d_tag: uid.to_string(),
   3022             title: "Market calendar".to_string(),
   3023             content: String::new(),
   3024             event_references: vec![event.clone()],
   3025             list_description: None,
   3026             image: Some(CalendarUri::parse("https://media.example/market.webp").unwrap()),
   3027         })
   3028         .unwrap();
   3029         assert_eq!(
   3030             AdmittedCalendar::try_from_parsed(ordinary_image),
   3031             Err(CalendarAdmissionError::NonBlossomImage)
   3032         );
   3033 
   3034         let blossom_url = format!("https://media.example/{}.webp", "c".repeat(64));
   3035         let blossom = ParsedNip52Calendar::try_new(ParsedNip52CalendarParts {
   3036             d_tag: uid.to_string(),
   3037             title: "Market calendar".to_string(),
   3038             content: String::new(),
   3039             event_references: vec![event],
   3040             list_description: Some("Victoria farm markets".to_string()),
   3041             image: Some(CalendarUri::parse(&blossom_url).unwrap()),
   3042         })
   3043         .unwrap();
   3044         assert_eq!(
   3045             AdmittedCalendar::try_from_parsed(blossom)
   3046                 .unwrap()
   3047                 .blossom_image()
   3048                 .unwrap()
   3049                 .as_str(),
   3050             blossom_url
   3051         );
   3052     }
   3053 
   3054     #[test]
   3055     fn calendar_collection_enforces_wire_tag_budgets() {
   3056         let references = (0..DEFAULT_TAG_MAX_COUNT)
   3057             .map(|index| canonical_event_reference(&format!("event-{index}")))
   3058             .collect::<Vec<_>>();
   3059         assert_eq!(
   3060             AuthoredCalendar::new(
   3061                 CalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAA").unwrap(),
   3062                 "Farm calendar",
   3063                 "",
   3064                 references,
   3065             ),
   3066             Err(CalendarEventError::TagCountExceeded {
   3067                 max: DEFAULT_TAG_MAX_COUNT,
   3068                 actual: DEFAULT_TAG_MAX_COUNT + 2,
   3069             })
   3070         );
   3071     }
   3072 
   3073     #[test]
   3074     fn declined_rsvp_retains_observed_fb_only_in_parsed_and_admitted_layers() {
   3075         let uid = CalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAQ").unwrap();
   3076         let event = canonical_event_reference("wash-pack");
   3077         assert_eq!(
   3078             AuthoredCalendarEventRsvp::new(
   3079                 uid.clone(),
   3080                 event.clone(),
   3081                 CalendarEventRsvpStatus::Declined,
   3082             )
   3083             .unwrap()
   3084             .with_free_busy(CalendarEventFreeBusy::Busy),
   3085             Err(CalendarEventError::DeclinedFreeBusyForbidden)
   3086         );
   3087 
   3088         let parsed = ParsedNip52CalendarEventRsvp::try_new(ParsedNip52CalendarEventRsvpParts {
   3089             d_tag: uid.to_string(),
   3090             event_reference: event,
   3091             revision_reference: Some(
   3092                 CalendarEventRevisionReference::parse(
   3093                     "b".repeat(64),
   3094                     Some("wss://revision.example"),
   3095                 )
   3096                 .unwrap(),
   3097             ),
   3098             status: CalendarEventRsvpStatus::Declined,
   3099             observed_free_busy: Some(CalendarEventFreeBusy::Busy),
   3100             author_hint: Some(
   3101                 CalendarEventAuthorReference::parse("a".repeat(64), Some("wss://author.example"))
   3102                     .unwrap(),
   3103             ),
   3104             note: Some("Unable to attend".to_string()),
   3105         })
   3106         .unwrap();
   3107         assert_eq!(
   3108             parsed.observed_free_busy(),
   3109             Some(&CalendarEventFreeBusy::Busy)
   3110         );
   3111         assert_eq!(parsed.effective_free_busy(), None);
   3112 
   3113         let admitted = AdmittedCalendarEventRsvp::try_from_parsed(parsed).unwrap();
   3114         assert_eq!(
   3115             admitted.observed_free_busy(),
   3116             Some(&CalendarEventFreeBusy::Busy)
   3117         );
   3118         assert_eq!(admitted.effective_free_busy(), None);
   3119         assert_eq!(
   3120             admitted.revision_reference().unwrap().relay(),
   3121             Some("wss://revision.example")
   3122         );
   3123         assert_eq!(
   3124             admitted.author_hint().unwrap().relay(),
   3125             Some("wss://author.example")
   3126         );
   3127     }
   3128 
   3129     #[test]
   3130     fn rsvp_author_hint_must_match_event_coordinate_in_strict_layers() {
   3131         let uid = CalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAQ").unwrap();
   3132         let event = canonical_event_reference("wash-pack");
   3133         let mismatched =
   3134             CalendarEventAuthorReference::parse(crate::test_valid_hex_64('b'), None).unwrap();
   3135         assert_eq!(
   3136             AuthoredCalendarEventRsvp::new(
   3137                 uid.clone(),
   3138                 event.clone(),
   3139                 CalendarEventRsvpStatus::Accepted,
   3140             )
   3141             .unwrap()
   3142             .with_author_hint(mismatched.clone()),
   3143             Err(CalendarEventError::AuthorHintMismatch)
   3144         );
   3145 
   3146         let parsed = ParsedNip52CalendarEventRsvp::try_new(ParsedNip52CalendarEventRsvpParts {
   3147             d_tag: uid.to_string(),
   3148             event_reference: event,
   3149             revision_reference: None,
   3150             status: CalendarEventRsvpStatus::Accepted,
   3151             observed_free_busy: None,
   3152             author_hint: Some(mismatched),
   3153             note: None,
   3154         })
   3155         .unwrap();
   3156         let error = AdmittedCalendarEventRsvp::try_from_parsed(parsed).unwrap_err();
   3157         assert_eq!(error, CalendarAdmissionError::AuthorHintMismatch);
   3158         assert_eq!(error.code(), "author_hint_mismatch");
   3159     }
   3160 
   3161     #[test]
   3162     fn calendar_value_types_cover_conversion_serialization_and_error_contracts() {
   3163         let uid = CalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAQ").unwrap();
   3164         assert_eq!(uid.as_ref(), uid.as_str());
   3165         assert_eq!(uid.to_string(), uid.as_str());
   3166         assert_eq!(CalendarUid::from_str(uid.as_str()).unwrap(), uid);
   3167         assert_eq!(CalendarUid::try_from(uid.as_str()).unwrap(), uid);
   3168         assert_eq!(CalendarUid::try_from(uid.to_string()).unwrap(), uid);
   3169         assert_eq!(serde_json::to_string(&uid).unwrap(), format!("\"{uid}\""));
   3170 
   3171         let date = CalendarDate::from_str("2028-02-29").unwrap();
   3172         assert_eq!(date.as_ref(), "2028-02-29");
   3173         assert_eq!(date.to_string(), "2028-02-29");
   3174         assert_eq!(serde_json::to_string(&date).unwrap(), "\"2028-02-29\"");
   3175         assert_eq!(
   3176             serde_json::from_str::<CalendarDate>("\"2028-02-29\"").unwrap(),
   3177             date
   3178         );
   3179 
   3180         let zone = IanaTimeZoneId::from_str("UTC").unwrap();
   3181         assert_eq!(zone.as_ref(), "UTC");
   3182         assert_eq!(zone.to_string(), "UTC");
   3183         assert_eq!(serde_json::to_string(&zone).unwrap(), "\"UTC\"");
   3184         assert_eq!(
   3185             serde_json::from_str::<IanaTimeZoneId>("\"UTC\"").unwrap(),
   3186             zone
   3187         );
   3188 
   3189         let uri = CalendarUri::from_str("https://example.com/calendar").unwrap();
   3190         assert_eq!(uri.as_ref(), uri.as_str());
   3191         assert_eq!(uri.to_string(), uri.as_str());
   3192         assert_eq!(
   3193             serde_json::from_str::<CalendarUri>(&serde_json::to_string(&uri).unwrap()).unwrap(),
   3194             uri
   3195         );
   3196 
   3197         let event_errors = [
   3198             CalendarEventError::InvalidIdentifier,
   3199             CalendarEventError::InvalidCalendarUid,
   3200             CalendarEventError::InvalidEventReference,
   3201             CalendarEventError::InvalidRevisionReference,
   3202             CalendarEventError::InvalidAuthorReference,
   3203             CalendarEventError::DuplicateEventReference,
   3204             CalendarEventError::AuthorHintMismatch,
   3205             CalendarEventError::DeclinedFreeBusyForbidden,
   3206             CalendarEventError::InvalidTitle,
   3207             CalendarEventError::InvalidText("field"),
   3208             CalendarEventError::InvalidUrl("field"),
   3209             CalendarEventError::InvalidGeohash,
   3210             CalendarEventError::InvalidTimeZone,
   3211             CalendarEventError::InvalidParticipant { index: 2 },
   3212             CalendarEventError::TooManyParticipants { max: 1, actual: 2 },
   3213             CalendarEventError::ContentTooLarge { max: 1, actual: 2 },
   3214             CalendarEventError::TagElementTooLarge {
   3215                 field: "x",
   3216                 max: 1,
   3217                 actual: 2,
   3218             },
   3219             CalendarEventError::TagCountExceeded { max: 1, actual: 2 },
   3220             CalendarEventError::TagBytesExceeded { max: 1, actual: 2 },
   3221             CalendarEventError::InvalidDate,
   3222             CalendarEventError::InvalidRange,
   3223             CalendarEventError::CoveredDayLimitExceeded { max: 1, actual: 2 },
   3224         ];
   3225         for error in event_errors {
   3226             assert!(!error.code().is_empty());
   3227             assert!(!error.to_string().is_empty());
   3228         }
   3229         let admission_errors = [
   3230             CalendarAdmissionError::NonCanonicalField("field"),
   3231             CalendarAdmissionError::DuplicateEventReference,
   3232             CalendarAdmissionError::AuthorHintMismatch,
   3233             CalendarAdmissionError::ForbiddenDateDayIndex,
   3234             CalendarAdmissionError::IncompleteDayCoverage,
   3235             CalendarAdmissionError::CoveredDayLimitExceeded { max: 1, actual: 2 },
   3236             CalendarAdmissionError::NonBlossomImage,
   3237         ];
   3238         for error in admission_errors {
   3239             assert!(!error.code().is_empty());
   3240             assert!(!error.to_string().is_empty());
   3241         }
   3242     }
   3243 
   3244     #[test]
   3245     fn calendar_references_and_requests_cover_canonical_accessors_and_rejections() {
   3246         let event = canonical_event_reference("market");
   3247         assert_eq!(
   3248             event.kind(),
   3249             crate::envelope::kind::KIND_CALENDAR_TIME_EVENT
   3250         );
   3251         assert_eq!(event.d_tag().as_str(), "market");
   3252         assert!(event.is_canonical());
   3253         assert_eq!(event.relay(), Some("wss://relay.example"));
   3254 
   3255         let revision = CalendarEventRevisionReference::parse("b".repeat(64), None).unwrap();
   3256         assert_eq!(revision.raw_event_id(), revision.event_id().to_hex());
   3257         assert_eq!(revision.relay(), None);
   3258         assert!(revision.is_canonical());
   3259         let author = CalendarEventAuthorReference::parse("a".repeat(64), None).unwrap();
   3260         assert_eq!(author.raw_pubkey(), author.pubkey().to_hex());
   3261         assert_eq!(author.relay(), None);
   3262         assert!(author.is_canonical());
   3263 
   3264         let coordinate = format!("31924:{}:calendar", "a".repeat(64));
   3265         let request = CalendarRequest::new(&coordinate, Some("wss://relay.example")).unwrap();
   3266         assert_eq!(request.calendar().as_str(), coordinate);
   3267         assert_eq!(request.relay(), Some("wss://relay.example"));
   3268         assert!(request.is_canonical());
   3269         assert!(CalendarRequest::new("bad", None).is_err());
   3270         assert!(CalendarRequest::new(format!("31923:{}:event", "a".repeat(64)), None).is_err());
   3271         assert!(CalendarRequest::new(&coordinate, Some("https://relay.example")).is_err());
   3272         assert!(CalendarEventReference::parse("bad", None).is_err());
   3273         assert!(CalendarEventRevisionReference::parse("bad", None).is_err());
   3274         assert!(CalendarEventAuthorReference::parse("bad", None).is_err());
   3275     }
   3276 
   3277     #[test]
   3278     fn full_authored_calendar_models_expose_every_validated_field() {
   3279         let uid = CalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAQ").unwrap();
   3280         let reference = canonical_event_reference("market");
   3281         let image = calendar_image();
   3282         let calendar = AuthoredCalendar::new(
   3283             uid.clone(),
   3284             "Farm calendar",
   3285             "Fresh food",
   3286             vec![reference.clone()],
   3287         )
   3288         .unwrap()
   3289         .with_list_description("Weekly calendar")
   3290         .unwrap()
   3291         .with_image(image.clone())
   3292         .unwrap();
   3293         assert_eq!(calendar.uid(), &uid);
   3294         assert_eq!(calendar.title(), "Farm calendar");
   3295         assert_eq!(calendar.content(), "Fresh food");
   3296         assert_eq!(calendar.event_references(), &[reference]);
   3297         assert_eq!(calendar.list_description(), Some("Weekly calendar"));
   3298         assert!(calendar.image().is_some());
   3299 
   3300         let request = CalendarRequest::new(
   3301             format!("31924:{}:calendar", "a".repeat(64)),
   3302             Some("wss://relay.example"),
   3303         )
   3304         .unwrap();
   3305         let participant = CalendarParticipant {
   3306             pubkey: "a".repeat(64),
   3307             relay: Some("wss://relay.example".into()),
   3308             role: Some("host".into()),
   3309         };
   3310         let uri = CalendarUri::parse("https://example.com/details").unwrap();
   3311         let date = AuthoredCalendarDateEvent::new(
   3312             "market",
   3313             "Market",
   3314             CalendarDate::parse("2026-06-20").unwrap(),
   3315         )
   3316         .unwrap()
   3317         .with_end(CalendarDate::parse("2026-06-21").unwrap())
   3318         .unwrap()
   3319         .with_description("description")
   3320         .unwrap()
   3321         .with_locations(vec!["Barn".into()])
   3322         .unwrap()
   3323         .with_geohash("c23nb62w20st")
   3324         .unwrap()
   3325         .with_summary("summary")
   3326         .unwrap()
   3327         .with_image(image.clone())
   3328         .unwrap()
   3329         .with_participants(vec![participant.clone()])
   3330         .unwrap()
   3331         .with_categories(vec!["market".into()])
   3332         .unwrap()
   3333         .with_references(vec![uri.clone()])
   3334         .unwrap()
   3335         .with_calendar_requests(vec![request.clone()])
   3336         .unwrap();
   3337         assert_eq!(date.d_tag().as_str(), "market");
   3338         assert_eq!(date.title(), "Market");
   3339         assert_eq!(date.start().as_str(), "2026-06-20");
   3340         assert_eq!(date.end().unwrap().as_str(), "2026-06-21");
   3341         assert_eq!(date.description(), Some("description"));
   3342         assert_eq!(date.locations(), ["Barn"]);
   3343         assert_eq!(date.geohash(), Some("c23nb62w20st"));
   3344         assert_eq!(date.summary(), Some("summary"));
   3345         assert!(date.image().is_some());
   3346         assert_eq!(date.participants().unwrap(), &vec![participant.clone()]);
   3347         assert_eq!(date.categories(), ["market"]);
   3348         assert_eq!(date.references(), std::slice::from_ref(&uri));
   3349         assert_eq!(date.calendar_requests(), std::slice::from_ref(&request));
   3350 
   3351         let time = AuthoredCalendarTimeEvent::new("shift", "Shift", 86_400)
   3352             .unwrap()
   3353             .with_end(90_000)
   3354             .unwrap()
   3355             .with_description("description")
   3356             .unwrap()
   3357             .with_start_tzid("UTC")
   3358             .unwrap()
   3359             .with_end_tzid("America/Vancouver")
   3360             .unwrap()
   3361             .with_locations(vec!["Barn".into()])
   3362             .unwrap()
   3363             .with_geohash("c23nb62w20st")
   3364             .unwrap()
   3365             .with_summary("summary")
   3366             .unwrap()
   3367             .with_image(image)
   3368             .unwrap()
   3369             .with_participants(vec![participant])
   3370             .unwrap()
   3371             .with_categories(vec!["shift".into()])
   3372             .unwrap()
   3373             .with_references(vec![uri])
   3374             .unwrap()
   3375             .with_calendar_requests(vec![request])
   3376             .unwrap();
   3377         assert_eq!(time.d_tag().as_str(), "shift");
   3378         assert_eq!(time.title(), "Shift");
   3379         assert_eq!(time.start(), 86_400);
   3380         assert_eq!(time.end(), Some(90_000));
   3381         assert_eq!(time.description(), Some("description"));
   3382         assert_eq!(time.start_tzid().unwrap().as_str(), "UTC");
   3383         assert_eq!(time.end_tzid().unwrap().as_str(), "America/Vancouver");
   3384         assert_eq!(time.effective_end_tzid(), time.end_tzid());
   3385         assert_eq!(time.locations(), ["Barn"]);
   3386         assert_eq!(time.geohash(), Some("c23nb62w20st"));
   3387         assert_eq!(time.summary(), Some("summary"));
   3388         assert!(time.image().is_some());
   3389         assert_eq!(time.participants().unwrap().len(), 1);
   3390         assert_eq!(time.categories(), ["shift"]);
   3391         assert_eq!(time.references().len(), 1);
   3392         assert_eq!(time.calendar_requests().len(), 1);
   3393     }
   3394 
   3395     #[test]
   3396     fn parsed_and_admitted_event_layers_preserve_complete_canonical_shapes() {
   3397         let image_url = format!("https://media.example/{}.webp", "c".repeat(64));
   3398         let common = ParsedNip52CalendarCommon::try_new(ParsedNip52CalendarCommonParts {
   3399             d_tag: "event".into(),
   3400             title: "Event".into(),
   3401             description: Some("Description".into()),
   3402             locations: vec!["Barn".into()],
   3403             geohash: Some("c23nb62w20st".into()),
   3404             summary: Some("Summary".into()),
   3405             image: Some(CalendarUri::parse(&image_url).unwrap()),
   3406             participants: vec![CalendarParticipant {
   3407                 pubkey: "a".repeat(64),
   3408                 relay: None,
   3409                 role: None,
   3410             }],
   3411             categories: vec!["market".into()],
   3412             references: vec![CalendarUri::parse("https://example.com/details").unwrap()],
   3413             calendar_requests: vec![
   3414                 CalendarRequest::new(format!("31924:{}:calendar", "a".repeat(64)), None).unwrap(),
   3415             ],
   3416             legacy_name: Some("Legacy".into()),
   3417         })
   3418         .unwrap();
   3419         assert_eq!(common.d_tag(), "event");
   3420         assert_eq!(common.title(), "Event");
   3421         assert_eq!(common.description(), Some("Description"));
   3422         assert_eq!(common.locations(), ["Barn"]);
   3423         assert_eq!(common.geohash(), Some("c23nb62w20st"));
   3424         assert_eq!(common.summary(), Some("Summary"));
   3425         assert!(common.image().is_some());
   3426         assert_eq!(common.participants().len(), 1);
   3427         assert_eq!(common.categories(), ["market"]);
   3428         assert_eq!(common.references().len(), 1);
   3429         assert_eq!(common.calendar_requests().len(), 1);
   3430         assert_eq!(common.legacy_name(), Some("Legacy"));
   3431 
   3432         let date = ParsedNip52CalendarDateEvent::try_new(
   3433             common.clone(),
   3434             CalendarDate::parse("2026-06-20").unwrap(),
   3435             None,
   3436             Vec::new(),
   3437         )
   3438         .unwrap();
   3439         assert_eq!(date.common(), &common);
   3440         assert_eq!(date.start().as_str(), "2026-06-20");
   3441         assert_eq!(date.end(), None);
   3442         assert!(date.extension_day_tags().is_empty());
   3443         let admitted_date = AdmittedCalendarDateEvent::try_from_parsed(date).unwrap();
   3444         assert_eq!(admitted_date.parsed().common().title(), "Event");
   3445         assert_eq!(admitted_date.d_tag().as_str(), "event");
   3446         assert_eq!(admitted_date.blossom_image().unwrap().as_str(), image_url);
   3447 
   3448         let time = ParsedNip52CalendarTimeEvent::try_new(
   3449             common,
   3450             "86400".into(),
   3451             86_400,
   3452             Some("90000".into()),
   3453             Some(90_000),
   3454             vec![ObservedUtcDay::parse("1").unwrap()],
   3455             Some(IanaTimeZoneId::parse("UTC").unwrap()),
   3456             None,
   3457         )
   3458         .unwrap();
   3459         assert_eq!(time.start_wire(), "86400");
   3460         assert_eq!(time.start(), 86_400);
   3461         assert_eq!(time.end_wire(), Some("90000"));
   3462         assert_eq!(time.end(), Some(90_000));
   3463         assert_eq!(time.observed_day_indices()[0].index(), 1);
   3464         assert_eq!(time.start_tzid().unwrap().as_str(), "UTC");
   3465         assert_eq!(time.end_tzid(), None);
   3466         assert_eq!(time.effective_end_tzid(), time.start_tzid());
   3467         let admitted_time = AdmittedCalendarTimeEvent::try_from_parsed(time).unwrap();
   3468         assert_eq!(admitted_time.parsed().common().title(), "Event");
   3469         assert_eq!(admitted_time.d_tag().as_str(), "event");
   3470         assert_eq!(admitted_time.covered_utc_days(), [1]);
   3471         assert_eq!(admitted_time.blossom_image().unwrap().as_str(), image_url);
   3472     }
   3473 
   3474     #[test]
   3475     fn calendar_validation_helpers_reject_noncanonical_inputs() {
   3476         for valid in ["text", "Märket", "a-b_c.1"] {
   3477             assert!(calendar_tag_text_is_valid(valid));
   3478         }
   3479         for invalid in ["", " text", "text ", "line\nbreak", "x\u{0000}"] {
   3480             assert!(!canonical_calendar_tag_text_is_valid(invalid));
   3481         }
   3482         for valid in ["c23nb62w20st", "0", "zzzz"] {
   3483             assert!(calendar_geohash_is_valid(valid));
   3484         }
   3485         for invalid in ["", "C23", "a", "i234"] {
   3486             assert!(!canonical_calendar_geohash_is_valid(invalid));
   3487         }
   3488         for valid in ["ws://localhost", "wss://relay.example/path?x=1"] {
   3489             assert!(calendar_relay_url_is_valid(valid));
   3490         }
   3491         for invalid in [
   3492             "",
   3493             " wss://relay.example",
   3494             "wss://relay.example/line\nbreak",
   3495             "relay.example",
   3496             "ftp://relay.example",
   3497             "wss:///path",
   3498             "https://relay.example",
   3499             "wss://user@relay.example",
   3500             "wss://relay.example/#fragment",
   3501         ] {
   3502             assert!(!calendar_relay_url_is_valid(invalid));
   3503         }
   3504         assert!(ObservedUtcDay::parse("").is_err());
   3505         assert!(ObservedUtcDay::parse("x").is_err());
   3506         assert!(ObservedUtcDay::parse("18446744073709551616").is_err());
   3507     }
   3508 
   3509     #[test]
   3510     fn parsed_calendar_layers_fail_closed_across_optional_and_range_branches() {
   3511         for invalid in [
   3512             " https://example.com/path",
   3513             "https://example.com/line\nbreak",
   3514             "not-a-uri",
   3515         ] {
   3516             assert!(CalendarUri::parse(invalid).is_err(), "{invalid}");
   3517         }
   3518         assert!(
   3519             CalendarUri::parse(format!(
   3520                 "https://example.com/{}",
   3521                 "x".repeat(DEFAULT_TAG_ELEMENT_MAX_BYTES)
   3522             ))
   3523             .is_err()
   3524         );
   3525 
   3526         let minimal_common = ParsedNip52CalendarCommon::try_new(ParsedNip52CalendarCommonParts {
   3527             d_tag: "event".into(),
   3528             title: "Event".into(),
   3529             description: None,
   3530             locations: Vec::new(),
   3531             geohash: None,
   3532             summary: None,
   3533             image: None,
   3534             participants: Vec::new(),
   3535             categories: Vec::new(),
   3536             references: Vec::new(),
   3537             calendar_requests: Vec::new(),
   3538             legacy_name: None,
   3539         })
   3540         .unwrap();
   3541         assert_eq!(minimal_common.description(), None);
   3542         assert_eq!(minimal_common.geohash(), None);
   3543         assert_eq!(minimal_common.summary(), None);
   3544         assert_eq!(minimal_common.legacy_name(), None);
   3545         let invalid_common = ParsedNip52CalendarCommon::try_new(ParsedNip52CalendarCommonParts {
   3546             d_tag: "event".into(),
   3547             title: "Event".into(),
   3548             description: None,
   3549             locations: Vec::new(),
   3550             geohash: Some("INVALID".into()),
   3551             summary: None,
   3552             image: None,
   3553             participants: Vec::new(),
   3554             categories: Vec::new(),
   3555             references: Vec::new(),
   3556             calendar_requests: Vec::new(),
   3557             legacy_name: None,
   3558         });
   3559         assert_eq!(invalid_common, Err(CalendarEventError::InvalidGeohash));
   3560 
   3561         let start_date = CalendarDate::parse("2026-06-20").unwrap();
   3562         assert_eq!(
   3563             ParsedNip52CalendarDateEvent::try_new(
   3564                 minimal_common.clone(),
   3565                 start_date.clone(),
   3566                 Some(start_date.clone()),
   3567                 Vec::new(),
   3568             ),
   3569             Err(CalendarEventError::InvalidRange)
   3570         );
   3571         let extension_date = ParsedNip52CalendarDateEvent::try_new(
   3572             minimal_common.clone(),
   3573             start_date,
   3574             None,
   3575             vec![vec!["D".into(), "1".into()]],
   3576         )
   3577         .unwrap();
   3578         assert_eq!(
   3579             AdmittedCalendarDateEvent::try_from_parsed(extension_date),
   3580             Err(CalendarAdmissionError::ForbiddenDateDayIndex)
   3581         );
   3582 
   3583         let make_time = |start_wire: &str,
   3584                          start: u64,
   3585                          end_wire: Option<&str>,
   3586                          end: Option<u64>,
   3587                          days: Vec<&str>| {
   3588             ParsedNip52CalendarTimeEvent::try_new(
   3589                 minimal_common.clone(),
   3590                 start_wire.into(),
   3591                 start,
   3592                 end_wire.map(str::to_owned),
   3593                 end,
   3594                 days.into_iter()
   3595                     .map(|day| ObservedUtcDay::parse(day).unwrap())
   3596                     .collect(),
   3597                 None,
   3598                 None,
   3599             )
   3600         };
   3601         assert_eq!(
   3602             make_time("2", 1, None, None, vec!["0"]),
   3603             Err(CalendarEventError::InvalidRange)
   3604         );
   3605         assert_eq!(
   3606             make_time("1", 1, Some("3"), Some(2), vec!["0"]),
   3607             Err(CalendarEventError::InvalidRange)
   3608         );
   3609         assert_eq!(
   3610             make_time("1", 1, Some("2"), None, vec!["0"]),
   3611             Err(CalendarEventError::InvalidRange)
   3612         );
   3613         assert_eq!(
   3614             make_time("1", 1, Some("1"), Some(1), vec!["0"]),
   3615             Err(CalendarEventError::InvalidRange)
   3616         );
   3617         assert_eq!(
   3618             make_time("1", 1, None, None, Vec::new()),
   3619             Err(CalendarEventError::InvalidRange)
   3620         );
   3621         assert_eq!(
   3622             make_time("1", 1, None, None, vec!["1"]),
   3623             Err(CalendarEventError::InvalidRange)
   3624         );
   3625 
   3626         let noncanonical = make_time("01", 1, None, None, vec!["0"]).unwrap();
   3627         assert_eq!(
   3628             AdmittedCalendarTimeEvent::try_from_parsed(noncanonical),
   3629             Err(CalendarAdmissionError::NonCanonicalField("timestamp"))
   3630         );
   3631         let incomplete = make_time("1", 1, Some("86401"), Some(86_401), vec!["0"]).unwrap();
   3632         assert_eq!(
   3633             AdmittedCalendarTimeEvent::try_from_parsed(incomplete),
   3634             Err(CalendarAdmissionError::IncompleteDayCoverage)
   3635         );
   3636         let noncanonical_day = make_time("1", 1, None, None, vec!["00"]).unwrap();
   3637         assert_eq!(
   3638             AdmittedCalendarTimeEvent::try_from_parsed(noncanonical_day),
   3639             Err(CalendarAdmissionError::IncompleteDayCoverage)
   3640         );
   3641     }
   3642 
   3643     #[test]
   3644     fn authored_and_admitted_rsvp_layers_cover_optional_transitions() {
   3645         let uid = CalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAQ").unwrap();
   3646         let event = canonical_event_reference("shift");
   3647         let revision = CalendarEventRevisionReference::parse("b".repeat(64), None).unwrap();
   3648         let author = CalendarEventAuthorReference::parse("a".repeat(64), None).unwrap();
   3649         let authored = AuthoredCalendarEventRsvp::new(
   3650             uid.clone(),
   3651             event.clone(),
   3652             CalendarEventRsvpStatus::Accepted,
   3653         )
   3654         .unwrap()
   3655         .with_revision_reference(revision.clone())
   3656         .unwrap()
   3657         .with_free_busy(CalendarEventFreeBusy::Free)
   3658         .unwrap()
   3659         .with_author_hint(author.clone())
   3660         .unwrap()
   3661         .with_note("Attending")
   3662         .unwrap();
   3663         assert_eq!(authored.uid(), &uid);
   3664         assert_eq!(authored.event_reference(), &event);
   3665         assert_eq!(authored.revision_reference(), Some(&revision));
   3666         assert_eq!(authored.status(), &CalendarEventRsvpStatus::Accepted);
   3667         assert_eq!(
   3668             authored.observed_free_busy(),
   3669             Some(&CalendarEventFreeBusy::Free)
   3670         );
   3671         assert_eq!(
   3672             authored.effective_free_busy(),
   3673             Some(&CalendarEventFreeBusy::Free)
   3674         );
   3675         assert_eq!(authored.author_hint(), Some(&author));
   3676         assert_eq!(authored.note(), Some("Attending"));
   3677 
   3678         let parsed = ParsedNip52CalendarEventRsvp::try_new(ParsedNip52CalendarEventRsvpParts {
   3679             d_tag: uid.to_string(),
   3680             event_reference: event,
   3681             revision_reference: Some(revision),
   3682             status: CalendarEventRsvpStatus::Tentative,
   3683             observed_free_busy: Some(CalendarEventFreeBusy::Busy),
   3684             author_hint: Some(author),
   3685             note: Some("Maybe".into()),
   3686         })
   3687         .unwrap();
   3688         assert_eq!(parsed.d_tag(), uid.as_str());
   3689         assert_eq!(parsed.note(), Some("Maybe"));
   3690         assert_eq!(
   3691             parsed.effective_free_busy(),
   3692             Some(&CalendarEventFreeBusy::Busy)
   3693         );
   3694         let admitted = AdmittedCalendarEventRsvp::try_from_parsed(parsed).unwrap();
   3695         assert_eq!(admitted.parsed().d_tag(), uid.as_str());
   3696         assert_eq!(admitted.uid(), &uid);
   3697         assert_eq!(admitted.status(), &CalendarEventRsvpStatus::Tentative);
   3698         assert_eq!(admitted.note(), Some("Maybe"));
   3699     }
   3700 
   3701     fn calendar_image() -> AuthoredImage {
   3702         let bytes = b"calendar-image";
   3703         let hash = Sha256::digest(bytes);
   3704         let media_type = MediaType::parse("image/webp").unwrap();
   3705         let descriptor = BlobDescriptor::new(
   3706             BlobUrl::parse(&format!("https://media.example/{hash}.webp")).unwrap(),
   3707             hash,
   3708             bytes.len() as u64,
   3709             media_type.clone(),
   3710             1_784_347_200,
   3711         )
   3712         .unwrap()
   3713         .approve_reference()
   3714         .unwrap()
   3715         .verify_bytes(bytes, &media_type)
   3716         .unwrap();
   3717         AuthoredImage::try_from(descriptor).unwrap()
   3718     }
   3719 
   3720     fn canonical_event_reference(d_tag: &str) -> CalendarEventReference {
   3721         CalendarEventReference::parse(
   3722             format!("31923:{}:{d_tag}", "a".repeat(64)),
   3723             Some("wss://relay.example"),
   3724         )
   3725         .unwrap()
   3726     }
   3727 }