From 17ba8faef27d883401ce542fc27afa0c724ae7d2 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Wed, 31 Dec 2025 12:24:57 +0100 Subject: [PATCH] text-match: Support match types and unicode-casemap collation --- .../report/calendar_query/comp_filter.rs | 5 +- .../methods/report/calendar_query/mod.rs | 6 +- crates/caldav/src/calendar/prop.rs | 1 + ...l_caldav__calendar__tests__propfind-2.snap | 1 + crates/dav/src/xml/text_match.rs | 74 ++++++++++++++++--- ...ests__caldav__calendar__propfind_body.snap | 1 + 6 files changed, 74 insertions(+), 14 deletions(-) diff --git a/crates/caldav/src/calendar/methods/report/calendar_query/comp_filter.rs b/crates/caldav/src/calendar/methods/report/calendar_query/comp_filter.rs index dc0e2e5..cc34a88 100644 --- a/crates/caldav/src/calendar/methods/report/calendar_query/comp_filter.rs +++ b/crates/caldav/src/calendar/methods/report/calendar_query/comp_filter.rs @@ -137,7 +137,7 @@ impl CompFilterable for CalendarObjectComponent { #[cfg(test)] mod tests { use chrono::{TimeZone, Utc}; - use rustical_dav::xml::{NegateCondition, TextCollation, TextMatchElement}; + use rustical_dav::xml::{MatchType, NegateCondition, TextCollation, TextMatchElement}; use rustical_ical::{CalendarObject, UtcDateTime}; use crate::calendar::methods::report::calendar_query::{ @@ -217,6 +217,7 @@ END:VCALENDAR"; name: "VERSION".to_string(), time_range: None, text_match: Some(TextMatchElement { + match_type: MatchType::Contains, needle: "2.0".to_string(), collation: TextCollation::default(), negate_condition: NegateCondition::default(), @@ -240,6 +241,7 @@ END:VCALENDAR"; name: "SUMMARY".to_string(), time_range: None, text_match: Some(TextMatchElement { + match_type: MatchType::Contains, collation: TextCollation::default(), negate_condition: NegateCondition(false), needle: "weekly".to_string(), @@ -327,6 +329,7 @@ END:VCALENDAR"; name: "TZID".to_string(), time_range: None, text_match: Some(TextMatchElement { + match_type: MatchType::Contains, collation: TextCollation::AsciiCasemap, negate_condition: NegateCondition::default(), needle: "Europe/Berlin".to_string(), diff --git a/crates/caldav/src/calendar/methods/report/calendar_query/mod.rs b/crates/caldav/src/calendar/methods/report/calendar_query/mod.rs index 6e23e78..11d081b 100644 --- a/crates/caldav/src/calendar/methods/report/calendar_query/mod.rs +++ b/crates/caldav/src/calendar/methods/report/calendar_query/mod.rs @@ -36,7 +36,9 @@ mod tests { calendar::methods::report::ReportRequest, calendar_object::{CalendarData, CalendarObjectPropName, CalendarObjectPropWrapperName}, }; - use rustical_dav::xml::{NegateCondition, PropElement, TextCollation, TextMatchElement}; + use rustical_dav::xml::{ + MatchType, NegateCondition, PropElement, TextCollation, TextMatchElement, + }; use rustical_xml::XmlDocument; #[test] @@ -93,6 +95,7 @@ mod tests { prop_filter: vec![PropFilterElement { name: "ATTENDEE".to_owned(), text_match: Some(TextMatchElement { + match_type: MatchType::Contains, collation: TextCollation::AsciiCasemap, negate_condition: NegateCondition(false), needle: "mailto:lisa@example.com".to_string() @@ -102,6 +105,7 @@ mod tests { is_not_defined: None, name: "PARTSTAT".to_owned(), text_match: Some(TextMatchElement { + match_type: MatchType::Contains, collation: TextCollation::AsciiCasemap, negate_condition: NegateCondition(false), needle: "NEEDS-ACTION".to_string() diff --git a/crates/caldav/src/calendar/prop.rs b/crates/caldav/src/calendar/prop.rs index efe3085..e87222b 100644 --- a/crates/caldav/src/calendar/prop.rs +++ b/crates/caldav/src/calendar/prop.rs @@ -54,6 +54,7 @@ impl Default for SupportedCollationSet { fn default() -> Self { Self(vec![ SupportedCollation(TextCollation::AsciiCasemap), + SupportedCollation(TextCollation::UnicodeCasemap), SupportedCollation(TextCollation::Octet), ]) } diff --git a/crates/caldav/src/calendar/snapshots/rustical_caldav__calendar__tests__propfind-2.snap b/crates/caldav/src/calendar/snapshots/rustical_caldav__calendar__tests__propfind-2.snap index 4ea5e6b..4710238 100644 --- a/crates/caldav/src/calendar/snapshots/rustical_caldav__calendar__tests__propfind-2.snap +++ b/crates/caldav/src/calendar/snapshots/rustical_caldav__calendar__tests__propfind-2.snap @@ -132,6 +132,7 @@ END:VCALENDAR i;ascii-casemap + i;unicode-casemap i;octet 10000000 diff --git a/crates/dav/src/xml/text_match.rs b/crates/dav/src/xml/text_match.rs index b79c634..3c88e18 100644 --- a/crates/dav/src/xml/text_match.rs +++ b/crates/dav/src/xml/text_match.rs @@ -1,23 +1,23 @@ use ical::property::Property; use rustical_xml::{ValueDeserialize, XmlDeserialize}; +use std::borrow::Cow; #[derive(Clone, Debug, PartialEq, Eq, Default)] pub enum TextCollation { #[default] AsciiCasemap, + UnicodeCasemap, Octet, } impl TextCollation { - // Check whether a haystack contains a needle respecting the collation #[must_use] - pub fn match_text(&self, needle: &str, haystack: &str) -> bool { + pub fn normalise<'a>(&self, value: &'a str) -> Cow<'a, str> { match self { // https://datatracker.ietf.org/doc/html/rfc4790#section-9.2 - Self::AsciiCasemap => haystack - .to_ascii_uppercase() - .contains(&needle.to_ascii_uppercase()), - Self::Octet => haystack.contains(needle), + Self::AsciiCasemap => Cow::from(value.to_ascii_uppercase()), + Self::UnicodeCasemap => Cow::from(value.to_uppercase()), + Self::Octet => Cow::from(value), } } } @@ -26,6 +26,7 @@ impl AsRef for TextCollation { fn as_ref(&self) -> &str { match self { Self::AsciiCasemap => "i;ascii-casemap", + Self::UnicodeCasemap => "i;unicode-casemap", Self::Octet => "i;octet", } } @@ -35,6 +36,7 @@ impl ValueDeserialize for TextCollation { fn deserialize(val: &str) -> Result { match val { "i;ascii-casemap" => Ok(Self::AsciiCasemap), + "i;unicode-casemap" => Ok(Self::UnicodeCasemap), "i;octet" => Ok(Self::Octet), _ => Err(rustical_xml::XmlError::InvalidVariant(format!( "Invalid collation: {val}" @@ -58,6 +60,45 @@ impl ValueDeserialize for NegateCondition { } } +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub enum MatchType { + Equals, + #[default] + Contains, + StartsWith, + EndsWith, +} + +impl MatchType { + pub fn match_text(&self, collation: &TextCollation, needle: &str, haystack: &str) -> bool { + let haystack = collation.normalise(haystack); + let needle = collation.normalise(needle); + + match &self { + Self::Equals => haystack == needle, + Self::Contains => haystack.contains(needle.as_ref()), + Self::StartsWith => haystack.starts_with(needle.as_ref()), + Self::EndsWith => haystack.ends_with(needle.as_ref()), + } + } +} + +impl ValueDeserialize for MatchType { + fn deserialize(val: &str) -> Result { + Ok(match val { + "equals" => Self::Equals, + "contains" => Self::Contains, + "starts-with" => Self::StartsWith, + "ends-with" => Self::EndsWith, + _ => { + return Err(rustical_xml::XmlError::InvalidVariant(format!( + "Invalid match-type parameter: {val}" + ))); + } + }) + } +} + #[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)] #[allow(dead_code)] pub struct TextMatchElement { @@ -65,6 +106,8 @@ pub struct TextMatchElement { pub collation: TextCollation, #[xml(ty = "attr", default = "Default::default")] pub negate_condition: NegateCondition, + #[xml(ty = "attr", default = "Default::default")] + pub match_type: MatchType, #[xml(ty = "text")] pub needle: String, } @@ -76,12 +119,13 @@ impl TextMatchElement { collation, negate_condition, needle, + match_type, } = self; let matches = property .value .as_ref() - .is_some_and(|haystack| collation.match_text(needle, haystack)); + .is_some_and(|haystack| match_type.match_text(collation, needle, haystack)); // XOR negate_condition.0 ^ matches @@ -90,14 +134,20 @@ impl TextMatchElement { #[cfg(test)] mod tests { + use crate::xml::MatchType; + use super::TextCollation; #[test] fn test_collation() { - assert!(TextCollation::AsciiCasemap.match_text("GrüN", "grün")); - assert!(!TextCollation::AsciiCasemap.match_text("GrÜN", "grün")); - assert!(!TextCollation::Octet.match_text("GrÜN", "grün")); - assert!(TextCollation::Octet.match_text("hallo", "hallo")); - assert!(TextCollation::AsciiCasemap.match_text("HaLlo", "hAllo")); + assert!(!MatchType::Contains.match_text(&TextCollation::AsciiCasemap, "GrÜN", "grünsd")); + assert!(MatchType::Contains.match_text(&TextCollation::AsciiCasemap, "GrüN", "grün")); + assert!(!MatchType::Contains.match_text(&TextCollation::Octet, "GrüN", "grün")); + assert!(MatchType::Contains.match_text(&TextCollation::UnicodeCasemap, "GrÜN", "grün")); + assert!(MatchType::Contains.match_text(&TextCollation::AsciiCasemap, "GrüN", "grün")); + assert!(MatchType::Contains.match_text(&TextCollation::AsciiCasemap, "GrüN", "grün")); + assert!(MatchType::StartsWith.match_text(&TextCollation::Octet, "hello", "hello you")); + assert!(MatchType::EndsWith.match_text(&TextCollation::Octet, "joe", "joe mama")); + assert!(MatchType::Equals.match_text(&TextCollation::UnicodeCasemap, "GrÜN", "grün")); } } diff --git a/src/integration_tests/caldav/snapshots/rustical__integration_tests__caldav__calendar__propfind_body.snap b/src/integration_tests/caldav/snapshots/rustical__integration_tests__caldav__calendar__propfind_body.snap index 86b6945..fa54706 100644 --- a/src/integration_tests/caldav/snapshots/rustical__integration_tests__caldav__calendar__propfind_body.snap +++ b/src/integration_tests/caldav/snapshots/rustical__integration_tests__caldav__calendar__propfind_body.snap @@ -136,6 +136,7 @@ END:VCALENDAR i;ascii-casemap + i;unicode-casemap i;octet 10000000