1use crate::util::{err, Result};
5use serde::Deserialize;
6use serde_json::{json, Value};
7use std::collections::BTreeMap;
8
9pub 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#[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 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 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
220pub 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 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}