lib

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

calendar.rs (30883B)


      1 #![cfg(feature = "serde_json")]
      2 
      3 use radroots_events::{
      4     calendar::{
      5         RadrootsCalendar, RadrootsCalendarDateEvent, RadrootsCalendarEventRsvp,
      6         RadrootsCalendarTimeEvent,
      7     },
      8     kinds::{
      9         KIND_ARTICLE, KIND_CALENDAR, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_EVENT_RSVP,
     10         KIND_CALENDAR_TIME_EVENT, KIND_POST,
     11     },
     12     social::{
     13         RadrootsCalendarDateValue, RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus,
     14         RadrootsCalendarParticipant, RadrootsSocialLocation, RadrootsSocialTarget,
     15     },
     16     tags::{
     17         TAG_A, TAG_D, TAG_D_DAY, TAG_E, TAG_END, TAG_END_TZID, TAG_FREE_BUSY, TAG_G, TAG_IMAGE,
     18         TAG_LOCATION, TAG_P, TAG_START, TAG_START_TZID, TAG_STATUS, TAG_SUMMARY, TAG_TITLE,
     19     },
     20 };
     21 use radroots_events_codec::{
     22     calendar::{
     23         decode::{
     24             calendar_data_from_event, calendar_date_event_from_event, calendar_from_event,
     25             calendar_parsed_from_event, calendar_time_event_from_event, date_data_from_event,
     26             date_parsed_from_event, rsvp_data_from_event, rsvp_from_event, rsvp_parsed_from_event,
     27             time_data_from_event, time_parsed_from_event,
     28         },
     29         encode::{
     30             calendar_collection_build_tags, calendar_date_event_build_tags,
     31             calendar_time_event_build_tags, calendar_to_wire_parts,
     32             calendar_to_wire_parts_with_kind, date_to_wire_parts, date_to_wire_parts_with_kind,
     33             rsvp_build_tags, rsvp_to_wire_parts, rsvp_to_wire_parts_with_kind, time_to_wire_parts,
     34             time_to_wire_parts_with_kind,
     35         },
     36     },
     37     error::{EventEncodeError, EventParseError},
     38 };
     39 
     40 const VALID_D_TAG: &str = "CCCCCCCCCCCCCCCCCCCCCA";
     41 const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
     42 const EVENT_AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
     43 const EVENT_D_TAG: &str = "EEEEEEEEEEEEEEEEEEEEEA";
     44 
     45 fn sample_date_event() -> RadrootsCalendarDateEvent {
     46     RadrootsCalendarDateEvent {
     47         d_tag: VALID_D_TAG.to_string(),
     48         title: "CSA pickup".to_string(),
     49         start: "2026-06-20".to_string(),
     50         description: Some("Bring clean bins to the farm stand.".to_string()),
     51         end: Some("2026-06-21".to_string()),
     52         days: Some(vec![RadrootsCalendarDateValue {
     53             value: "2026-06-20".to_string(),
     54         }]),
     55         location: Some(RadrootsSocialLocation {
     56             name: Some("Farm stand".to_string()),
     57             geohash: Some("c23nb62w20st".to_string()),
     58         }),
     59         summary: Some("Weekly pickup".to_string()),
     60         image: Some("https://media.example.test/calendar.jpg".to_string()),
     61         participants: Some(vec![RadrootsCalendarParticipant {
     62             pubkey: "host_pubkey".to_string(),
     63             relay: Some("wss://relay.example.test".to_string()),
     64             role: Some("host".to_string()),
     65         }]),
     66     }
     67 }
     68 
     69 fn sample_time_event() -> RadrootsCalendarTimeEvent {
     70     RadrootsCalendarTimeEvent {
     71         d_tag: VALID_D_TAG.to_string(),
     72         title: "Wash pack shift".to_string(),
     73         start: 1_781_895_600,
     74         dates: vec![RadrootsCalendarDateValue {
     75             value: "2026-06-20".to_string(),
     76         }],
     77         description: Some("Prepare CSA bins before pickup.".to_string()),
     78         end: Some(1_781_899_200),
     79         start_tzid: Some("America/Vancouver".to_string()),
     80         end_tzid: Some("America/Vancouver".to_string()),
     81         location: Some(RadrootsSocialLocation {
     82             name: Some("Pack shed".to_string()),
     83             geohash: Some("c23nb62w20st".to_string()),
     84         }),
     85         summary: Some("Prepare CSA bins".to_string()),
     86         image: None,
     87         participants: Some(vec![RadrootsCalendarParticipant {
     88             pubkey: "crew_pubkey".to_string(),
     89             relay: None,
     90             role: Some("participant".to_string()),
     91         }]),
     92     }
     93 }
     94 
     95 fn sample_calendar_collection() -> RadrootsCalendar {
     96     RadrootsCalendar {
     97         d_tag: VALID_D_TAG.to_string(),
     98         title: "Farm calendar".to_string(),
     99         events: vec![RadrootsSocialTarget::Address {
    100             address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"),
    101             author: Some(EVENT_AUTHOR.to_string()),
    102             event_kind: Some(KIND_CALENDAR_TIME_EVENT),
    103             relays: Some(vec!["wss://relay.example.test".to_string()]),
    104         }],
    105         description: Some("Shared schedule for farm operations.".to_string()),
    106         summary: Some("CSA and harvest schedule".to_string()),
    107         image: Some("https://media.example.test/calendar.jpg".to_string()),
    108     }
    109 }
    110 
    111 fn sample_rsvp() -> RadrootsCalendarEventRsvp {
    112     RadrootsCalendarEventRsvp {
    113         d_tag: VALID_D_TAG.to_string(),
    114         event: RadrootsSocialTarget::Address {
    115             address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"),
    116             author: Some(EVENT_AUTHOR.to_string()),
    117             event_kind: Some(KIND_CALENDAR_TIME_EVENT),
    118             relays: Some(vec!["wss://relay.example.test".to_string()]),
    119         },
    120         event_id: Some(EVENT_ID.to_string()),
    121         status: RadrootsCalendarEventRsvpStatus::Accepted,
    122         free_busy: Some(RadrootsCalendarEventFreeBusy::Busy),
    123         note: Some("I can attend after harvest".to_string()),
    124         participants: Some(vec![RadrootsCalendarParticipant {
    125             pubkey: "crew_pubkey".to_string(),
    126             relay: None,
    127             role: Some("participant".to_string()),
    128         }]),
    129     }
    130 }
    131 
    132 fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool {
    133     tags.iter().any(|tag| {
    134         tag.first().map(|entry| entry.as_str()) == Some(key)
    135             && tag.get(1).map(|entry| entry.as_str()) == Some(value)
    136     })
    137 }
    138 
    139 fn replace_tag_value(tags: &mut [Vec<String>], key: &str, value: &str) {
    140     let tag = tags
    141         .iter_mut()
    142         .find(|tag| tag.first().map(|entry| entry.as_str()) == Some(key))
    143         .expect("tag");
    144     tag[1] = value.to_string();
    145 }
    146 
    147 #[test]
    148 fn calendar_date_event_to_wire_parts_roundtrips_tags() {
    149     let event = sample_date_event();
    150     let parts = date_to_wire_parts(&event).unwrap();
    151 
    152     assert_eq!(parts.kind, KIND_CALENDAR_DATE_EVENT);
    153     assert_eq!(parts.content, "Bring clean bins to the farm stand.");
    154     assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
    155     assert!(has_tag(&parts.tags, TAG_TITLE, "CSA pickup"));
    156     assert!(has_tag(&parts.tags, TAG_START, "2026-06-20"));
    157     assert!(has_tag(&parts.tags, TAG_END, "2026-06-21"));
    158     assert!(has_tag(&parts.tags, TAG_D_DAY, "2026-06-20"));
    159     assert!(has_tag(&parts.tags, TAG_LOCATION, "Farm stand"));
    160     assert!(has_tag(&parts.tags, TAG_G, "c23nb62w20st"));
    161     assert!(has_tag(&parts.tags, TAG_SUMMARY, "Weekly pickup"));
    162     assert!(has_tag(
    163         &parts.tags,
    164         TAG_IMAGE,
    165         "https://media.example.test/calendar.jpg"
    166     ));
    167     assert!(has_tag(&parts.tags, TAG_P, "host_pubkey"));
    168 
    169     let decoded = calendar_date_event_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    170     assert_eq!(decoded.d_tag, VALID_D_TAG);
    171     assert_eq!(decoded.title, "CSA pickup");
    172     assert_eq!(
    173         decoded.description.as_deref(),
    174         Some("Bring clean bins to the farm stand.")
    175     );
    176     assert_eq!(decoded.start, "2026-06-20");
    177     assert_eq!(decoded.end.as_deref(), Some("2026-06-21"));
    178     assert_eq!(decoded.days.as_ref().map(Vec::len), Some(1));
    179     assert_eq!(
    180         decoded
    181             .location
    182             .as_ref()
    183             .and_then(|location| location.name.as_deref()),
    184         Some("Farm stand")
    185     );
    186     assert_eq!(decoded.participants.as_ref().map(Vec::len), Some(1));
    187 }
    188 
    189 #[test]
    190 fn calendar_time_event_to_wire_parts_roundtrips_tags() {
    191     let event = sample_time_event();
    192     let parts = time_to_wire_parts(&event).unwrap();
    193 
    194     assert_eq!(parts.kind, KIND_CALENDAR_TIME_EVENT);
    195     assert_eq!(parts.content, "Prepare CSA bins before pickup.");
    196     assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
    197     assert!(has_tag(&parts.tags, TAG_TITLE, "Wash pack shift"));
    198     assert!(has_tag(&parts.tags, TAG_START, "1781895600"));
    199     assert!(has_tag(&parts.tags, TAG_D_DAY, "2026-06-20"));
    200     assert!(has_tag(&parts.tags, TAG_END, "1781899200"));
    201     assert!(has_tag(&parts.tags, TAG_START_TZID, "America/Vancouver"));
    202     assert!(has_tag(&parts.tags, TAG_END_TZID, "America/Vancouver"));
    203     assert!(has_tag(&parts.tags, TAG_LOCATION, "Pack shed"));
    204     assert!(has_tag(&parts.tags, TAG_P, "crew_pubkey"));
    205 
    206     let decoded = calendar_time_event_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    207     assert_eq!(decoded.d_tag, VALID_D_TAG);
    208     assert_eq!(decoded.title, "Wash pack shift");
    209     assert_eq!(
    210         decoded.description.as_deref(),
    211         Some("Prepare CSA bins before pickup.")
    212     );
    213     assert_eq!(decoded.start, 1_781_895_600);
    214     assert_eq!(decoded.dates.len(), 1);
    215     assert_eq!(decoded.end, Some(1_781_899_200));
    216     assert_eq!(decoded.start_tzid.as_deref(), Some("America/Vancouver"));
    217     assert_eq!(decoded.participants.as_ref().map(Vec::len), Some(1));
    218 }
    219 
    220 #[test]
    221 fn calendar_collection_to_wire_parts_roundtrips_event_addresses() {
    222     let calendar = sample_calendar_collection();
    223     let parts = calendar_to_wire_parts(&calendar).unwrap();
    224 
    225     assert_eq!(parts.kind, KIND_CALENDAR);
    226     assert_eq!(parts.content, "Shared schedule for farm operations.");
    227     assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
    228     assert!(has_tag(&parts.tags, TAG_TITLE, "Farm calendar"));
    229     assert!(has_tag(
    230         &parts.tags,
    231         TAG_A,
    232         format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}").as_str()
    233     ));
    234 
    235     let decoded = calendar_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    236     assert_eq!(decoded.d_tag, VALID_D_TAG);
    237     assert_eq!(decoded.title, "Farm calendar");
    238     assert_eq!(
    239         decoded.description.as_deref(),
    240         Some("Shared schedule for farm operations.")
    241     );
    242     assert_eq!(decoded.events.len(), 1);
    243     assert!(matches!(
    244         decoded.events[0],
    245         RadrootsSocialTarget::Address {
    246             event_kind: Some(KIND_CALENDAR_TIME_EVENT),
    247             ..
    248         }
    249     ));
    250 }
    251 
    252 #[test]
    253 fn calendar_rsvp_to_wire_parts_roundtrips_status_event_id_and_participants() {
    254     let rsvp = sample_rsvp();
    255     let parts = rsvp_to_wire_parts(&rsvp).unwrap();
    256 
    257     assert_eq!(parts.kind, KIND_CALENDAR_EVENT_RSVP);
    258     assert_eq!(parts.content, "I can attend after harvest");
    259     assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
    260     assert!(has_tag(
    261         &parts.tags,
    262         TAG_A,
    263         format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}").as_str()
    264     ));
    265     assert!(has_tag(&parts.tags, TAG_E, EVENT_ID));
    266     assert!(has_tag(&parts.tags, TAG_STATUS, "accepted"));
    267     assert!(has_tag(&parts.tags, TAG_FREE_BUSY, "busy"));
    268     assert!(has_tag(&parts.tags, TAG_P, "crew_pubkey"));
    269 
    270     let decoded = rsvp_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    271     assert_eq!(decoded.event_id.as_deref(), Some(EVENT_ID));
    272     assert_eq!(decoded.status, RadrootsCalendarEventRsvpStatus::Accepted);
    273     assert_eq!(decoded.free_busy, Some(RadrootsCalendarEventFreeBusy::Busy));
    274     assert_eq!(decoded.note.as_deref(), Some("I can attend after harvest"));
    275     assert_eq!(decoded.participants.as_ref().map(Vec::len), Some(1));
    276 }
    277 
    278 #[test]
    279 fn calendar_encode_omits_absent_optional_fields() {
    280     let mut time = sample_time_event();
    281     time.end = None;
    282     time.location = None;
    283     let tags = calendar_time_event_build_tags(&time).unwrap();
    284     assert!(!tags.iter().any(|tag| tag[0] == TAG_END));
    285     assert!(!tags.iter().any(|tag| tag[0] == TAG_LOCATION));
    286 
    287     let mut collection = sample_calendar_collection();
    288     collection.events[0] = RadrootsSocialTarget::Address {
    289         address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"),
    290         author: Some(EVENT_AUTHOR.to_string()),
    291         event_kind: None,
    292         relays: None,
    293     };
    294     let tags = calendar_collection_build_tags(&collection).unwrap();
    295     assert_eq!(
    296         tags.iter()
    297             .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A))
    298             .map(Vec::len),
    299         Some(2)
    300     );
    301 
    302     let mut rsvp = sample_rsvp();
    303     rsvp.event = RadrootsSocialTarget::Address {
    304         address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"),
    305         author: Some(EVENT_AUTHOR.to_string()),
    306         event_kind: None,
    307         relays: None,
    308     };
    309     rsvp.free_busy = None;
    310     let tags = rsvp_build_tags(&rsvp).unwrap();
    311     assert_eq!(
    312         tags.iter()
    313             .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E))
    314             .map(Vec::len),
    315         Some(2)
    316     );
    317     assert!(!tags.iter().any(|tag| tag[0] == TAG_FREE_BUSY));
    318 }
    319 
    320 #[test]
    321 fn calendar_codecs_reject_wrong_kind_invalid_dates_and_missing_time_dates() {
    322     assert!(matches!(
    323         date_to_wire_parts_with_kind(&sample_date_event(), KIND_POST),
    324         Err(EventEncodeError::InvalidKind(KIND_POST))
    325     ));
    326     assert!(matches!(
    327         time_to_wire_parts_with_kind(&sample_time_event(), KIND_POST),
    328         Err(EventEncodeError::InvalidKind(KIND_POST))
    329     ));
    330 
    331     let mut event = sample_date_event();
    332     event.start = "2026-6-20".to_string();
    333     assert!(matches!(
    334         calendar_date_event_build_tags(&event),
    335         Err(EventEncodeError::InvalidField("start"))
    336     ));
    337 
    338     let mut event = sample_time_event();
    339     event.end = Some(event.start - 1);
    340     assert!(matches!(
    341         calendar_time_event_build_tags(&event),
    342         Err(EventEncodeError::InvalidField("end"))
    343     ));
    344 
    345     let mut event = sample_time_event();
    346     event.dates.clear();
    347     assert!(matches!(
    348         calendar_time_event_build_tags(&event),
    349         Err(EventEncodeError::EmptyRequiredField("dates"))
    350     ));
    351 
    352     let tags = calendar_date_event_build_tags(&sample_date_event()).unwrap();
    353     let decoded = calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, "body").unwrap();
    354     assert_eq!(decoded.description.as_deref(), Some("body"));
    355 
    356     let mut tags = calendar_date_event_build_tags(&sample_date_event()).unwrap();
    357     let start = tags
    358         .iter_mut()
    359         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_START))
    360         .expect("start tag");
    361     start[1] = "bad".to_string();
    362     assert!(matches!(
    363         calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, ""),
    364         Err(EventParseError::InvalidTag(TAG_START))
    365     ));
    366 
    367     let mut tags = calendar_time_event_build_tags(&sample_time_event()).unwrap();
    368     tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_D_DAY));
    369     assert!(matches!(
    370         calendar_time_event_from_event(KIND_CALENDAR_TIME_EVENT, &tags, ""),
    371         Err(EventParseError::MissingTag(TAG_D_DAY))
    372     ));
    373 
    374     let err = calendar_time_event_from_event(KIND_POST, &tags, "").unwrap_err();
    375     assert!(matches!(
    376         err,
    377         EventParseError::InvalidKind {
    378             expected: "31923",
    379             got: KIND_POST
    380         }
    381     ));
    382 }
    383 
    384 #[test]
    385 fn calendar_collection_and_rsvp_reject_missing_or_invalid_required_tags() {
    386     assert!(matches!(
    387         calendar_to_wire_parts_with_kind(&sample_calendar_collection(), KIND_POST),
    388         Err(EventEncodeError::InvalidKind(KIND_POST))
    389     ));
    390     assert!(matches!(
    391         rsvp_to_wire_parts_with_kind(&sample_rsvp(), KIND_POST),
    392         Err(EventEncodeError::InvalidKind(KIND_POST))
    393     ));
    394 
    395     let mut calendar = sample_calendar_collection();
    396     calendar.events.clear();
    397     assert!(matches!(
    398         calendar_collection_build_tags(&calendar),
    399         Err(EventEncodeError::EmptyRequiredField("events"))
    400     ));
    401 
    402     let mut rsvp = sample_rsvp();
    403     if let RadrootsSocialTarget::Address { event_kind, .. } = &mut rsvp.event {
    404         *event_kind = Some(KIND_ARTICLE);
    405     }
    406     assert!(matches!(
    407         rsvp_build_tags(&rsvp),
    408         Err(EventEncodeError::InvalidField("event"))
    409     ));
    410 
    411     let mut tags = calendar_collection_build_tags(&sample_calendar_collection()).unwrap();
    412     tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_A));
    413     assert!(matches!(
    414         calendar_from_event(KIND_CALENDAR, &tags, ""),
    415         Err(EventParseError::MissingTag(TAG_A))
    416     ));
    417 
    418     let mut tags = rsvp_build_tags(&sample_rsvp()).unwrap();
    419     let status = tags
    420         .iter_mut()
    421         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_STATUS))
    422         .expect("status tag");
    423     status[1] = "maybe".to_string();
    424     assert!(matches!(
    425         rsvp_from_event(KIND_CALENDAR_EVENT_RSVP, &tags, ""),
    426         Err(EventParseError::InvalidTag(TAG_STATUS))
    427     ));
    428 
    429     let mut tags = rsvp_build_tags(&sample_rsvp()).unwrap();
    430     let free_busy = tags
    431         .iter_mut()
    432         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_FREE_BUSY))
    433         .expect("fb tag");
    434     free_busy[1] = "unknown".to_string();
    435     assert!(matches!(
    436         rsvp_from_event(KIND_CALENDAR_EVENT_RSVP, &tags, ""),
    437         Err(EventParseError::InvalidTag(TAG_FREE_BUSY))
    438     ));
    439 }
    440 
    441 #[test]
    442 fn calendar_date_codecs_cover_optional_and_error_edges() {
    443     let date_tags = calendar_date_event_build_tags(&sample_date_event()).unwrap();
    444     let wrong_kind = calendar_date_event_from_event(KIND_POST, &date_tags, "").unwrap_err();
    445     assert!(matches!(
    446         wrong_kind,
    447         EventParseError::InvalidKind {
    448             expected: "31922",
    449             got: KIND_POST
    450         }
    451     ));
    452 
    453     let mut minimal = sample_date_event();
    454     minimal.description = None;
    455     minimal.end = None;
    456     minimal.days = None;
    457     minimal.location = None;
    458     minimal.summary = None;
    459     minimal.image = None;
    460     minimal.participants = None;
    461     let parts = date_to_wire_parts(&minimal).unwrap();
    462     assert_eq!(parts.content, "");
    463     assert!(
    464         !parts
    465             .tags
    466             .iter()
    467             .any(|tag| tag.first().map(String::as_str) == Some(TAG_D_DAY))
    468     );
    469     let decoded = calendar_date_event_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    470     assert!(decoded.description.is_none());
    471     assert!(decoded.end.is_none());
    472     assert!(decoded.days.is_none());
    473 
    474     let mut tags = calendar_date_event_build_tags(&sample_date_event()).unwrap();
    475     replace_tag_value(&mut tags, TAG_END, "2026-06-19");
    476     assert!(matches!(
    477         calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, ""),
    478         Err(EventParseError::InvalidTag(TAG_END))
    479     ));
    480 
    481     let mut tags = calendar_date_event_build_tags(&sample_date_event()).unwrap();
    482     replace_tag_value(&mut tags, TAG_D_DAY, "2026-6-20");
    483     assert!(matches!(
    484         calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, ""),
    485         Err(EventParseError::InvalidTag(TAG_D_DAY))
    486     ));
    487 
    488     let mut event = sample_date_event();
    489     event.title.clear();
    490     assert!(matches!(
    491         calendar_date_event_build_tags(&event),
    492         Err(EventEncodeError::EmptyRequiredField("title"))
    493     ));
    494 
    495     let mut event = sample_date_event();
    496     event.end = Some("2026-6-21".to_string());
    497     assert!(matches!(
    498         calendar_date_event_build_tags(&event),
    499         Err(EventEncodeError::InvalidField("end"))
    500     ));
    501 
    502     let mut event = sample_date_event();
    503     event.end = Some("2026-06-19".to_string());
    504     assert!(matches!(
    505         calendar_date_event_build_tags(&event),
    506         Err(EventEncodeError::InvalidField("end"))
    507     ));
    508 }
    509 
    510 #[test]
    511 fn calendar_time_codecs_cover_numeric_and_validation_edges() {
    512     let mut tags = calendar_time_event_build_tags(&sample_time_event()).unwrap();
    513     replace_tag_value(&mut tags, TAG_START, "not-a-number");
    514     assert!(matches!(
    515         calendar_time_event_from_event(KIND_CALENDAR_TIME_EVENT, &tags, ""),
    516         Err(EventParseError::InvalidNumber(TAG_START, _))
    517     ));
    518 
    519     let mut tags = calendar_time_event_build_tags(&sample_time_event()).unwrap();
    520     replace_tag_value(&mut tags, TAG_END, "not-a-number");
    521     assert!(matches!(
    522         calendar_time_event_from_event(KIND_CALENDAR_TIME_EVENT, &tags, ""),
    523         Err(EventParseError::InvalidNumber(TAG_END, _))
    524     ));
    525 
    526     let mut tags = calendar_time_event_build_tags(&sample_time_event()).unwrap();
    527     replace_tag_value(&mut tags, TAG_D_DAY, "2026-6-20");
    528     assert!(matches!(
    529         calendar_time_event_from_event(KIND_CALENDAR_TIME_EVENT, &tags, ""),
    530         Err(EventParseError::InvalidTag(TAG_D_DAY))
    531     ));
    532 
    533     let mut event = sample_time_event();
    534     event.d_tag = "bad".to_string();
    535     assert!(matches!(
    536         calendar_time_event_build_tags(&event),
    537         Err(EventEncodeError::InvalidField("d_tag"))
    538     ));
    539 
    540     let mut event = sample_time_event();
    541     event.title.clear();
    542     assert!(matches!(
    543         calendar_time_event_build_tags(&event),
    544         Err(EventEncodeError::EmptyRequiredField("title"))
    545     ));
    546 
    547     let mut event = sample_time_event();
    548     event.dates[0].value = "2026-6-20".to_string();
    549     assert!(matches!(
    550         calendar_time_event_build_tags(&event),
    551         Err(EventEncodeError::InvalidField("dates"))
    552     ));
    553 }
    554 
    555 #[test]
    556 fn calendar_collection_codecs_cover_address_edges() {
    557     let tags = calendar_collection_build_tags(&sample_calendar_collection()).unwrap();
    558     let wrong_kind = calendar_from_event(KIND_POST, &tags, "").unwrap_err();
    559     assert!(matches!(
    560         wrong_kind,
    561         EventParseError::InvalidKind {
    562             expected: "31924",
    563             got: KIND_POST
    564         }
    565     ));
    566 
    567     let mut calendar = sample_calendar_collection();
    568     calendar.summary = None;
    569     calendar.image = None;
    570     calendar.description = None;
    571     if let RadrootsSocialTarget::Address { relays, .. } = &mut calendar.events[0] {
    572         *relays = None;
    573     }
    574     let parts = calendar_to_wire_parts(&calendar).unwrap();
    575     assert_eq!(parts.content, "");
    576     let decoded = calendar_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    577     assert!(decoded.description.is_none());
    578     assert!(matches!(
    579         &decoded.events[0],
    580         RadrootsSocialTarget::Address { relays: None, .. }
    581     ));
    582 
    583     let mut tags = calendar_collection_build_tags(&sample_calendar_collection()).unwrap();
    584     let address = tags
    585         .iter_mut()
    586         .find(|tag| tag.first().map(String::as_str) == Some(TAG_A))
    587         .expect("address tag");
    588     address.truncate(1);
    589     assert!(matches!(
    590         calendar_from_event(KIND_CALENDAR, &tags, ""),
    591         Err(EventParseError::InvalidTag(TAG_A))
    592     ));
    593 
    594     let mut tags = calendar_collection_build_tags(&sample_calendar_collection()).unwrap();
    595     replace_tag_value(
    596         &mut tags,
    597         TAG_A,
    598         format!("{KIND_ARTICLE}:{EVENT_AUTHOR}:{EVENT_D_TAG}").as_str(),
    599     );
    600     assert!(matches!(
    601         calendar_from_event(KIND_CALENDAR, &tags, ""),
    602         Err(EventParseError::InvalidTag(TAG_A))
    603     ));
    604 
    605     let mut calendar = sample_calendar_collection();
    606     calendar.title.clear();
    607     assert!(matches!(
    608         calendar_collection_build_tags(&calendar),
    609         Err(EventEncodeError::EmptyRequiredField("title"))
    610     ));
    611 
    612     let mut calendar = sample_calendar_collection();
    613     calendar.events[0] = RadrootsSocialTarget::Event {
    614         id: EVENT_ID.to_string(),
    615         author: Some(EVENT_AUTHOR.to_string()),
    616         event_kind: Some(KIND_CALENDAR_TIME_EVENT),
    617         relays: None,
    618     };
    619     assert!(matches!(
    620         calendar_collection_build_tags(&calendar),
    621         Err(EventEncodeError::InvalidField("events"))
    622     ));
    623 
    624     let mut calendar = sample_calendar_collection();
    625     if let RadrootsSocialTarget::Address { address, .. } = &mut calendar.events[0] {
    626         *address = "not-an-address".to_string();
    627     }
    628     assert!(matches!(
    629         calendar_collection_build_tags(&calendar),
    630         Err(EventEncodeError::InvalidField("events"))
    631     ));
    632 
    633     let mut calendar = sample_calendar_collection();
    634     if let RadrootsSocialTarget::Address {
    635         address,
    636         event_kind,
    637         ..
    638     } = &mut calendar.events[0]
    639     {
    640         *address = format!("{KIND_ARTICLE}:{EVENT_AUTHOR}:{EVENT_D_TAG}");
    641         *event_kind = Some(KIND_ARTICLE);
    642     }
    643     assert!(matches!(
    644         calendar_collection_build_tags(&calendar),
    645         Err(EventEncodeError::InvalidField("events"))
    646     ));
    647 }
    648 
    649 #[test]
    650 fn calendar_rsvp_codecs_cover_status_and_target_edges() {
    651     let tags = rsvp_build_tags(&sample_rsvp()).unwrap();
    652     let wrong_kind = rsvp_from_event(KIND_POST, &tags, "").unwrap_err();
    653     assert!(matches!(
    654         wrong_kind,
    655         EventParseError::InvalidKind {
    656             expected: "31925",
    657             got: KIND_POST
    658         }
    659     ));
    660 
    661     let mut rsvp = sample_rsvp();
    662     rsvp.status = RadrootsCalendarEventRsvpStatus::Declined;
    663     rsvp.free_busy = Some(RadrootsCalendarEventFreeBusy::Free);
    664     rsvp.event_id = None;
    665     rsvp.note = None;
    666     rsvp.participants = None;
    667     if let RadrootsSocialTarget::Address { relays, .. } = &mut rsvp.event {
    668         *relays = None;
    669     }
    670     let parts = rsvp_to_wire_parts(&rsvp).unwrap();
    671     assert_eq!(parts.content, "");
    672     assert!(!parts.tags.iter().any(|tag| {
    673         tag.first().map(String::as_str) == Some(TAG_E)
    674             || tag.first().map(String::as_str) == Some(TAG_P)
    675     }));
    676     let decoded = rsvp_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    677     assert_eq!(decoded.status, RadrootsCalendarEventRsvpStatus::Declined);
    678     assert_eq!(decoded.free_busy, Some(RadrootsCalendarEventFreeBusy::Free));
    679     assert!(decoded.note.is_none());
    680 
    681     let mut rsvp = sample_rsvp();
    682     rsvp.status = RadrootsCalendarEventRsvpStatus::Tentative;
    683     let parts = rsvp_to_wire_parts(&rsvp).unwrap();
    684     assert!(has_tag(&parts.tags, TAG_STATUS, "tentative"));
    685     let decoded = rsvp_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    686     assert_eq!(decoded.status, RadrootsCalendarEventRsvpStatus::Tentative);
    687 
    688     let mut rsvp = sample_rsvp();
    689     rsvp.event_id = Some("not-a-lowercase-hex-id".to_string());
    690     assert!(matches!(
    691         rsvp_build_tags(&rsvp),
    692         Err(EventEncodeError::InvalidField("event_id"))
    693     ));
    694 
    695     let mut tags = rsvp_build_tags(&sample_rsvp()).unwrap();
    696     replace_tag_value(&mut tags, TAG_E, "not-a-lowercase-hex-id");
    697     assert!(matches!(
    698         rsvp_from_event(KIND_CALENDAR_EVENT_RSVP, &tags, ""),
    699         Err(EventParseError::InvalidTag(TAG_E))
    700     ));
    701 }
    702 
    703 #[test]
    704 fn calendar_wrappers_preserve_event_metadata() {
    705     let date = sample_date_event();
    706     let date_parts = date_to_wire_parts(&date).unwrap();
    707     let date_data = date_data_from_event(
    708         "date_id".to_string(),
    709         "author".to_string(),
    710         7,
    711         date_parts.kind,
    712         date_parts.content.clone(),
    713         date_parts.tags.clone(),
    714     )
    715     .unwrap();
    716     assert_eq!(date_data.kind, KIND_CALENDAR_DATE_EVENT);
    717     assert_eq!(date_data.data.title, "CSA pickup");
    718 
    719     let err = date_parsed_from_event(
    720         "date_id".to_string(),
    721         "author".to_string(),
    722         7,
    723         KIND_POST,
    724         date_parts.content.clone(),
    725         date_parts.tags.clone(),
    726         "sig".to_string(),
    727     )
    728     .unwrap_err();
    729     assert!(matches!(
    730         err,
    731         EventParseError::InvalidKind {
    732             expected: "31922",
    733             got: KIND_POST
    734         }
    735     ));
    736 
    737     let date_parsed = date_parsed_from_event(
    738         "date_id".to_string(),
    739         "author".to_string(),
    740         7,
    741         date_parts.kind,
    742         date_parts.content,
    743         date_parts.tags,
    744         "sig".to_string(),
    745     )
    746     .unwrap();
    747     assert_eq!(date_parsed.event.sig, "sig");
    748 
    749     let time = sample_time_event();
    750     let time_parts = time_to_wire_parts(&time).unwrap();
    751     let time_data = time_data_from_event(
    752         "time_id".to_string(),
    753         "author".to_string(),
    754         8,
    755         time_parts.kind,
    756         time_parts.content.clone(),
    757         time_parts.tags.clone(),
    758     )
    759     .unwrap();
    760     assert_eq!(time_data.kind, KIND_CALENDAR_TIME_EVENT);
    761     assert_eq!(time_data.data.title, "Wash pack shift");
    762 
    763     let err = time_parsed_from_event(
    764         "time_id".to_string(),
    765         "author".to_string(),
    766         8,
    767         KIND_POST,
    768         time_parts.content.clone(),
    769         time_parts.tags.clone(),
    770         "sig".to_string(),
    771     )
    772     .unwrap_err();
    773     assert!(matches!(
    774         err,
    775         EventParseError::InvalidKind {
    776             expected: "31923",
    777             got: KIND_POST
    778         }
    779     ));
    780 
    781     let time_parsed = time_parsed_from_event(
    782         "time_id".to_string(),
    783         "author".to_string(),
    784         8,
    785         time_parts.kind,
    786         time_parts.content,
    787         time_parts.tags,
    788         "sig".to_string(),
    789     )
    790     .unwrap();
    791     assert_eq!(time_parsed.event.created_at, 8);
    792 
    793     let calendar = sample_calendar_collection();
    794     let calendar_parts = calendar_to_wire_parts(&calendar).unwrap();
    795     let calendar_data = calendar_data_from_event(
    796         "calendar_id".to_string(),
    797         "author".to_string(),
    798         9,
    799         calendar_parts.kind,
    800         calendar_parts.content.clone(),
    801         calendar_parts.tags.clone(),
    802     )
    803     .unwrap();
    804     assert_eq!(calendar_data.kind, KIND_CALENDAR);
    805     assert_eq!(calendar_data.data.title, "Farm calendar");
    806 
    807     let err = calendar_parsed_from_event(
    808         "calendar_id".to_string(),
    809         "author".to_string(),
    810         9,
    811         KIND_POST,
    812         calendar_parts.content.clone(),
    813         calendar_parts.tags.clone(),
    814         "sig".to_string(),
    815     )
    816     .unwrap_err();
    817     assert!(matches!(
    818         err,
    819         EventParseError::InvalidKind {
    820             expected: "31924",
    821             got: KIND_POST
    822         }
    823     ));
    824 
    825     let calendar_parsed = calendar_parsed_from_event(
    826         "calendar_id".to_string(),
    827         "author".to_string(),
    828         9,
    829         calendar_parts.kind,
    830         calendar_parts.content,
    831         calendar_parts.tags,
    832         "sig".to_string(),
    833     )
    834     .unwrap();
    835     assert_eq!(calendar_parsed.event.sig, "sig");
    836 
    837     let rsvp = sample_rsvp();
    838     let rsvp_parts = rsvp_to_wire_parts(&rsvp).unwrap();
    839     let rsvp_data = rsvp_data_from_event(
    840         "rsvp_id".to_string(),
    841         "author".to_string(),
    842         10,
    843         rsvp_parts.kind,
    844         rsvp_parts.content.clone(),
    845         rsvp_parts.tags.clone(),
    846     )
    847     .unwrap();
    848     assert_eq!(rsvp_data.kind, KIND_CALENDAR_EVENT_RSVP);
    849     assert_eq!(rsvp_data.data.event_id.as_deref(), Some(EVENT_ID));
    850 
    851     let err = rsvp_parsed_from_event(
    852         "rsvp_id".to_string(),
    853         "author".to_string(),
    854         10,
    855         KIND_POST,
    856         rsvp_parts.content.clone(),
    857         rsvp_parts.tags.clone(),
    858         "sig".to_string(),
    859     )
    860     .unwrap_err();
    861     assert!(matches!(
    862         err,
    863         EventParseError::InvalidKind {
    864             expected: "31925",
    865             got: KIND_POST
    866         }
    867     ));
    868 
    869     let rsvp_parsed = rsvp_parsed_from_event(
    870         "rsvp_id".to_string(),
    871         "author".to_string(),
    872         10,
    873         rsvp_parts.kind,
    874         rsvp_parts.content,
    875         rsvp_parts.tags,
    876         "sig".to_string(),
    877     )
    878     .unwrap();
    879     assert_eq!(rsvp_parsed.event.created_at, 10);
    880 }