Skip to main content

osp_cli/dsl/eval/
matchers.rs

1use crate::core::row::Row;
2use std::collections::HashSet;
3
4use crate::dsl::parse::key_spec::ExactMode;
5use crate::dsl::parse::path::{PathExpression, Selector, parse_path};
6
7#[derive(Debug, Clone, Default, PartialEq, Eq)]
8pub struct KeyMatches {
9    pub exact: Vec<String>,
10    pub partial: Vec<String>,
11}
12
13pub fn match_row_keys<'a>(row: &'a Row, token: &str, exact: ExactMode) -> Vec<&'a str> {
14    let matches = compute_key_matches(row, token, exact);
15    let selected = if !matches.exact.is_empty() {
16        matches.exact
17    } else {
18        matches.partial
19    };
20    if selected.is_empty() {
21        return Vec::new();
22    }
23
24    let selected = selected.into_iter().collect::<HashSet<_>>();
25    row.keys()
26        .filter_map(|key| {
27            if selected.contains(key) {
28                Some(key.as_str())
29            } else {
30                None
31            }
32        })
33        .collect()
34}
35
36pub fn match_row_keys_detailed(row: &Row, token: &str, exact: ExactMode) -> KeyMatches {
37    compute_key_matches(row, token, exact)
38}
39
40pub fn value_contains(value: &serde_json::Value, query: &str, case_sensitive: bool) -> bool {
41    match value {
42        serde_json::Value::Array(values) => values
43            .iter()
44            .any(|item| value_contains(item, query, case_sensitive)),
45        _ => {
46            let rendered = render_value(value);
47            if case_sensitive {
48                rendered.contains(query)
49            } else {
50                rendered
51                    .to_ascii_lowercase()
52                    .contains(&query.to_ascii_lowercase())
53            }
54        }
55    }
56}
57
58pub fn render_value(value: &serde_json::Value) -> String {
59    match value {
60        serde_json::Value::Null => "null".to_string(),
61        serde_json::Value::Bool(v) => v.to_string(),
62        serde_json::Value::Number(v) => v.to_string(),
63        serde_json::Value::String(v) => v.clone(),
64        serde_json::Value::Array(_) | serde_json::Value::Object(_) => value.to_string(),
65    }
66}
67
68fn expression_segments(expr: &PathExpression, case_sensitive: bool) -> Vec<String> {
69    expr.segments
70        .iter()
71        .filter_map(|segment| segment.name.as_ref())
72        .map(|name| {
73            if case_sensitive {
74                name.clone()
75            } else {
76                name.to_ascii_lowercase()
77            }
78        })
79        .collect()
80}
81
82fn key_segments(key: &str, case_sensitive: bool) -> Vec<String> {
83    let mut segments = Vec::new();
84    let mut current = String::new();
85    let mut in_brackets = false;
86
87    for ch in key.chars() {
88        match ch {
89            '.' if !in_brackets => {
90                push_segment(&mut segments, &mut current, case_sensitive);
91            }
92            '[' => {
93                push_segment(&mut segments, &mut current, case_sensitive);
94                in_brackets = true;
95                current.clear();
96            }
97            ']' => {
98                in_brackets = false;
99                current.clear();
100            }
101            _ => {
102                if !in_brackets {
103                    current.push(ch);
104                }
105            }
106        }
107    }
108
109    push_segment(&mut segments, &mut current, case_sensitive);
110    segments
111}
112
113fn push_segment(segments: &mut Vec<String>, current: &mut String, case_sensitive: bool) {
114    if current.is_empty() {
115        return;
116    }
117    let segment = if case_sensitive {
118        current.clone()
119    } else {
120        current.to_ascii_lowercase()
121    };
122    segments.push(segment);
123    current.clear();
124}
125
126fn segments_match(seq: &[String], pattern: &[String], absolute: bool) -> bool {
127    if pattern.is_empty() {
128        return false;
129    }
130    if absolute {
131        if seq.len() < pattern.len() {
132            return false;
133        }
134        return seq[..pattern.len()] == *pattern;
135    }
136
137    // Relative paths use segment-subsequence matching: the pattern must appear
138    // in order, but other segments may exist in between.
139    let mut pos = 0usize;
140    for segment in pattern {
141        while pos < seq.len() && &seq[pos] != segment {
142            pos += 1;
143        }
144        if pos == seq.len() {
145            return false;
146        }
147        pos += 1;
148    }
149    true
150}
151
152fn matches_expression_with_selectors(
153    key: &str,
154    expr: &PathExpression,
155    case_sensitive: bool,
156) -> bool {
157    let Ok(key_expr) = parse_path(key) else {
158        return false;
159    };
160    if key_expr.segments.len() != expr.segments.len() {
161        return false;
162    }
163
164    for (key_segment, expr_segment) in key_expr.segments.iter().zip(expr.segments.iter()) {
165        if !names_match(&key_segment.name, &expr_segment.name, case_sensitive) {
166            return false;
167        }
168        if !selectors_match(&key_segment.selectors, &expr_segment.selectors) {
169            return false;
170        }
171    }
172    true
173}
174
175fn names_match(left: &Option<String>, right: &Option<String>, case_sensitive: bool) -> bool {
176    match (left, right) {
177        (Some(left), Some(right)) => {
178            if case_sensitive {
179                left == right
180            } else {
181                left.eq_ignore_ascii_case(right)
182            }
183        }
184        (None, None) => true,
185        _ => false,
186    }
187}
188
189fn selectors_match(keys: &[Selector], exprs: &[Selector]) -> bool {
190    if exprs.is_empty() {
191        return keys.is_empty();
192    }
193
194    let mut key_iter = keys.iter();
195    for expr in exprs {
196        match expr {
197            Selector::Index(target) => match key_iter.next() {
198                Some(Selector::Index(actual)) if actual == target => {}
199                _ => return false,
200            },
201            Selector::Fanout => return true,
202            Selector::Slice { start, stop, step } => {
203                let is_full = start.is_none() && stop.is_none() && step.is_none();
204                if is_full {
205                    return true;
206                }
207                return false;
208            }
209        }
210    }
211
212    key_iter.next().is_none()
213}
214
215fn compute_key_matches(row: &Row, token: &str, exact: ExactMode) -> KeyMatches {
216    let trimmed = token.trim();
217    if trimmed.is_empty() {
218        return KeyMatches::default();
219    }
220
221    let expr = parse_path(trimmed)
222        .ok()
223        .filter(|expr| !expr.segments.is_empty());
224    let case_sensitive = matches!(exact, ExactMode::CaseSensitive);
225    let allow_partial = matches!(exact, ExactMode::None);
226    let expr_has_selectors = expr.as_ref().is_some_and(|expr| {
227        expr.segments
228            .iter()
229            .any(|segment| !segment.selectors.is_empty())
230    });
231    let expr_segments = expr
232        .as_ref()
233        .map(|expr| expression_segments(expr, case_sensitive));
234    let should_try_plain_label_match = expr.is_none()
235        || (!expr.as_ref().is_some_and(|expr| expr.absolute)
236            && !expr_has_selectors
237            && expr_segments
238                .as_ref()
239                .map(|segments| segments.len())
240                .unwrap_or(0)
241                == 1);
242
243    let token_cmp = if case_sensitive {
244        trimmed.to_string()
245    } else {
246        trimmed.to_ascii_lowercase()
247    };
248
249    let mut exact_keys = Vec::new();
250    let mut partial_keys = Vec::new();
251
252    for key in row.keys() {
253        if let Some(expr) = &expr {
254            if expr_has_selectors {
255                if matches_expression_with_selectors(key, expr, case_sensitive) {
256                    exact_keys.push(key.clone());
257                    continue;
258                }
259            } else if let Some(pattern) = &expr_segments {
260                let segments = key_segments(key, case_sensitive);
261                if segments_match(&segments, pattern, expr.absolute) {
262                    exact_keys.push(key.clone());
263                    continue;
264                }
265            }
266        }
267
268        if !should_try_plain_label_match {
269            continue;
270        }
271
272        let segments = key_segments(key, case_sensitive);
273        let Some(last_segment) = segments.last() else {
274            continue;
275        };
276
277        let last_cmp = if case_sensitive {
278            last_segment.clone()
279        } else {
280            last_segment.to_ascii_lowercase()
281        };
282
283        let exact_match = match exact {
284            ExactMode::CaseSensitive => last_segment == trimmed,
285            ExactMode::CaseInsensitive => last_segment.eq_ignore_ascii_case(trimmed),
286            ExactMode::None => last_segment.eq_ignore_ascii_case(trimmed),
287        };
288        if exact_match {
289            exact_keys.push(key.clone());
290            continue;
291        }
292
293        if allow_partial {
294            let key_cmp = if case_sensitive {
295                key.clone()
296            } else {
297                key.to_ascii_lowercase()
298            };
299            if key_cmp.contains(&token_cmp) || last_cmp.contains(&token_cmp) {
300                partial_keys.push(key.clone());
301            }
302        }
303    }
304
305    let mut seen_partial = partial_keys.iter().cloned().collect::<HashSet<_>>();
306    for key in &exact_keys {
307        if seen_partial.insert(key.clone()) {
308            partial_keys.push(key.clone());
309        }
310    }
311
312    KeyMatches {
313        exact: exact_keys,
314        partial: partial_keys,
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use serde_json::json;
321
322    use crate::dsl::parse::key_spec::ExactMode;
323
324    use super::{match_row_keys, match_row_keys_detailed, value_contains};
325
326    #[test]
327    fn matches_last_segment_case_insensitive() {
328        let row = json!({"ldap.uid": "oistes", "mail": "o@uio.no"})
329            .as_object()
330            .cloned()
331            .expect("object");
332
333        let matched = match_row_keys(&row, "UID", ExactMode::CaseInsensitive);
334        assert_eq!(matched, vec!["ldap.uid"]);
335    }
336
337    #[test]
338    fn matches_subsequence_dotted_paths() {
339        let row = json!({
340            "metadata.asset.id": 42,
341            "asset.id": 7,
342            "metadata.owner.id": 9
343        })
344        .as_object()
345        .cloned()
346        .expect("object");
347
348        let matched = match_row_keys(&row, "asset.id", ExactMode::None);
349        assert_eq!(matched, vec!["metadata.asset.id", "asset.id"]);
350    }
351
352    #[test]
353    fn absolute_paths_require_prefix_match() {
354        let row = json!({
355            "metadata.asset.id": 42,
356            "asset.id": 7
357        })
358        .as_object()
359        .cloned()
360        .expect("object");
361
362        let matched = match_row_keys(&row, ".asset.id", ExactMode::None);
363        assert_eq!(matched, vec!["asset.id"]);
364    }
365
366    #[test]
367    fn selector_paths_match_exact_index() {
368        let row = json!({
369            "items[0].id": 1,
370            "items[1].id": 2
371        })
372        .as_object()
373        .cloned()
374        .expect("object");
375
376        let matched = match_row_keys(&row, "items[0].id", ExactMode::None);
377        assert_eq!(matched, vec!["items[0].id"]);
378    }
379
380    #[test]
381    fn detailed_matching_reports_partial_hits_when_exact_match_is_absent() {
382        let row = json!({
383            "metadata.asset.id": 42,
384            "metadata.asset.name": "vm-01"
385        })
386        .as_object()
387        .cloned()
388        .expect("object");
389
390        let matches = match_row_keys_detailed(&row, "nam", ExactMode::None);
391        assert!(matches.exact.is_empty());
392        assert_eq!(matches.partial, vec!["metadata.asset.name".to_string()]);
393    }
394
395    #[test]
396    fn value_contains_handles_arrays_and_case_sensitivity() {
397        let value = json!(["Alpha", {"name": "Bravo"}]);
398
399        assert!(value_contains(&value, "bravo", false));
400        assert!(!value_contains(&value, "bravo", true));
401        assert!(value_contains(&value, "Alpha", true));
402    }
403}