mago_reference/
query.rs

1use std::str::FromStr;
2
3/// Represents different ways to match input text.
4///
5/// - `Exact(String, bool)` matches if the text is exactly equal to the query string.
6/// - `StartsWith(String, bool)` matches if the text starts with the query string.
7/// - `Contains(String, bool)` matches if the text contains the query string.
8/// - `EndsWith(String, bool)` matches if the text ends with the query string.
9///
10/// The `bool` field indicates whether the match should be **case-sensitive** (`true`)
11/// or **case-insensitive** (`false`). By default, it is **case-insensitive** unless the
12/// query string is prefixed with `c:` (for “case-sensitive”) or `i:` (for “case-insensitive”).
13#[derive(Debug, Clone, PartialEq)]
14pub enum Query {
15    /// Variant for an exact match.
16    /// The second parameter specifies if it's case-sensitive.
17    Exact(String, bool),
18    /// Variant for matching if the text starts with the query.
19    /// The second parameter specifies if it's case-sensitive.
20    StartsWith(String, bool),
21    /// Variant for matching if the text contains the query.
22    /// The second parameter specifies if it's case-sensitive.
23    Contains(String, bool),
24    /// Variant for matching if the text ends with the query.
25    /// The second parameter specifies if it's case-sensitive.
26    EndsWith(String, bool),
27}
28
29impl Query {
30    /// Checks whether the given `text` matches this query.
31    ///
32    /// By default, matches are **case-insensitive** unless specified otherwise in the query.
33    /// If `case_sensitive` is `true`, the match must respect exact casing.
34    ///
35    /// # Arguments
36    ///
37    /// * `text` - The text to check against the query.
38    ///
39    /// # Returns
40    ///
41    /// Returns `true` if the text matches according to the query variant (respecting case sensitivity),
42    /// or `false` otherwise.
43    ///
44    /// # Examples
45    ///
46    /// ```
47    /// # use std::str::FromStr;
48    /// # use mago_reference::query::Query;
49    ///
50    /// // Case-insensitive exact match example:
51    /// let query = Query::from_str("=Hello").unwrap();
52    /// assert!(query.matches("hello"));
53    ///
54    /// // Case-sensitive contains example:
55    /// let query = Query::from_str("c:ell").unwrap();
56    /// assert!(query.matches("Well")); // false
57    /// assert!(query.matches("ell"));  // true if text is exactly "ell"
58    /// ```
59    pub fn matches(&self, text: &str) -> bool {
60        match self {
61            Query::Exact(q, case_sensitive) => {
62                if *case_sensitive {
63                    text == q
64                } else {
65                    text.eq_ignore_ascii_case(q)
66                }
67            }
68            Query::StartsWith(q, case_sensitive) => {
69                if *case_sensitive {
70                    text.starts_with(q)
71                } else {
72                    text.to_ascii_lowercase().starts_with(&q.to_ascii_lowercase())
73                }
74            }
75            Query::Contains(q, case_sensitive) => {
76                if *case_sensitive {
77                    text.contains(q)
78                } else {
79                    text.to_ascii_lowercase().contains(&q.to_ascii_lowercase())
80                }
81            }
82            Query::EndsWith(q, case_sensitive) => {
83                if *case_sensitive {
84                    text.ends_with(q)
85                } else {
86                    text.to_ascii_lowercase().ends_with(&q.to_ascii_lowercase())
87                }
88            }
89        }
90    }
91}
92
93impl FromStr for Query {
94    type Err = ();
95
96    /// Parses a string slice into a `Query` with optional case sensitivity.
97    ///
98    /// The parsing follows a simple syntax:
99    ///
100    /// - **Case Sensitivity**:
101    ///   - If the string starts with `"c:"`, the query is **case-sensitive**.
102    ///   - If it starts with `"i:"`, the query is explicitly case-insensitive.
103    ///   - Otherwise, it defaults to **case-insensitive**.
104    ///
105    /// - **Match Variants**:
106    ///   - If, after removing any `c:` or `i:` prefix, the string starts with `=`
107    ///     => parsed as `Exact(...)`.
108    ///   - If it starts with `^`
109    ///     => parsed as `StartsWith(...)`.
110    ///   - If it ends with `$`
111    ///     => parsed as `EndsWith(...)`.
112    ///   - Otherwise, it is parsed as `Contains(...)`.
113    ///
114    /// Whitespace is trimmed, and an empty input results in an error.
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// # use std::str::FromStr;
120    /// # use mago_reference::query::Query;
121    ///
122    /// // Case-insensitive exact match
123    /// let query = Query::from_str("=Exact").unwrap();
124    /// if let Query::Exact(ref s, false) = query {
125    ///     assert_eq!(s, "Exact");
126    /// } else {
127    ///     panic!("Expected case-insensitive Exact variant");
128    /// }
129    ///
130    /// // Case-sensitive starts-with match
131    /// let query = Query::from_str("c:^Hello").unwrap();
132    /// if let Query::StartsWith(ref s, true) = query {
133    ///     assert_eq!(s, "Hello");
134    /// } else {
135    ///     panic!("Expected case-sensitive StartsWith variant");
136    /// }
137    /// ```
138    fn from_str(s: &str) -> Result<Self, Self::Err> {
139        // 1) Trim whitespace
140        let trimmed = s.trim();
141        if trimmed.is_empty() {
142            return Err(());
143        }
144
145        // 2) Determine case sensitivity
146        let (remaining, case_sensitive) = if let Some(rest) = trimmed.strip_prefix("c:") {
147            (rest, true)
148        } else if let Some(rest) = trimmed.strip_prefix("i:") {
149            (rest, false)
150        } else {
151            (trimmed, false) // default case-insensitive
152        };
153
154        // 3) Inspect the resulting string to see which variant we need
155        // Lowercase not mandatory for case-sensitive scenario, but needed to check
156        // special prefix/suffix markers only. We'll treat them consistently.
157        let lower = remaining.to_ascii_lowercase();
158
159        if lower.is_empty() {
160            return Err(());
161        }
162
163        // Exact if starts with '='
164        if let Some(rest) = remaining.strip_prefix('=') {
165            Ok(Query::Exact(rest.to_string(), case_sensitive))
166        }
167        // StartsWith if starts with '^'
168        else if let Some(rest) = remaining.strip_prefix('^') {
169            Ok(Query::StartsWith(rest.to_string(), case_sensitive))
170        }
171        // EndsWith if ends with '$'
172        else if lower.ends_with('$') {
173            let query_str = &remaining[..remaining.len() - 1];
174            Ok(Query::EndsWith(query_str.to_string(), case_sensitive))
175        }
176        // Otherwise, it's Contains
177        else {
178            Ok(Query::Contains(remaining.to_string(), case_sensitive))
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use std::str::FromStr;
187
188    #[test]
189    fn test_case_sensitive_exact() {
190        let query = Query::from_str("c:=Hello").unwrap();
191        match &query {
192            Query::Exact(s, true) => assert_eq!(s, "Hello"),
193            _ => panic!("Expected case-sensitive Exact variant"),
194        }
195
196        assert!(query.matches("Hello"));
197        assert!(!query.matches("hello"));
198    }
199
200    #[test]
201    fn test_case_insensitive_default() {
202        // =Hello without c: => case-insensitive
203        let query = Query::from_str("=Hello").unwrap();
204        match &query {
205            Query::Exact(s, false) => assert_eq!(s, "Hello"),
206            _ => panic!("Expected case-insensitive Exact variant"),
207        }
208
209        assert!(query.matches("hello"));
210        assert!(query.matches("HELLO"));
211    }
212
213    #[test]
214    fn test_starts_with_case_sensitive() {
215        let query = Query::from_str("c:^Hell").unwrap();
216        match &query {
217            Query::StartsWith(s, true) => assert_eq!(s, "Hell"),
218            _ => panic!("Expected case-sensitive StartsWith variant"),
219        }
220        assert!(query.matches("Hellworld"));
221        assert!(!query.matches("helloworld")); // different case
222    }
223
224    #[test]
225    fn test_contains_case_insensitive() {
226        let query = Query::from_str("CoNtEnT").unwrap();
227        match &query {
228            Query::Contains(s, false) => assert_eq!(s, "CoNtEnT"),
229            _ => panic!("Expected case-insensitive Contains variant"),
230        }
231        assert!(query.matches("this content is here"));
232        assert!(query.matches("CONTENT!"));
233    }
234
235    #[test]
236    fn test_ends_with() {
237        let query = Query::from_str("something$").unwrap();
238        match &query {
239            Query::EndsWith(s, false) => assert_eq!(s, "something"),
240            _ => panic!("Expected ends-with, case-insensitive"),
241        }
242
243        assert!(query.matches("ANYTHING someThing"));
244    }
245
246    #[test]
247    fn test_empty_string_error() {
248        assert!(Query::from_str("  ").is_err());
249    }
250}