Skip to main content

rightkit_qa/
check.rs

1//! Assertions over a step's JSON result: a small path syntax (`a.b[0].c`,
2//! `list[*].name`) plus value predicates, and `${...}` interpolation of saved
3//! results into later steps.
4use crate::util::{err, Result};
5use serde::Deserialize;
6use serde_json::{json, Value};
7use std::collections::BTreeMap;
8
9/// Resolve a path. `[*]` fans out into an array of the remaining path's values.
10pub fn select(root: &Value, path: &str) -> Option<Value> {
11    let mut cur = vec![root.clone()];
12    let mut fanned = false;
13    for seg in split_path(path) {
14        let mut next = vec![];
15        for v in cur {
16            match seg.as_str() {
17                "[*]" => {
18                    fanned = true;
19                    if let Some(a) = v.as_array() {
20                        next.extend(a.iter().cloned());
21                    }
22                }
23                s if s.starts_with('[') => {
24                    let i: usize = s[1..s.len() - 1].parse().ok()?;
25                    next.push(v.get(i)?.clone());
26                }
27                key => {
28                    if let Some(x) = v.get(key) {
29                        next.push(x.clone());
30                    } else if fanned {
31                        continue;
32                    } else {
33                        return None;
34                    }
35                }
36            }
37        }
38        cur = next;
39    }
40    Some(if fanned {
41        Value::Array(cur)
42    } else {
43        cur.into_iter().next()?
44    })
45}
46
47fn split_path(path: &str) -> Vec<String> {
48    let mut out = vec![];
49    let mut cur = String::new();
50    let mut chars = path.chars().peekable();
51    while let Some(c) = chars.next() {
52        match c {
53            '.' => {
54                if !cur.is_empty() {
55                    out.push(std::mem::take(&mut cur));
56                }
57            }
58            '[' => {
59                if !cur.is_empty() {
60                    out.push(std::mem::take(&mut cur));
61                }
62                let mut idx = String::from("[");
63                for d in chars.by_ref() {
64                    idx.push(d);
65                    if d == ']' {
66                        break;
67                    }
68                }
69                out.push(idx);
70            }
71            c => cur.push(c),
72        }
73    }
74    if !cur.is_empty() {
75        out.push(cur);
76    }
77    out
78}
79
80/// One assertion. `path` empty/absent targets the whole result.
81#[derive(Debug, Clone, Deserialize, Default)]
82pub struct Expect {
83    pub path: Option<String>,
84    pub eq: Option<Value>,
85    pub ne: Option<Value>,
86    pub gte: Option<f64>,
87    pub lte: Option<f64>,
88    /// String contains substring, or array contains element.
89    pub contains: Option<Value>,
90    pub exists: Option<bool>,
91    pub len_gte: Option<usize>,
92    pub len_eq: Option<usize>,
93    pub not_empty: Option<bool>,
94}
95
96fn to_f64(v: &Value) -> Option<f64> {
97    v.as_f64()
98        .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
99}
100
101fn declared_beyond_exists(e: &Expect) -> bool {
102    e.eq.is_some()
103        || e.ne.is_some()
104        || e.gte.is_some()
105        || e.lte.is_some()
106        || e.contains.is_some()
107        || e.len_gte.is_some()
108        || e.len_eq.is_some()
109        || e.not_empty.is_some()
110}
111
112pub fn check(result: &Value, e: &Expect) -> Result<()> {
113    let label = e.path.clone().unwrap_or_else(|| "<result>".into());
114    let got = match e.path.as_deref().filter(|p| !p.is_empty()) {
115        Some(p) => select(result, p),
116        None => Some(result.clone()),
117    };
118    let declared = e.eq.is_some()
119        || e.ne.is_some()
120        || e.gte.is_some()
121        || e.lte.is_some()
122        || e.contains.is_some()
123        || e.exists.is_some()
124        || e.len_gte.is_some()
125        || e.len_eq.is_some()
126        || e.not_empty.is_some();
127    if !declared {
128        return err(format!(
129            "{label}: expectation declares no constraint (a bare path asserts nothing)"
130        ));
131    }
132    if let Some(want) = e.exists {
133        if got.is_some() != want {
134            return err(format!("{label}: exists={} expected {want}", got.is_some()));
135        }
136        if !want {
137            // Absence was asserted; no other constraint can apply to a missing value.
138            return if declared_beyond_exists(e) {
139                err(format!(
140                    "{label}: exists=false cannot be combined with value constraints"
141                ))
142            } else {
143                Ok(())
144            };
145        }
146    }
147    let Some(got) = got else {
148        return err(format!(
149            "{label}: path not found in {}",
150            crate::util::tail(&result.to_string(), 400)
151        ));
152    };
153    if let Some(v) = &e.eq {
154        if &got != v && !(got.is_number() && v.is_number() && got.as_f64() == v.as_f64()) {
155            return err(format!("{label}: expected {v}, got {got}"));
156        }
157    }
158    if let Some(v) = &e.ne {
159        if &got == v {
160            return err(format!("{label}: expected not {v}"));
161        }
162    }
163    if let Some(min) = e.gte {
164        match to_f64(&got) {
165            Some(n) if n >= min => {}
166            _ => return err(format!("{label}: expected >= {min}, got {got}")),
167        }
168    }
169    if let Some(max) = e.lte {
170        match to_f64(&got) {
171            Some(n) if n <= max => {}
172            _ => return err(format!("{label}: expected <= {max}, got {got}")),
173        }
174    }
175    if let Some(needle) = &e.contains {
176        let ok = match (&got, needle) {
177            (Value::String(s), Value::String(n)) => s.contains(n.as_str()),
178            (Value::Array(a), n) => a.contains(n),
179            _ => false,
180        };
181        if !ok {
182            return err(format!(
183                "{label}: expected to contain {needle}, got {}",
184                crate::util::tail(&got.to_string(), 400)
185            ));
186        }
187    }
188    let len = match &got {
189        Value::Array(a) => Some(a.len()),
190        Value::String(s) => Some(s.len()),
191        Value::Object(o) => Some(o.len()),
192        _ => None,
193    };
194    if let Some(n) = e.len_gte {
195        if len.map(|l| l >= n) != Some(true) {
196            return err(format!("{label}: expected length >= {n}, got {len:?}"));
197        }
198    }
199    if let Some(n) = e.len_eq {
200        if len != Some(n) {
201            return err(format!("{label}: expected length {n}, got {len:?}"));
202        }
203    }
204    if e.not_empty == Some(false) && !(len == Some(0) || got.is_null()) {
205        return err(format!("{label}: expected empty"));
206    }
207    if e.not_empty == Some(true) && (len == Some(0) || got.is_null()) {
208        return err(format!("{label}: expected non-empty"));
209    }
210    Ok(())
211}
212
213pub fn check_all(result: &Value, expects: &[Expect]) -> Result<()> {
214    for e in expects {
215        check(result, e)?;
216    }
217    Ok(())
218}
219
220/// Replace `${name.path}` references with saved values. A string that is exactly
221/// one placeholder keeps the saved value's JSON type.
222pub fn interpolate(v: &Value, vars: &BTreeMap<String, Value>) -> Result<Value> {
223    Ok(match v {
224        Value::String(s) => interpolate_str(s, vars)?,
225        Value::Array(a) => Value::Array(
226            a.iter()
227                .map(|x| interpolate(x, vars))
228                .collect::<Result<_>>()?,
229        ),
230        Value::Object(o) => Value::Object(
231            o.iter()
232                .map(|(k, x)| Ok((k.clone(), interpolate(x, vars)?)))
233                .collect::<Result<_>>()?,
234        ),
235        other => other.clone(),
236    })
237}
238
239fn lookup(name: &str, vars: &BTreeMap<String, Value>) -> Result<Value> {
240    let (head, rest) = name
241        .split_once('.')
242        .map(|(h, r)| (h, Some(r)))
243        .unwrap_or((name, None));
244    let base = vars
245        .get(head)
246        .ok_or_else(|| crate::util::Error(format!("unknown variable ${{{name}}}")))?;
247    match rest {
248        None => Ok(base.clone()),
249        Some(p) => select(base, p)
250            .ok_or_else(|| crate::util::Error(format!("variable path not found: ${{{name}}}"))),
251    }
252}
253
254fn interpolate_str(s: &str, vars: &BTreeMap<String, Value>) -> Result<Value> {
255    if let Some(inner) = s.strip_prefix("${").and_then(|r| r.strip_suffix('}')) {
256        if !inner.contains("${") {
257            return lookup(inner, vars);
258        }
259    }
260    let mut out = String::new();
261    let mut rest = s;
262    while let Some(i) = rest.find("${") {
263        out.push_str(&rest[..i]);
264        let after = &rest[i + 2..];
265        let Some(end) = after.find('}') else {
266            return err(format!("unterminated ${{ in {s}"));
267        };
268        match lookup(&after[..end], vars)? {
269            Value::String(t) => out.push_str(&t),
270            other => out.push_str(&other.to_string()),
271        }
272        rest = &after[end + 1..];
273    }
274    out.push_str(rest);
275    Ok(json!(out))
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    fn ex(path: &str) -> Expect {
283        Expect {
284            path: Some(path.into()),
285            ..Default::default()
286        }
287    }
288
289    #[test]
290    fn selects_nested_indexed_and_fanned_paths() {
291        let v = json!({"json": {"contract_version": 3, "capabilities": [{"name": "generate"}, {"name": "job.wait"}]}});
292        assert_eq!(select(&v, "json.contract_version"), Some(json!(3)));
293        assert_eq!(
294            select(&v, "json.capabilities[1].name"),
295            Some(json!("job.wait"))
296        );
297        assert_eq!(
298            select(&v, "json.capabilities[*].name"),
299            Some(json!(["generate", "job.wait"]))
300        );
301        assert_eq!(select(&v, "json.nope"), None);
302    }
303
304    #[test]
305    fn predicates() {
306        let v = json!({"n": 3, "s": "hello world", "a": ["x", "y"], "e": []});
307        assert!(check(
308            &v,
309            &Expect {
310                gte: Some(2.0),
311                ..ex("n")
312            }
313        )
314        .is_ok());
315        assert!(check(
316            &v,
317            &Expect {
318                gte: Some(4.0),
319                ..ex("n")
320            }
321        )
322        .is_err());
323        assert!(check(
324            &v,
325            &Expect {
326                contains: Some(json!("world")),
327                ..ex("s")
328            }
329        )
330        .is_ok());
331        assert!(check(
332            &v,
333            &Expect {
334                contains: Some(json!("y")),
335                ..ex("a")
336            }
337        )
338        .is_ok());
339        assert!(check(
340            &v,
341            &Expect {
342                eq: Some(json!(3.0)),
343                ..ex("n")
344            }
345        )
346        .is_ok());
347        assert!(check(
348            &v,
349            &Expect {
350                exists: Some(false),
351                ..ex("zzz")
352            }
353        )
354        .is_ok());
355        assert!(check(
356            &v,
357            &Expect {
358                not_empty: Some(true),
359                ..ex("e")
360            }
361        )
362        .is_err());
363        assert!(check(
364            &v,
365            &Expect {
366                len_gte: Some(2),
367                ..ex("a")
368            }
369        )
370        .is_ok());
371    }
372
373    #[test]
374    fn every_declared_constraint_is_evaluated() {
375        let v = json!({"s": "actual", "n": 3});
376        // exists=true must not short-circuit the value constraint.
377        assert!(check(
378            &v,
379            &Expect {
380                exists: Some(true),
381                eq: Some(json!("expected")),
382                ..ex("s")
383            }
384        )
385        .is_err());
386        assert!(check(
387            &v,
388            &Expect {
389                exists: Some(true),
390                eq: Some(json!("actual")),
391                ..ex("s")
392            }
393        )
394        .is_ok());
395        assert!(check(
396            &v,
397            &Expect {
398                exists: Some(true),
399                gte: Some(5.0),
400                ..ex("n")
401            }
402        )
403        .is_err());
404        assert!(check(
405            &v,
406            &Expect {
407                exists: Some(false),
408                eq: Some(json!("x")),
409                ..ex("zzz")
410            }
411        )
412        .is_err());
413        assert!(
414            check(&v, &Expect { ..ex("s") }).is_err(),
415            "a bare path asserts nothing and must not pass"
416        );
417        assert!(check(
418            &v,
419            &Expect {
420                not_empty: Some(false),
421                ..ex("s")
422            }
423        )
424        .is_err());
425    }
426
427    #[test]
428    fn interpolation_keeps_types_for_whole_placeholders() {
429        let vars = BTreeMap::from([("job".to_string(), json!({"json": {"id": "j1", "n": 4}}))]);
430        assert_eq!(
431            interpolate(&json!("${job.json.n}"), &vars).unwrap(),
432            json!(4)
433        );
434        assert_eq!(
435            interpolate(&json!("id=${job.json.id}!"), &vars).unwrap(),
436            json!("id=j1!")
437        );
438        assert!(interpolate(&json!("${missing}"), &vars).is_err());
439        assert_eq!(
440            interpolate(&json!({"a": ["${job.json.id}"]}), &vars).unwrap(),
441            json!({"a": ["j1"]})
442        );
443    }
444}