Skip to main content

rustical_dav/xml/
text_match.rs

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