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}