rustical_dav/xml/
text_match.rs

1use caldata::parser::ContentLine;
2use rustical_xml::{ValueDeserialize, XmlDeserialize};
3use std::borrow::Cow;
4
5#[derive(Clone, Debug, PartialEq, Eq, Default)]
6pub enum TextCollation {
7    #[default]
8    AsciiCasemap,
9    UnicodeCasemap,
10    Octet,
11}
12
13impl TextCollation {
14    #[must_use]
15    pub fn normalise<'a>(&self, value: &'a str) -> Cow<'a, str> {
16        match self {
17            // https://datatracker.ietf.org/doc/html/rfc4790#section-9.2
18            Self::AsciiCasemap => Cow::from(value.to_ascii_uppercase()),
19            Self::UnicodeCasemap => Cow::from(value.to_uppercase()),
20            Self::Octet => Cow::from(value),
21        }
22    }
23}
24
25impl AsRef<str> for TextCollation {
26    fn as_ref(&self) -> &str {
27        match self {
28            Self::AsciiCasemap => "i;ascii-casemap",
29            Self::UnicodeCasemap => "i;unicode-casemap",
30            Self::Octet => "i;octet",
31        }
32    }
33}
34
35impl ValueDeserialize for TextCollation {
36    fn deserialize(val: &str) -> Result<Self, rustical_xml::XmlError> {
37        match val {
38            "i;ascii-casemap" => Ok(Self::AsciiCasemap),
39            "i;unicode-casemap" => Ok(Self::UnicodeCasemap),
40            "i;octet" => Ok(Self::Octet),
41            _ => Err(rustical_xml::XmlError::InvalidVariant(format!(
42                "Invalid collation: {val}"
43            ))),
44        }
45    }
46}
47
48#[derive(Clone, Debug, PartialEq, Eq, Default)]
49pub struct NegateCondition(pub bool);
50
51impl ValueDeserialize for NegateCondition {
52    fn deserialize(val: &str) -> Result<Self, rustical_xml::XmlError> {
53        match val {
54            "yes" => Ok(Self(true)),
55            "no" => Ok(Self(false)),
56            _ => Err(rustical_xml::XmlError::InvalidVariant(format!(
57                "Invalid negate-condition parameter: {val}"
58            ))),
59        }
60    }
61}
62
63#[derive(Clone, Debug, PartialEq, Eq, Default)]
64pub enum MatchType {
65    Equals,
66    #[default]
67    Contains,
68    StartsWith,
69    EndsWith,
70}
71
72impl MatchType {
73    #[must_use]
74    pub fn match_text(&self, collation: &TextCollation, needle: &str, haystack: &str) -> bool {
75        let haystack = collation.normalise(haystack);
76        let needle = collation.normalise(needle);
77
78        match &self {
79            Self::Equals => haystack == needle,
80            Self::Contains => haystack.contains(needle.as_ref()),
81            Self::StartsWith => haystack.starts_with(needle.as_ref()),
82            Self::EndsWith => haystack.ends_with(needle.as_ref()),
83        }
84    }
85}
86
87impl ValueDeserialize for MatchType {
88    fn deserialize(val: &str) -> Result<Self, rustical_xml::XmlError> {
89        Ok(match val {
90            "equals" => Self::Equals,
91            "contains" => Self::Contains,
92            "starts-with" => Self::StartsWith,
93            "ends-with" => Self::EndsWith,
94            _ => {
95                return Err(rustical_xml::XmlError::InvalidVariant(format!(
96                    "Invalid match-type parameter: {val}"
97                )));
98            }
99        })
100    }
101}
102
103#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
104#[allow(dead_code)]
105pub struct TextMatchElement {
106    #[xml(ty = "attr", default = "Default::default")]
107    pub collation: TextCollation,
108    #[xml(ty = "attr", default = "Default::default")]
109    pub negate_condition: NegateCondition,
110    #[xml(ty = "attr", default = "Default::default")]
111    pub match_type: MatchType,
112    #[xml(ty = "text")]
113    pub needle: String,
114}
115
116impl TextMatchElement {
117    #[must_use]
118    pub fn match_text(&self, haystack: &str) -> bool {
119        let Self {
120            collation,
121            negate_condition,
122            needle,
123            match_type,
124        } = self;
125
126        let matches = match_type.match_text(collation, needle, haystack);
127        // XOR
128        negate_condition.0 ^ matches
129    }
130    #[must_use]
131    pub fn match_property(&self, property: &ContentLine) -> bool {
132        let text = property.value.as_deref().unwrap_or("");
133        self.match_text(text)
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::TextCollation;
140    use crate::xml::MatchType;
141
142    #[test]
143    fn test_collation() {
144        assert!(!MatchType::Contains.match_text(&TextCollation::AsciiCasemap, "GrÜN", "grünsd"));
145        assert!(MatchType::Contains.match_text(&TextCollation::AsciiCasemap, "GrüN", "grün"));
146        assert!(!MatchType::Contains.match_text(&TextCollation::Octet, "GrüN", "grün"));
147        assert!(MatchType::Contains.match_text(&TextCollation::UnicodeCasemap, "GrÜN", "grün"));
148        assert!(MatchType::Contains.match_text(&TextCollation::AsciiCasemap, "GrüN", "grün"));
149        assert!(MatchType::Contains.match_text(&TextCollation::AsciiCasemap, "GrüN", "grün"));
150        assert!(MatchType::StartsWith.match_text(&TextCollation::Octet, "hello", "hello you"));
151        assert!(MatchType::EndsWith.match_text(&TextCollation::Octet, "mama", "joe mama"));
152        assert!(MatchType::Equals.match_text(&TextCollation::UnicodeCasemap, "GrÜN", "grün"));
153    }
154}