Skip to main content

talon_cli/cli/
where_clause.rs

1use talon_core::{WhereClause, WhereOperator};
2
3/// Parses a `--where` string into a [`WhereClause`].
4///
5/// Format: `KEY OP VALUE` (space-separated, but multi-char operators like
6/// `^=` and `~=` can be glued to the key: `path^=prefix`).
7/// `exists` takes only one token: `KEY exists`.
8///
9/// # Errors
10///
11/// Returns an error string if the format is invalid or the operator is unknown.
12pub fn parse_where_clause(value: &str) -> Result<WhereClause, String> {
13    let mut parts = value.splitn(3, ' ');
14
15    // First token is always part of the key (possibly with a glued operator).
16    let Some(key_with_op) = parts.next().filter(|k| !k.is_empty()) else {
17        return Err(invalid_clause(value));
18    };
19
20    // Second token is either the operator or part of the value.
21    let second_token = parts.next();
22
23    // Try to extract a multi-char operator from within the first token.
24    if let Some((key, op_str)) = split_operator_in_token(key_with_op) {
25        let op = parse_operator(op_str)?;
26        // The value is either the remaining iterator token OR (if no space-separated
27        // token exists) the remainder of key_with_op after the operator.
28        let value_part = match second_token {
29            Some(v) if !v.is_empty() => Some(v),
30            _ => {
31                // No space separator — value is glued to the operator.
32                // e.g. "path^=Templates/" → key="path", op="^=", value="Templates/"
33                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    // First token is a plain key; second token must be the operator.
41    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
49/// If `token` contains a known multi-char operator (`^=` or `~=`),
50/// split it into `(key, operator)`. Returns `None` if no match.
51fn split_operator_in_token(token: &str) -> Option<(&str, &str)> {
52    // Check for ^= — find '^' and verify it's followed by '='.
53    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    // Check for ~= — find '~=' anywhere.
62    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}