//! REPORT parsing, query filters, calendar-data rendering and free-busy. use chrono::{DateTime, Utc}; use pimdav::calcard::icalendar::ICalendar; use pimdav::calcard::vcard::VCard; use pimdav::filter::{matches_calendar, matches_card}; use pimdav::freebusy::{Busy, busy, merge}; use pimdav::render::{AddressData, address_data, calendar_data}; use pimdav::report::{Refused, Report, parse}; use pimdav::xml::{CALDAV, Name}; use pimdav::zone::{Zone, named}; fn utc(s: &str) -> DateTime { format!("{s}Z").parse().unwrap() } fn cal(body: &str) -> ICalendar { ICalendar::parse(format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n" )) .unwrap() } fn query(filter: &str) -> String { format!( r#" {filter} "# ) } /// Whether `body` matches `filter` inside the VCALENDAR comp-filter. fn hit(body: &str, filter: &str) -> bool { let Ok(Report::CalendarQuery { filter, .. }) = parse(query(filter).as_bytes()) else { panic!("bad query {filter}"); }; matches_calendar(&cal(body), &filter, &Zone::Utc) } fn range(comp: &str, start: &str, end: &str) -> String { format!( r#""# ) } #[test] fn todo_time_ranges() { let day = |comp| range(comp, "20260110T000000Z", "20260111T000000Z"); let todo = |props: &str| format!("BEGIN:VTODO\r\nUID:t\r\n{props}END:VTODO\r\n"); assert!(hit(&todo("DUE:20260110T170000Z\r\n"), &day("VTODO"))); // RFC 4791 counts a DUE on the range end as inside. assert!(hit(&todo("DUE:20260111T000000Z\r\n"), &day("VTODO"))); assert!(!hit(&todo("DUE:20260111T000001Z\r\n"), &day("VTODO"))); assert!(hit(&todo("COMPLETED:20260110T100000Z\r\n"), &day("VTODO"))); assert!(hit(&todo("CREATED:20260101T000000Z\r\n"), &day("VTODO"))); assert!(!hit(&todo("CREATED:20260201T000000Z\r\n"), &day("VTODO"))); assert!(hit(&todo(""), &day("VTODO"))); // DTSTART with DUE spanning the range. assert!(hit( &todo("DTSTART:20260101T000000Z\r\nDUE:20260201T000000Z\r\n"), &day("VTODO") )); assert!(!hit(&todo("DTSTART:20260111T000000Z\r\n"), &day("VTODO"))); } #[test] fn alarms_fire_per_instance() { let body = "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260105T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY\r\n\ BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\n"; let alarm = |start, end| { format!( r#"{}"#, range("VALARM", start, end) ) }; assert!(hit(body, &alarm("20260110T084000Z", "20260110T085000Z"))); assert!(!hit(body, &alarm("20260110T085000Z", "20260110T090000Z"))); let from_end = body.replace("TRIGGER:-PT15M", "TRIGGER;RELATED=END:PT5M"); assert!(hit( &from_end, &alarm("20260110T100000Z", "20260110T101000Z") )); } #[test] fn freebusy_components_and_properties() { let fb = "BEGIN:VFREEBUSY\r\nUID:f\r\nDTSTART:20260101T000000Z\r\nDTEND:20260102T000000Z\r\n\ FREEBUSY:20260101T100000Z/PT1H\r\nEND:VFREEBUSY\r\n"; assert!(hit( fb, &range("VFREEBUSY", "20260101T103000Z", "20260101T120000Z") )); assert!(!hit( fb, &range("VFREEBUSY", "20260101T120000Z", "20260101T130000Z") )); let event = "BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=Europe/Berlin:20260101T100000\r\n\ ATTENDEE;PARTSTAT=ACCEPTED;CN=\"Bob, B\":mailto:bob@example.com\r\nEND:VEVENT\r\n"; let prop = |inner: &str| format!(r#"{inner}"#); let partstat = r#"ACCEPTED"#; assert!(hit(event, &prop(partstat))); let cn = r#"bob, b"#; assert!(hit(event, &prop(cn))); let missing = r#""#; assert!(hit(event, &prop(missing))); let dtstart = r#""#; assert!(hit(event, &prop(dtstart))); let no_todo = r#""#; assert!(hit(event, no_todo)); } #[test] fn floating_times_use_the_query_zone() { let body = "BEGIN:VEVENT\r\nUID:f\r\nDTSTART:20260101T100000\r\nDURATION:PT1H\r\nEND:VEVENT\r\n"; let filter = range("VEVENT", "20260101T090000Z", "20260101T093000Z"); let Ok(Report::CalendarQuery { filter, .. }) = parse(query(&filter).as_bytes()) else { panic!(); }; assert!(!matches_calendar(&cal(body), &filter, &Zone::Utc)); let berlin = named("Europe/Berlin").unwrap(); assert!(matches_calendar(&cal(body), &filter, &berlin)); } #[test] fn report_bodies() { let body = br#"1 5"#; let Ok(Report::SyncCollection { token, limit, .. }) = parse(body) else { panic!(); }; assert_eq!((token.as_str(), limit), ("", Some(5))); let body = br#""#; assert_eq!( parse(body).unwrap_err(), Refused::Condition(Name::new(CALDAV, "valid-filter")) ); let body = br#"Nowhere/Else"#; assert_eq!( parse(body).unwrap_err(), Refused::Condition(Name::new(CALDAV, "valid-timezone")) ); let body = br#""#; assert_eq!(parse(body).unwrap_err(), Refused::Invalid); assert!(matches!( parse(b""), Err(Refused::Condition(_)) )); } #[test] fn expand_keeps_dates_and_limit_keeps_used_overrides() { let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ BEGIN:VEVENT\r\nUID:d\r\nDTSTART;VALUE=DATE:20260101\r\nRRULE:FREQ=DAILY;COUNT=5\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID;VALUE=DATE:20260102\r\nDTSTART;VALUE=DATE:20260102\r\nSUMMARY:moved\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID;VALUE=DATE:20260104\r\nDTSTART;VALUE=DATE:20260104\r\nSUMMARY:later\r\nEND:VEVENT\r\n\ END:VCALENDAR\r\n"; let req = |inner: &str| { let xml = format!( r#"{inner}"# ); let Ok(Report::CalendarMultiget { props, .. }) = parse(xml.as_bytes()) else { panic!(); }; props.calendar.unwrap() }; let out = calendar_data( body, &req(r#""#), &Zone::Utc, ) .unwrap() .0; assert!(out.contains("DTSTART;VALUE=DATE:20260102"), "{out}"); assert!(out.contains("RECURRENCE-ID;VALUE=DATE:20260103"), "{out}"); assert!(!out.contains("RRULE") && !out.contains("20260104"), "{out}"); let out = calendar_data( body, &req(r#""#), &Zone::Utc, ) .unwrap() .0; assert!( out.contains("RRULE") && out.contains("moved") && !out.contains("later"), "{out}" ); } #[test] fn address_data_converts_versions() { let card = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c\r\nFN:Bob\r\nTEL;TYPE=CELL:123\r\nEND:VCARD\r\n"; let same = AddressData::default(); assert_eq!(address_data(card, &same), card); let v4 = AddressData { props: None, version: Some(pimdav::calcard::vcard::VCardVersion::V4_0), }; assert!(address_data(card, &v4).contains("VERSION:4.0")); } #[test] fn groups_and_companies_in_apple_forms() { use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0}; let to = |v| AddressData { props: None, version: Some(v), }; let group = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:g\r\nFN:Team\r\nKIND:group\r\n\ MEMBER:urn:uuid:m1\r\nEMAIL;PREF=1;TYPE=work:t@example.com\r\nEND:VCARD\r\n"; let v3 = address_data(group, &to(V3_0)); assert!(v3.contains("VERSION:3.0"), "{v3}"); assert!(v3.contains("X-ADDRESSBOOKSERVER-KIND:group\r\n"), "{v3}"); assert!( v3.contains("X-ADDRESSBOOKSERVER-MEMBER:urn:uuid:m1\r\n"), "{v3}" ); assert!( !v3.contains("\r\nKIND") && !v3.contains("\r\nMEMBER"), "{v3}" ); assert!(v3.contains("TYPE=pref") && !v3.contains("PREF=1"), "{v3}"); // Back to 4.0, as a client asking for it gets an Apple-made card. let back = address_data(&v3, &to(V4_0)); assert!( back.to_ascii_lowercase().contains("\r\nkind:group\r\n"), "{back}" ); assert!(back.contains("MEMBER:urn:uuid:m1\r\n"), "{back}"); assert!( back.contains("PREF=1") && !back.contains("X-ADDRESSBOOKSERVER"), "{back}" ); let org = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:o\r\nFN:ACME\r\nKIND:org\r\nEND:VCARD\r\n"; assert!(address_data(org, &to(V3_0)).contains("X-ABSHOWAS:COMPANY\r\n")); let apple = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:o\r\nFN:ACME\r\nX-ABSHOWAS:COMPANY\r\n\ TEL;TYPE=\"CELL,pref\":1\r\nEND:VCARD\r\n"; let v4 = address_data(apple, &to(V4_0)); assert!(v4.to_ascii_lowercase().contains("\r\nkind:org\r\n"), "{v4}"); assert!(v4.contains("TYPE=CELL") && v4.contains("PREF=1"), "{v4}"); } #[test] fn vcard_three_is_the_default() { use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0}; use pimdav::render::{accepted_version, address_request}; use pimdav::xml::{CARDDAV, el}; assert_eq!( address_request(&el(CARDDAV, "address-data")) .unwrap() .version, Some(V3_0) ); assert_eq!(accepted_version(None), V3_0); assert_eq!(accepted_version(Some("text/vcard")), V3_0); assert_eq!(accepted_version(Some("text/vcard; version=4.0")), V4_0); } #[test] fn card_filters() { let card = VCard::parse( "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Zoë\r\nEMAIL:z@example.com\r\nEND:VCARD\r\n", ) .unwrap(); let parse_filter = |f: &str| { let xml = format!( r#"{f}"# ); let Ok(Report::AddressbookQuery { filter, .. }) = parse(xml.as_bytes()) else { panic!("{f}"); }; filter }; let f = parse_filter( r#"ZO"#, ); assert!(matches_card(&card, &f)); let f = parse_filter( r#"zoë"#, ); assert!(!matches_card(&card, &f)); let f = parse_filter( r#""#, ); assert!(!matches_card(&card, &f)); let f = parse_filter( r#""#, ); assert!(matches_card(&card, &f)); } #[test] fn busy_time() { let body = "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260101T103000Z\r\nDTEND:20260101T120000Z\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:c\r\nDTSTART:20260101T130000Z\r\nDTEND:20260101T140000Z\r\nSTATUS:TENTATIVE\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20260101T150000Z\r\nDTEND:20260101T160000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20260101T170000Z\r\nDTEND:20260101T180000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\n"; let range = utc("2026-01-01T00:00:00")..utc("2026-01-01T13:30:00"); let got = merge(busy(&cal(body), &range, &Zone::Utc, None)); let got: Vec<_> = got.iter().map(|p| (p.kind, p.start, p.end)).collect(); assert_eq!( got, [ ( Busy::Busy, utc("2026-01-01T10:00:00"), utc("2026-01-01T12:00:00") ), ( Busy::Tentative, utc("2026-01-01T13:00:00"), utc("2026-01-01T13:30:00") ), ] ); } #[test] fn unicode_casemap_folds_after_nfkd() { use pimdav::filter::{Collation, MatchType, TextMatch}; let m = |text: &str, collation| TextMatch { text: text.to_string(), collation, match_type: MatchType::Equals, negate: false, }; let unicode = Collation::UnicodeCasemap; assert!(m("Müller", unicode).matches("Mu\u{0308}ller")); assert!(m("file", unicode).matches("FILE")); assert!(m("ix", unicode).matches("\u{2168}")); assert!(m("é", Collation::AsciiCasemap).matches("é")); assert!(!m("é", Collation::AsciiCasemap).matches("É")); } #[test] fn bad_filters_are_valid_filter_conditions() { let bad_range = query( r#""#, ); assert!(matches!( parse(bad_range.as_bytes()), Err(Refused::Condition(n)) if n == Name::new(CALDAV, "valid-filter") )); let bad_test = r#" "#; assert!(matches!( parse(bad_test.as_bytes()), Err(Refused::Condition(n)) if n == Name::new(pimdav::xml::CARDDAV, "valid-filter") )); } #[test] fn busy_time_follows_own_answer() { const ME: &str = "mailto:room@rooms.dovenest.invalid"; let me = |a: &str| a.eq_ignore_ascii_case(ME); let body = format!( "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nATTENDEE;PARTSTAT=DECLINED:{ME}\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260101T120000Z\r\nDTEND:20260101T130000Z\r\nATTENDEE:{ME}\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:c\r\nDTSTART:20260101T140000Z\r\nDTEND:20260101T150000Z\r\nATTENDEE;PARTSTAT=ACCEPTED:{ME}\r\nEND:VEVENT\r\n" ); let range = utc("2026-01-01T00:00:00")..utc("2026-01-02T00:00:00"); let got: Vec<_> = merge(busy(&cal(&body), &range, &Zone::Utc, Some(&me))) .iter() .map(|p| (p.kind, p.start)) .collect(); assert_eq!( got, [ (Busy::Busy, utc("2026-01-01T14:00:00")), (Busy::Tentative, utc("2026-01-01T12:00:00")), ] ); // Without a calendar user, the answers do not count (RFC 4791). assert_eq!(merge(busy(&cal(&body), &range, &Zone::Utc, None)).len(), 3); } #[test] fn outbox_free_busy_request() { use pimdav::freebusy::{reply, request, schedule_response}; let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nMETHOD:REQUEST\r\n\ BEGIN:VFREEBUSY\r\nUID:fb-1\r\nDTSTAMP:20260101T000000Z\r\n\ DTSTART:20260105T000000Z\r\nDTEND:20260106T000000Z\r\n\ ORGANIZER:mailto:alice@dovenest.invalid\r\n\ ATTENDEE:mailto:bob@dovenest.invalid\r\n\ ATTENDEE:mailto:BOB@dovenest.invalid\r\n\ ATTENDEE:mailto:dave@example.com\r\n\ END:VFREEBUSY\r\nEND:VCALENDAR\r\n"; let req = request(body.as_bytes()).unwrap(); assert_eq!( req.range, utc("2026-01-05T00:00:00")..utc("2026-01-06T00:00:00") ); assert_eq!(req.organizer, "mailto:alice@dovenest.invalid"); assert_eq!( req.attendees, ["mailto:bob@dovenest.invalid", "mailto:dave@example.com"] ); let busy = [pimdav::freebusy::Period { kind: Busy::Busy, start: utc("2026-01-05T10:00:00"), end: utc("2026-01-05T11:00:00"), }]; let text = reply(&busy, &req, &req.attendees[0], utc("2026-01-01T00:00:00")); let got = ICalendar::parse(&text).unwrap().to_string(); for line in [ "METHOD:REPLY", "UID:fb-1", "ATTENDEE:mailto:bob@dovenest.invalid", "FREEBUSY;FBTYPE=BUSY:20260105T100000Z/20260105T110000Z", ] { assert!(got.contains(line), "{line} in {got}"); } let xml = schedule_response(&[( req.attendees[1].clone(), "5.2;Invalid calendar service", None, )]); assert!(xml.contains("5.2;Invalid calendar service")); // Not a request, or a request without the parts it needs. for bad in [ body.replace("METHOD:REQUEST", "METHOD:PUBLISH"), body.replace("DTEND:20260106T000000Z\r\n", ""), body.replace("ORGANIZER:mailto:alice@dovenest.invalid\r\n", ""), ] { let err = request(bad.as_bytes()).unwrap_err(); assert!(err.is(CALDAV, "valid-scheduling-message"), "{bad}"); } }