talon_cli/cli/
where_clause.rs1use talon_core::{WhereClause, WhereOperator};
2
3pub fn parse_where_clause(value: &str) -> Result<WhereClause, String> {
13 let mut parts = value.splitn(3, ' ');
14
15 let Some(key_with_op) = parts.next().filter(|k| !k.is_empty()) else {
17 return Err(invalid_clause(value));
18 };
19
20 let second_token = parts.next();
22
23 if let Some((key, op_str)) = split_operator_in_token(key_with_op) {
25 let op = parse_operator(op_str)?;
26 let value_part = match second_token {
29 Some(v) if !v.is_empty() => Some(v),
30 _ => {
31 let prefix_len = key.len();
34 Some(&key_with_op[prefix_len + op_str.len()..])
35 }
36 };
37 return finish_clause(key, op, value_part, value);
38 }
39
40 let Some(operator_str) = second_token.filter(|o| !o.is_empty()) else {
42 return Err(invalid_clause(value));
43 };
44
45 let op = parse_operator(operator_str)?;
46 finish_clause(key_with_op, op, parts.next(), value)
47}
48
49fn split_operator_in_token(token: &str) -> Option<(&str, &str)> {
52 if let Some(pos) = token.find('^')
54 && token.get(pos + 1..=pos + 1) == Some("=")
55 {
56 let key = &token[..pos];
57 if !key.is_empty() {
58 return Some((key, "^="));
59 }
60 }
61 if let Some(pos) = token.find("~=") {
63 let key = &token[..pos];
64 if !key.is_empty() {
65 return Some((key, "~="));
66 }
67 }
68 None
69}
70
71fn finish_clause(
72 key: &str,
73 op: WhereOperator,
74 value_part: Option<&str>,
75 clause: &str,
76) -> Result<WhereClause, String> {
77 if op == WhereOperator::Exists {
78 return parse_exists_clause(key, op, value_part, clause);
79 }
80
81 let Some(value_part) = value_part.filter(|v| !v.is_empty()) else {
82 return Err(format!(
83 "operator '{}' requires a value in '{}'",
84 op_display(op),
85 clause
86 ));
87 };
88
89 Ok(WhereClause {
90 key: key.to_string(),
91 op,
92 value: Some(value_part.to_string()),
93 })
94}
95
96fn parse_operator(operator: &str) -> Result<WhereOperator, String> {
97 match operator {
98 "=" => Ok(WhereOperator::Equals),
99 "!=" => Ok(WhereOperator::NotEquals),
100 "<" => Ok(WhereOperator::LessThan),
101 "<=" => Ok(WhereOperator::LessThanOrEqual),
102 ">" => Ok(WhereOperator::GreaterThan),
103 ">=" => Ok(WhereOperator::GreaterThanOrEqual),
104 "contains" => Ok(WhereOperator::Contains),
105 "exists" => Ok(WhereOperator::Exists),
106 "^=" => Ok(WhereOperator::StartsWith),
107 "~=" => Ok(WhereOperator::GlobMatch),
108 other => Err(format!(
109 "unknown operator '{other}'; try =, !=, <, <=, >, >=, contains, exists, ^=, ~="
110 )),
111 }
112}
113
114const fn op_display(op: WhereOperator) -> &'static str {
115 match op {
116 WhereOperator::Equals => "=",
117 WhereOperator::NotEquals => "!=",
118 WhereOperator::LessThan => "<",
119 WhereOperator::LessThanOrEqual => "<=",
120 WhereOperator::GreaterThan => ">",
121 WhereOperator::GreaterThanOrEqual => ">=",
122 WhereOperator::Contains => "contains",
123 WhereOperator::Exists => "exists",
124 WhereOperator::StartsWith => "^=",
125 WhereOperator::GlobMatch => "~=",
126 }
127}
128
129fn parse_exists_clause(
130 key: &str,
131 op: WhereOperator,
132 value_part: Option<&str>,
133 clause: &str,
134) -> Result<WhereClause, String> {
135 if value_part.is_some() {
136 return Err(format!(
137 "operator 'exists' does not accept a value in '{clause}'"
138 ));
139 }
140 Ok(WhereClause {
141 key: key.to_string(),
142 op,
143 value: None,
144 })
145}
146
147fn invalid_clause(value: &str) -> String {
148 format!("invalid where clause '{value}'; expected 'KEY OP VALUE' or 'KEY exists'")
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn parse_where_clause_accepts_value_operator() {
157 let Ok(clause) = parse_where_clause("status = archived") else {
158 panic!("valid where clause should parse");
159 };
160
161 assert_eq!(clause.key, "status");
162 assert_eq!(clause.op, WhereOperator::Equals);
163 assert_eq!(clause.value.as_deref(), Some("archived"));
164 }
165
166 #[test]
167 fn parse_where_clause_accepts_exists_without_value() {
168 let Ok(clause) = parse_where_clause("source exists") else {
169 panic!("valid where clause should parse");
170 };
171
172 assert_eq!(clause.key, "source");
173 assert_eq!(clause.op, WhereOperator::Exists);
174 assert_eq!(clause.value, None);
175 }
176
177 #[test]
178 fn parse_where_clause_accepts_prefix_operator_glued() {
179 let Ok(clause) = parse_where_clause("path^=Patients/") else {
180 panic!("valid where clause should parse");
181 };
182
183 assert_eq!(clause.key, "path");
184 assert_eq!(clause.op, WhereOperator::StartsWith);
185 assert_eq!(clause.value.as_deref(), Some("Patients/"));
186 }
187
188 #[test]
189 fn parse_where_clause_accepts_prefix_operator_spaced() {
190 let Ok(clause) = parse_where_clause("path ^= Patients/") else {
191 panic!("valid where clause should parse");
192 };
193
194 assert_eq!(clause.key, "path");
195 assert_eq!(clause.op, WhereOperator::StartsWith);
196 assert_eq!(clause.value.as_deref(), Some("Patients/"));
197 }
198
199 #[test]
200 fn parse_where_clause_accepts_glob_operator_glued() {
201 let Ok(clause) = parse_where_clause("path~='Templates/**'") else {
202 panic!("valid where clause should parse");
203 };
204
205 assert_eq!(clause.key, "path");
206 assert_eq!(clause.op, WhereOperator::GlobMatch);
207 assert_eq!(clause.value.as_deref(), Some("'Templates/**'"));
208 }
209
210 #[test]
211 fn parse_where_clause_accepts_glob_operator_spaced() {
212 let Ok(clause) = parse_where_clause("path ~= Templates/**") else {
213 panic!("valid where clause should parse");
214 };
215
216 assert_eq!(clause.key, "path");
217 assert_eq!(clause.op, WhereOperator::GlobMatch);
218 assert_eq!(clause.value.as_deref(), Some("Templates/**"));
219 }
220
221 #[test]
222 fn parse_where_clause_rejects_missing_value() {
223 let Err(err) = parse_where_clause("status =") else {
224 panic!("missing value should fail");
225 };
226
227 assert!(err.contains("requires a value"));
228 }
229
230 #[test]
231 fn parse_where_clause_rejects_exists_value() {
232 let Err(err) = parse_where_clause("source exists yes") else {
233 panic!("exists value should fail");
234 };
235
236 assert!(err.contains("does not accept a value"));
237 }
238}