1#![deny(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
14
15use jsonschema::validator_for;
16use rand::RngCore;
17use regex::Regex;
18use serde_json::{json, Map, Number, Value};
19use thiserror::Error;
20use tracing::warn;
21
22use super::{
23 dsl::{
24 Assertion, FixtureExpect, Invariant, JsonType, Operand, TestFixture, ValueKind, ValueSpec,
25 },
26 jsonpath,
27};
28
29#[derive(Debug, Error)]
31pub enum RunnerError {
32 #[error("{0}")]
34 Assertion(String),
35 #[error(transparent)]
37 JsonPath(#[from] jsonpath::JsonPathError),
38 #[error("invalid regex `{pattern}`: {source}")]
40 Regex {
41 pattern: String,
42 #[source]
43 source: regex::Error,
44 },
45 #[error("invalid inline schema in `matches_schema`: {0}")]
47 Schema(String),
48}
49
50pub type Result<T> = std::result::Result<T, RunnerError>;
51
52pub fn input_for_case(invariant: &Invariant, case_index: u32, rng: &mut impl RngCore) -> Value {
53 if let Some(fixed) = &invariant.fixed {
54 return Value::Object(
55 fixed
56 .iter()
57 .map(|(key, value)| (key.clone(), value.clone()))
58 .collect(),
59 );
60 }
61
62 let mut input = Map::new();
63 if let Some(generate) = &invariant.generate {
64 for (key, spec) in generate {
65 let value = if case_index == 0 {
66 boundary_value(spec)
67 } else {
68 generated_value(spec, rng)
69 };
70 input.insert(key.clone(), value);
71 }
72 }
73 Value::Object(input)
74}
75
76pub fn evaluate(invariant: &Invariant, input: Value, response: Value) -> Result<()> {
79 let context = json!({
80 "input": input,
81 "response": response,
82 });
83 for assertion in &invariant.assertions {
84 evaluate_assertion(assertion, &context)?;
85 }
86 Ok(())
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum FixtureOutcome {
93 Match,
95 Mismatch {
97 expected: FixtureExpect,
99 observed: FixtureExpect,
101 detail: String,
104 },
105 Structural {
109 error: String,
111 },
112}
113
114pub fn evaluate_fixture(invariant: &Invariant, fixture: &TestFixture) -> FixtureOutcome {
118 let input = fixture.input.clone().unwrap_or_else(|| {
119 let map = invariant
120 .fixed
121 .clone()
122 .unwrap_or_default()
123 .into_iter()
124 .collect::<serde_json::Map<_, _>>();
125 Value::Object(map)
126 });
127 match evaluate(invariant, input, fixture.response.clone()) {
128 Ok(()) => match fixture.expect {
129 FixtureExpect::Pass => FixtureOutcome::Match,
130 FixtureExpect::Fail => FixtureOutcome::Mismatch {
131 expected: FixtureExpect::Fail,
132 observed: FixtureExpect::Pass,
133 detail: String::new(),
134 },
135 },
136 Err(RunnerError::Assertion(message)) => match fixture.expect {
137 FixtureExpect::Fail => FixtureOutcome::Match,
138 FixtureExpect::Pass => FixtureOutcome::Mismatch {
139 expected: FixtureExpect::Pass,
140 observed: FixtureExpect::Fail,
141 detail: message,
142 },
143 },
144 Err(other) => FixtureOutcome::Structural {
145 error: other.to_string(),
146 },
147 }
148}
149
150fn evaluate_assertion(assertion: &Assertion, context: &Value) -> Result<()> {
151 match assertion {
152 Assertion::Equals { lhs, rhs } => {
153 let left = lhs.resolve(context)?;
154 let right = rhs.resolve(context)?;
155 if left == right {
156 Ok(())
157 } else {
158 Err(RunnerError::Assertion(format!(
159 "expected {left} to equal {right}"
160 )))
161 }
162 }
163 Assertion::NotEquals { lhs, rhs } => {
164 let left = lhs.resolve(context)?;
165 let right = rhs.resolve(context)?;
166 if left != right {
167 Ok(())
168 } else {
169 Err(RunnerError::Assertion(format!(
170 "expected {left} to differ from {right}"
171 )))
172 }
173 }
174 Assertion::AtMost { path, value } => compare_number(path, value, context, |o| {
175 matches!(o, std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
176 }),
177 Assertion::AtLeast { path, value } => compare_number(path, value, context, |o| {
178 matches!(o, std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)
179 }),
180 Assertion::LengthEq { path, value } => compare_length(path, value, context, |a, b| a == b),
181 Assertion::LengthAtMost { path, value } => {
182 compare_length(path, value, context, |a, b| a <= b)
183 }
184 Assertion::LengthAtLeast { path, value } => {
185 compare_length(path, value, context, |a, b| a >= b)
186 }
187 Assertion::IsType { path, expected } => {
188 let value = jsonpath::resolve_one(context, path)?;
189 let actual = json_type(&value);
190 if actual == *expected {
191 Ok(())
192 } else {
193 Err(RunnerError::Assertion(format!(
194 "expected {path} to be {expected:?}, got {actual:?}"
195 )))
196 }
197 }
198 Assertion::MatchesRegex { path, pattern } => {
199 let value = jsonpath::resolve_one(context, path)?;
200 let Some(text) = value.as_str() else {
201 return Err(RunnerError::Assertion(format!(
202 "expected {path} to resolve to a string"
203 )));
204 };
205 let regex = Regex::new(pattern).map_err(|source| RunnerError::Regex {
206 pattern: pattern.clone(),
207 source,
208 })?;
209 if regex.is_match(text) {
210 Ok(())
211 } else {
212 Err(RunnerError::Assertion(format!(
213 "expected {path} to match {pattern}"
214 )))
215 }
216 }
217 Assertion::AllOf { assertions } => {
218 for child in assertions {
219 evaluate_assertion(child, context)?;
220 }
221 Ok(())
222 }
223 Assertion::AnyOf { assertions } => evaluate_any_of(assertions, context),
224 Assertion::Not { assertion } => match evaluate_assertion(assertion, context) {
225 Ok(()) => Err(RunnerError::Assertion(
226 "expected child assertion to fail under `not`".to_string(),
227 )),
228 Err(RunnerError::Assertion(_)) => Ok(()),
238 Err(RunnerError::JsonPath(jsonpath::JsonPathError::Missing(_))) => Ok(()),
239 Err(other) => Err(other),
240 },
241 Assertion::ForEach { path, assertions } => evaluate_for_each(path, assertions, context),
242 Assertion::MatchesSchema { path, schema } => {
243 let target = jsonpath::resolve_one(context, path)?;
244 let validator =
245 validator_for(schema).map_err(|err| RunnerError::Schema(err.to_string()))?;
246 if validator.is_valid(&target) {
247 Ok(())
248 } else {
249 let errors = validator
250 .iter_errors(&target)
251 .map(|err| format!("{err} at {}", err.instance_path()))
252 .collect::<Vec<_>>()
253 .join("; ");
254 Err(RunnerError::Assertion(format!(
255 "value at {path} does not validate against inline schema: {errors}"
256 )))
257 }
258 }
259 }
260}
261
262fn evaluate_any_of(assertions: &[Assertion], context: &Value) -> Result<()> {
263 if assertions.is_empty() {
264 return Err(RunnerError::Assertion(
265 "`any_of` requires at least one child assertion".to_string(),
266 ));
267 }
268 let mut last_assertion_error: Option<String> = None;
269 for child in assertions {
270 match evaluate_assertion(child, context) {
271 Ok(()) => return Ok(()),
272 Err(RunnerError::Assertion(message)) => {
273 last_assertion_error = Some(message);
274 }
275 Err(RunnerError::JsonPath(jsonpath::JsonPathError::Missing(path))) => {
280 last_assertion_error = Some(format!("path `{path}` did not resolve"));
281 }
282 Err(other) => return Err(other),
283 }
284 }
285 Err(RunnerError::Assertion(format!(
286 "no `any_of` branch matched (last failure: {})",
287 last_assertion_error.unwrap_or_else(|| "unknown".to_string())
288 )))
289}
290
291fn evaluate_for_each(path: &str, assertions: &[Assertion], context: &Value) -> Result<()> {
292 let nodes = jsonpath::resolve(context, path)?;
293 if nodes.is_empty() {
294 warn!(
299 jsonpath = path,
300 "for_each path matched zero nodes; the assertion is vacuously true. \
301 Double-check the path or wrap intentional empty-set cases in `any_of` / `not`."
302 );
303 return Ok(());
304 }
305 for (index, node) in nodes.into_iter().enumerate() {
306 let Some(base) = context.as_object() else {
310 return Err(RunnerError::Assertion(
311 "internal: evaluation context must be an object".to_string(),
312 ));
313 };
314 let mut child = base.clone();
315 child.insert("item".to_string(), node);
316 child.insert("index".to_string(), json!(index));
317 let child_context = Value::Object(child);
318 for assertion in assertions {
319 evaluate_assertion(assertion, &child_context).map_err(|err| match err {
320 RunnerError::Assertion(message) => {
321 RunnerError::Assertion(format!("for_each at {path}[{index}]: {message}"))
322 }
323 other => other,
324 })?;
325 }
326 }
327 Ok(())
328}
329
330fn compare_number(
331 path: &str,
332 value: &Operand,
333 context: &Value,
334 compare: impl Fn(std::cmp::Ordering) -> bool,
335) -> Result<()> {
336 let left = jsonpath::resolve_one(context, path)?;
337 let right = value.resolve(context)?;
338
339 if let (Some(l), Some(r)) = (as_i128(&left), as_i128(&right)) {
345 if compare(l.cmp(&r)) {
346 return Ok(());
347 }
348 return Err(RunnerError::Assertion(format!(
349 "numeric comparison failed: {l} vs {r}"
350 )));
351 }
352
353 let Some(left_f) = left.as_f64() else {
354 return Err(RunnerError::Assertion(format!(
355 "expected {path} to resolve to a number"
356 )));
357 };
358 let Some(right_f) = right.as_f64() else {
359 return Err(RunnerError::Assertion(
360 "expected comparison value to be a number".to_string(),
361 ));
362 };
363 let ordering = left_f
364 .partial_cmp(&right_f)
365 .ok_or_else(|| RunnerError::Assertion("comparison against NaN".to_string()))?;
366 if compare(ordering) {
367 Ok(())
368 } else {
369 Err(RunnerError::Assertion(format!(
370 "numeric comparison failed: {left_f} vs {right_f}"
371 )))
372 }
373}
374
375fn as_i128(value: &Value) -> Option<i128> {
376 let Value::Number(n) = value else {
377 return None;
378 };
379 if let Some(i) = n.as_i64() {
380 Some(i as i128)
381 } else {
382 n.as_u64().map(|u| u as i128)
383 }
384}
385
386fn compare_length(
387 path: &str,
388 value: &Operand,
389 context: &Value,
390 compare: impl FnOnce(usize, usize) -> bool,
391) -> Result<()> {
392 let left = jsonpath::resolve_one(context, path)?;
393 let right = value.resolve(context)?;
394 let Some(right) = right.as_u64().map(|value| value as usize) else {
395 return Err(RunnerError::Assertion(
396 "expected comparison value to be an integer".to_string(),
397 ));
398 };
399 let Some(left) = length(&left) else {
400 return Err(RunnerError::Assertion(format!(
401 "expected {path} to resolve to an array or string"
402 )));
403 };
404 if compare(left, right) {
405 Ok(())
406 } else {
407 Err(RunnerError::Assertion(format!(
408 "length comparison failed: {left} vs {right}"
409 )))
410 }
411}
412
413fn length(value: &Value) -> Option<usize> {
414 match value {
415 Value::Array(items) => Some(items.len()),
416 Value::String(text) => Some(text.chars().count()),
417 _ => None,
418 }
419}
420
421fn json_type(value: &Value) -> JsonType {
422 match value {
423 Value::Null => JsonType::Null,
424 Value::Bool(_) => JsonType::Boolean,
425 Value::Number(number) if number.is_i64() || number.is_u64() => JsonType::Integer,
426 Value::Number(_) => JsonType::Number,
427 Value::String(_) => JsonType::String,
428 Value::Array(_) => JsonType::Array,
429 Value::Object(_) => JsonType::Object,
430 }
431}
432
433impl Operand {
434 pub fn resolve(&self, context: &Value) -> Result<Value> {
441 match self {
442 Operand::Path { path } => Ok(jsonpath::resolve_one(context, path)?),
443 Operand::Literal { value } => Ok(value.clone()),
444 Operand::Direct(Value::String(s)) if s.starts_with('$') => {
445 Ok(jsonpath::resolve_one(context, s)?)
446 }
447 Operand::Direct(value) => Ok(value.clone()),
448 }
449 }
450}
451
452fn boundary_value(spec: &ValueSpec) -> Value {
453 match spec.kind {
454 ValueKind::String => {
455 let len = spec.max_length.or(spec.min_length).unwrap_or(8).min(1024);
456 Value::String("x".repeat(len))
457 }
458 ValueKind::Integer => json!(spec.max.or(spec.min).unwrap_or(1)),
459 ValueKind::Number => Number::from_f64(spec.max.or(spec.min).unwrap_or(1) as f64)
460 .map(Value::Number)
461 .unwrap_or(Value::Null),
462 ValueKind::Boolean => Value::Bool(true),
463 ValueKind::Array => {
464 let len = spec.max_items.or(spec.min_items).unwrap_or(1).min(64);
465 let item_spec = spec.items.as_deref();
466 Value::Array(
467 (0..len)
468 .map(|_| item_spec.map(boundary_value).unwrap_or(Value::Null))
469 .collect(),
470 )
471 }
472 }
473}
474
475fn generated_value(spec: &ValueSpec, rng: &mut impl RngCore) -> Value {
476 match spec.kind {
477 ValueKind::String => {
478 let min = spec.min_length.unwrap_or(0);
479 let max = spec.max_length.unwrap_or(32).max(min).min(1024);
480 let len = min + (rng.next_u64() as usize % (max - min + 1));
481 Value::String("a".repeat(len))
482 }
483 ValueKind::Integer => {
484 let min = spec.min.unwrap_or(-100);
485 let max = spec.max.unwrap_or(100).max(min);
486 let span = (max as i128 - min as i128 + 1) as u64;
487 json!(min + (rng.next_u64() % span) as i64)
488 }
489 ValueKind::Number => {
490 let min = spec.min.unwrap_or(-100) as f64;
491 let max = (spec.max.unwrap_or(100) as f64).max(min);
492 let unit = rng.next_u64() as f64 / u64::MAX as f64;
493 Number::from_f64(min + (max - min) * unit)
494 .map(Value::Number)
495 .unwrap_or(Value::Null)
496 }
497 ValueKind::Boolean => Value::Bool((rng.next_u64() & 1) == 0),
498 ValueKind::Array => {
499 let min = spec.min_items.unwrap_or(0);
500 let max = spec.max_items.unwrap_or(8).max(min).min(64);
501 let len = min + (rng.next_u64() as usize % (max - min + 1));
502 let item_spec = spec.items.as_deref();
503 Value::Array(
504 (0..len)
505 .map(|_| {
506 item_spec
507 .map(|item| generated_value(item, rng))
508 .unwrap_or(Value::Null)
509 })
510 .collect(),
511 )
512 }
513 }
514}
515
516#[cfg(test)]
517#[allow(
518 clippy::expect_used,
519 clippy::unwrap_used,
520 clippy::panic,
521 clippy::unwrap_in_result
522)]
523mod tests {
524 use super::*;
525 use crate::property::dsl::parse;
526
527 fn evaluate_yaml(source: &str, input: Value, response: Value) -> Result<()> {
528 let file = parse(source).unwrap();
529 evaluate(&file.invariants[0], input, response)
530 }
531
532 #[test]
533 fn explicit_path_operand_works() {
534 let source = r#"
535version: 2
536invariants:
537 - name: t
538 tool: x
539 fixed: {}
540 assert:
541 - kind: equals
542 lhs: { path: "$.response.x" }
543 rhs: { value: 42 }
544"#;
545 evaluate_yaml(source, json!({}), json!({"x": 42})).unwrap();
546 assert!(evaluate_yaml(source, json!({}), json!({"x": 41})).is_err());
547 }
548
549 #[test]
550 fn at_least_uses_integer_comparison_beyond_f64_mantissa() {
551 let source = r#"
556version: 2
557invariants:
558 - name: precision
559 tool: x
560 fixed: {}
561 assert:
562 - kind: at_least
563 path: "$.response.n"
564 value: { value: 9007199254740993 }
565"#;
566 evaluate_yaml(source, json!({}), json!({"n": 9_007_199_254_740_993_i64})).unwrap();
568 let err =
570 evaluate_yaml(source, json!({}), json!({"n": 9_007_199_254_740_992_i64})).unwrap_err();
571 assert!(matches!(err, RunnerError::Assertion(_)));
572 }
573
574 #[test]
575 fn legacy_string_operand_still_works() {
576 let source = r#"
577version: 1
578invariants:
579 - name: t
580 tool: x
581 fixed: {}
582 assert:
583 - kind: equals
584 lhs: "$.response.x"
585 rhs: "$.input.expected"
586"#;
587 evaluate_yaml(source, json!({"expected": 7}), json!({"x": 7})).unwrap();
588 }
589
590 #[test]
591 fn all_of_combines_assertions() {
592 let source = r#"
593version: 2
594invariants:
595 - name: t
596 tool: x
597 fixed: {}
598 assert:
599 - kind: all_of
600 assert:
601 - kind: equals
602 lhs: { path: "$.response.a" }
603 rhs: { value: 1 }
604 - kind: at_most
605 path: "$.response.b"
606 value: { value: 5 }
607"#;
608 evaluate_yaml(source, json!({}), json!({"a": 1, "b": 4})).unwrap();
609 let err = evaluate_yaml(source, json!({}), json!({"a": 1, "b": 99})).unwrap_err();
610 assert!(matches!(err, RunnerError::Assertion(_)));
611 }
612
613 #[test]
614 fn any_of_succeeds_when_one_branch_passes() {
615 let source = r#"
616version: 2
617invariants:
618 - name: t
619 tool: x
620 fixed: {}
621 assert:
622 - kind: any_of
623 assert:
624 - kind: equals
625 lhs: { path: "$.response.a" }
626 rhs: { value: 1 }
627 - kind: equals
628 lhs: { path: "$.response.a" }
629 rhs: { value: 2 }
630"#;
631 evaluate_yaml(source, json!({}), json!({"a": 2})).unwrap();
632 let err = evaluate_yaml(source, json!({}), json!({"a": 9})).unwrap_err();
633 assert!(matches!(err, RunnerError::Assertion(message) if message.contains("any_of")));
634 }
635
636 #[test]
637 fn not_inverts_assertion_outcome() {
638 let source = r#"
639version: 2
640invariants:
641 - name: t
642 tool: x
643 fixed: {}
644 assert:
645 - kind: not
646 assertion:
647 kind: equals
648 lhs: { path: "$.response.a" }
649 rhs: { value: 0 }
650"#;
651 evaluate_yaml(source, json!({}), json!({"a": 5})).unwrap();
652 let err = evaluate_yaml(source, json!({}), json!({"a": 0})).unwrap_err();
653 assert!(matches!(err, RunnerError::Assertion(_)));
654 }
655
656 #[test]
657 fn for_each_visits_every_node() {
658 let source = r#"
659version: 2
660invariants:
661 - name: t
662 tool: x
663 fixed: {}
664 assert:
665 - kind: for_each
666 path: "$.response.items[*]"
667 assert:
668 - kind: at_least
669 path: "$.item.score"
670 value: { value: 0 }
671"#;
672 evaluate_yaml(
673 source,
674 json!({}),
675 json!({"items": [{"score": 1}, {"score": 5}]}),
676 )
677 .unwrap();
678 let err = evaluate_yaml(
679 source,
680 json!({}),
681 json!({"items": [{"score": 1}, {"score": -3}]}),
682 )
683 .unwrap_err();
684 assert!(matches!(err, RunnerError::Assertion(message) if message.contains("for_each at")));
685 }
686
687 #[test]
688 fn matches_schema_validates_inline_schema() {
689 let source = r#"
690version: 2
691invariants:
692 - name: t
693 tool: x
694 fixed: {}
695 assert:
696 - kind: matches_schema
697 path: "$.response.user"
698 schema:
699 type: object
700 required: [name]
701 properties:
702 name: { type: string }
703 age: { type: integer, minimum: 0 }
704"#;
705 evaluate_yaml(
706 source,
707 json!({}),
708 json!({"user": {"name": "alice", "age": 30}}),
709 )
710 .unwrap();
711 let err = evaluate_yaml(source, json!({}), json!({"user": {"age": -1}})).unwrap_err();
712 assert!(matches!(err, RunnerError::Assertion(message) if message.contains("schema")));
713 }
714}