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 evaluate_step_assertions(&invariant.assertions, input, response)
80}
81
82pub fn evaluate_with_tool(
88 invariant: &Invariant,
89 input: Value,
90 response: Value,
91 tool: Option<&rmcp::model::Tool>,
92) -> Result<()> {
93 evaluate_step_assertions_with_tool(&invariant.assertions, input, response, tool)
94}
95
96pub fn evaluate_step_assertions(
102 assertions: &[Assertion],
103 input: Value,
104 response: Value,
105) -> Result<()> {
106 evaluate_step_assertions_with_tool(assertions, input, response, None)
107}
108
109pub fn evaluate_step_assertions_with_tool(
113 assertions: &[Assertion],
114 input: Value,
115 response: Value,
116 tool: Option<&rmcp::model::Tool>,
117) -> Result<()> {
118 let tool_value = tool
119 .map(|t| {
120 let annotations = serde_json::to_value(&t.annotations).unwrap_or(Value::Null);
121 json!({
122 "name": t.name.as_ref(),
123 "description": t.description.as_deref().unwrap_or(""),
124 "annotations": annotations,
125 })
126 })
127 .unwrap_or(Value::Null);
128 let context = json!({
129 "input": input,
130 "response": response,
131 "tool": tool_value,
132 });
133 for assertion in assertions {
134 evaluate_assertion(assertion, &context)?;
135 }
136 Ok(())
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum FixtureOutcome {
143 Match,
145 Mismatch {
147 expected: FixtureExpect,
149 observed: FixtureExpect,
151 detail: String,
154 },
155 Structural {
159 error: String,
161 },
162}
163
164pub fn evaluate_fixture(invariant: &Invariant, fixture: &TestFixture) -> FixtureOutcome {
168 let input = fixture.input.clone().unwrap_or_else(|| {
169 let map = invariant
170 .fixed
171 .clone()
172 .unwrap_or_default()
173 .into_iter()
174 .collect::<serde_json::Map<_, _>>();
175 Value::Object(map)
176 });
177 match evaluate(invariant, input, fixture.response.clone()) {
178 Ok(()) => match fixture.expect {
179 FixtureExpect::Pass => FixtureOutcome::Match,
180 FixtureExpect::Fail => FixtureOutcome::Mismatch {
181 expected: FixtureExpect::Fail,
182 observed: FixtureExpect::Pass,
183 detail: String::new(),
184 },
185 },
186 Err(RunnerError::Assertion(message)) => match fixture.expect {
187 FixtureExpect::Fail => FixtureOutcome::Match,
188 FixtureExpect::Pass => FixtureOutcome::Mismatch {
189 expected: FixtureExpect::Pass,
190 observed: FixtureExpect::Fail,
191 detail: message,
192 },
193 },
194 Err(other) => FixtureOutcome::Structural {
195 error: other.to_string(),
196 },
197 }
198}
199
200fn evaluate_assertion(assertion: &Assertion, context: &Value) -> Result<()> {
201 match assertion {
202 Assertion::Equals { lhs, rhs } => {
203 let left = lhs.resolve(context)?;
204 let right = rhs.resolve(context)?;
205 if left == right {
206 Ok(())
207 } else {
208 Err(RunnerError::Assertion(format!(
209 "expected {left} to equal {right}"
210 )))
211 }
212 }
213 Assertion::NotEquals { lhs, rhs } => {
214 let left = lhs.resolve(context)?;
215 let right = rhs.resolve(context)?;
216 if left != right {
217 Ok(())
218 } else {
219 Err(RunnerError::Assertion(format!(
220 "expected {left} to differ from {right}"
221 )))
222 }
223 }
224 Assertion::AtMost { path, value } => compare_number(path, value, context, |o| {
225 matches!(o, std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
226 }),
227 Assertion::AtLeast { path, value } => compare_number(path, value, context, |o| {
228 matches!(o, std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)
229 }),
230 Assertion::LengthEq { path, value } => compare_length(path, value, context, |a, b| a == b),
231 Assertion::LengthAtMost { path, value } => {
232 compare_length(path, value, context, |a, b| a <= b)
233 }
234 Assertion::LengthAtLeast { path, value } => {
235 compare_length(path, value, context, |a, b| a >= b)
236 }
237 Assertion::IsType { path, expected } => {
238 let value = jsonpath::resolve_one(context, path)?;
239 let actual = json_type(&value);
240 if actual == *expected {
241 Ok(())
242 } else {
243 Err(RunnerError::Assertion(format!(
244 "expected {path} to be {expected:?}, got {actual:?}"
245 )))
246 }
247 }
248 Assertion::MatchesRegex { path, pattern } => {
249 let value = jsonpath::resolve_one(context, path)?;
250 let Some(text) = value.as_str() else {
251 return Err(RunnerError::Assertion(format!(
252 "expected {path} to resolve to a string"
253 )));
254 };
255 let regex = Regex::new(pattern).map_err(|source| RunnerError::Regex {
256 pattern: pattern.clone(),
257 source,
258 })?;
259 if regex.is_match(text) {
260 Ok(())
261 } else {
262 Err(RunnerError::Assertion(format!(
263 "expected {path} to match {pattern}"
264 )))
265 }
266 }
267 Assertion::AllOf { assertions } => {
268 for child in assertions {
269 evaluate_assertion(child, context)?;
270 }
271 Ok(())
272 }
273 Assertion::AnyOf { assertions } => evaluate_any_of(assertions, context),
274 Assertion::Not { assertion } => match evaluate_assertion(assertion, context) {
275 Ok(()) => Err(RunnerError::Assertion(
276 "expected child assertion to fail under `not`".to_string(),
277 )),
278 Err(RunnerError::Assertion(_)) => Ok(()),
288 Err(RunnerError::JsonPath(jsonpath::JsonPathError::Missing(_))) => Ok(()),
289 Err(other) => Err(other),
290 },
291 Assertion::ForEach { path, assertions } => evaluate_for_each(path, assertions, context),
292 Assertion::MatchesSchema { path, schema } => {
293 let target = jsonpath::resolve_one(context, path)?;
294 let validator =
295 validator_for(schema).map_err(|err| RunnerError::Schema(err.to_string()))?;
296 if validator.is_valid(&target) {
297 Ok(())
298 } else {
299 let errors = validator
300 .iter_errors(&target)
301 .map(|err| format!("{err} at {}", err.instance_path()))
302 .collect::<Vec<_>>()
303 .join("; ");
304 Err(RunnerError::Assertion(format!(
305 "value at {path} does not validate against inline schema: {errors}"
306 )))
307 }
308 }
309 }
310}
311
312fn evaluate_any_of(assertions: &[Assertion], context: &Value) -> Result<()> {
313 if assertions.is_empty() {
314 return Err(RunnerError::Assertion(
315 "`any_of` requires at least one child assertion".to_string(),
316 ));
317 }
318 let mut last_assertion_error: Option<String> = None;
319 for child in assertions {
320 match evaluate_assertion(child, context) {
321 Ok(()) => return Ok(()),
322 Err(RunnerError::Assertion(message)) => {
323 last_assertion_error = Some(message);
324 }
325 Err(RunnerError::JsonPath(jsonpath::JsonPathError::Missing(path))) => {
330 last_assertion_error = Some(format!("path `{path}` did not resolve"));
331 }
332 Err(other) => return Err(other),
333 }
334 }
335 Err(RunnerError::Assertion(format!(
336 "no `any_of` branch matched (last failure: {})",
337 last_assertion_error.unwrap_or_else(|| "unknown".to_string())
338 )))
339}
340
341fn evaluate_for_each(path: &str, assertions: &[Assertion], context: &Value) -> Result<()> {
342 let nodes = jsonpath::resolve(context, path)?;
343 if nodes.is_empty() {
344 warn!(
349 jsonpath = path,
350 "for_each path matched zero nodes; the assertion is vacuously true. \
351 Double-check the path or wrap intentional empty-set cases in `any_of` / `not`."
352 );
353 return Ok(());
354 }
355 for (index, node) in nodes.into_iter().enumerate() {
356 let Some(base) = context.as_object() else {
360 return Err(RunnerError::Assertion(
361 "internal: evaluation context must be an object".to_string(),
362 ));
363 };
364 let mut child = base.clone();
365 child.insert("item".to_string(), node);
366 child.insert("index".to_string(), json!(index));
367 let child_context = Value::Object(child);
368 for assertion in assertions {
369 evaluate_assertion(assertion, &child_context).map_err(|err| match err {
370 RunnerError::Assertion(message) => {
371 RunnerError::Assertion(format!("for_each at {path}[{index}]: {message}"))
372 }
373 other => other,
374 })?;
375 }
376 }
377 Ok(())
378}
379
380fn compare_number(
381 path: &str,
382 value: &Operand,
383 context: &Value,
384 compare: impl Fn(std::cmp::Ordering) -> bool,
385) -> Result<()> {
386 let left = jsonpath::resolve_one(context, path)?;
387 let right = value.resolve(context)?;
388
389 if let (Some(l), Some(r)) = (as_i128(&left), as_i128(&right)) {
395 if compare(l.cmp(&r)) {
396 return Ok(());
397 }
398 return Err(RunnerError::Assertion(format!(
399 "numeric comparison failed: {l} vs {r}"
400 )));
401 }
402
403 let Some(left_f) = left.as_f64() else {
404 return Err(RunnerError::Assertion(format!(
405 "expected {path} to resolve to a number"
406 )));
407 };
408 let Some(right_f) = right.as_f64() else {
409 return Err(RunnerError::Assertion(
410 "expected comparison value to be a number".to_string(),
411 ));
412 };
413 let ordering = left_f
414 .partial_cmp(&right_f)
415 .ok_or_else(|| RunnerError::Assertion("comparison against NaN".to_string()))?;
416 if compare(ordering) {
417 Ok(())
418 } else {
419 Err(RunnerError::Assertion(format!(
420 "numeric comparison failed: {left_f} vs {right_f}"
421 )))
422 }
423}
424
425fn as_i128(value: &Value) -> Option<i128> {
426 let Value::Number(n) = value else {
427 return None;
428 };
429 if let Some(i) = n.as_i64() {
430 Some(i as i128)
431 } else {
432 n.as_u64().map(|u| u as i128)
433 }
434}
435
436fn compare_length(
437 path: &str,
438 value: &Operand,
439 context: &Value,
440 compare: impl FnOnce(usize, usize) -> bool,
441) -> Result<()> {
442 let left = jsonpath::resolve_one(context, path)?;
443 let right = value.resolve(context)?;
444 let Some(right) = right.as_u64().map(|value| value as usize) else {
445 return Err(RunnerError::Assertion(
446 "expected comparison value to be an integer".to_string(),
447 ));
448 };
449 let Some(left) = length(&left) else {
450 return Err(RunnerError::Assertion(format!(
451 "expected {path} to resolve to an array or string"
452 )));
453 };
454 if compare(left, right) {
455 Ok(())
456 } else {
457 Err(RunnerError::Assertion(format!(
458 "length comparison failed: {left} vs {right}"
459 )))
460 }
461}
462
463fn length(value: &Value) -> Option<usize> {
464 match value {
465 Value::Array(items) => Some(items.len()),
466 Value::String(text) => Some(text.chars().count()),
467 _ => None,
468 }
469}
470
471fn json_type(value: &Value) -> JsonType {
472 match value {
473 Value::Null => JsonType::Null,
474 Value::Bool(_) => JsonType::Boolean,
475 Value::Number(number) if number.is_i64() || number.is_u64() => JsonType::Integer,
476 Value::Number(_) => JsonType::Number,
477 Value::String(_) => JsonType::String,
478 Value::Array(_) => JsonType::Array,
479 Value::Object(_) => JsonType::Object,
480 }
481}
482
483impl Operand {
484 pub fn resolve(&self, context: &Value) -> Result<Value> {
491 match self {
492 Operand::Path { path } => Ok(jsonpath::resolve_one(context, path)?),
493 Operand::Literal { value } => Ok(value.clone()),
494 Operand::Direct(Value::String(s)) if s.starts_with('$') => {
495 Ok(jsonpath::resolve_one(context, s)?)
496 }
497 Operand::Direct(value) => Ok(value.clone()),
498 }
499 }
500}
501
502fn boundary_value(spec: &ValueSpec) -> Value {
503 match spec.kind {
504 ValueKind::String => {
505 let len = spec.max_length.or(spec.min_length).unwrap_or(8).min(1024);
506 Value::String("x".repeat(len))
507 }
508 ValueKind::Integer => json!(spec.max.or(spec.min).unwrap_or(1)),
509 ValueKind::Number => Number::from_f64(spec.max.or(spec.min).unwrap_or(1) as f64)
510 .map(Value::Number)
511 .unwrap_or(Value::Null),
512 ValueKind::Boolean => Value::Bool(true),
513 ValueKind::Array => {
514 let len = spec.max_items.or(spec.min_items).unwrap_or(1).min(64);
515 let item_spec = spec.items.as_deref();
516 Value::Array(
517 (0..len)
518 .map(|_| item_spec.map(boundary_value).unwrap_or(Value::Null))
519 .collect(),
520 )
521 }
522 }
523}
524
525fn generated_value(spec: &ValueSpec, rng: &mut impl RngCore) -> Value {
526 match spec.kind {
527 ValueKind::String => {
528 let min = spec.min_length.unwrap_or(0);
529 let max = spec.max_length.unwrap_or(32).max(min).min(1024);
530 let len = min + (rng.next_u64() as usize % (max - min + 1));
531 Value::String("a".repeat(len))
532 }
533 ValueKind::Integer => {
534 let min = spec.min.unwrap_or(-100);
535 let max = spec.max.unwrap_or(100).max(min);
536 let span = (max as i128 - min as i128 + 1) as u64;
537 json!(min + (rng.next_u64() % span) as i64)
538 }
539 ValueKind::Number => {
540 let min = spec.min.unwrap_or(-100) as f64;
541 let max = (spec.max.unwrap_or(100) as f64).max(min);
542 let unit = rng.next_u64() as f64 / u64::MAX as f64;
543 Number::from_f64(min + (max - min) * unit)
544 .map(Value::Number)
545 .unwrap_or(Value::Null)
546 }
547 ValueKind::Boolean => Value::Bool((rng.next_u64() & 1) == 0),
548 ValueKind::Array => {
549 let min = spec.min_items.unwrap_or(0);
550 let max = spec.max_items.unwrap_or(8).max(min).min(64);
551 let len = min + (rng.next_u64() as usize % (max - min + 1));
552 let item_spec = spec.items.as_deref();
553 Value::Array(
554 (0..len)
555 .map(|_| {
556 item_spec
557 .map(|item| generated_value(item, rng))
558 .unwrap_or(Value::Null)
559 })
560 .collect(),
561 )
562 }
563 }
564}
565
566#[cfg(test)]
567#[allow(
568 clippy::expect_used,
569 clippy::unwrap_used,
570 clippy::panic,
571 clippy::unwrap_in_result
572)]
573mod tests {
574 use super::*;
575 use crate::property::dsl::parse;
576
577 fn evaluate_yaml(source: &str, input: Value, response: Value) -> Result<()> {
578 let file = parse(source).unwrap();
579 evaluate(&file.invariants[0], input, response)
580 }
581
582 #[test]
583 fn explicit_path_operand_works() {
584 let source = r#"
585version: 2
586invariants:
587 - name: t
588 tool: x
589 fixed: {}
590 assert:
591 - kind: equals
592 lhs: { path: "$.response.x" }
593 rhs: { value: 42 }
594"#;
595 evaluate_yaml(source, json!({}), json!({"x": 42})).unwrap();
596 assert!(evaluate_yaml(source, json!({}), json!({"x": 41})).is_err());
597 }
598
599 #[test]
600 fn at_least_uses_integer_comparison_beyond_f64_mantissa() {
601 let source = r#"
606version: 2
607invariants:
608 - name: precision
609 tool: x
610 fixed: {}
611 assert:
612 - kind: at_least
613 path: "$.response.n"
614 value: { value: 9007199254740993 }
615"#;
616 evaluate_yaml(source, json!({}), json!({"n": 9_007_199_254_740_993_i64})).unwrap();
618 let err =
620 evaluate_yaml(source, json!({}), json!({"n": 9_007_199_254_740_992_i64})).unwrap_err();
621 assert!(matches!(err, RunnerError::Assertion(_)));
622 }
623
624 #[test]
625 fn legacy_string_operand_still_works() {
626 let source = r#"
627version: 1
628invariants:
629 - name: t
630 tool: x
631 fixed: {}
632 assert:
633 - kind: equals
634 lhs: "$.response.x"
635 rhs: "$.input.expected"
636"#;
637 evaluate_yaml(source, json!({"expected": 7}), json!({"x": 7})).unwrap();
638 }
639
640 #[test]
641 fn all_of_combines_assertions() {
642 let source = r#"
643version: 2
644invariants:
645 - name: t
646 tool: x
647 fixed: {}
648 assert:
649 - kind: all_of
650 assert:
651 - kind: equals
652 lhs: { path: "$.response.a" }
653 rhs: { value: 1 }
654 - kind: at_most
655 path: "$.response.b"
656 value: { value: 5 }
657"#;
658 evaluate_yaml(source, json!({}), json!({"a": 1, "b": 4})).unwrap();
659 let err = evaluate_yaml(source, json!({}), json!({"a": 1, "b": 99})).unwrap_err();
660 assert!(matches!(err, RunnerError::Assertion(_)));
661 }
662
663 #[test]
664 fn any_of_succeeds_when_one_branch_passes() {
665 let source = r#"
666version: 2
667invariants:
668 - name: t
669 tool: x
670 fixed: {}
671 assert:
672 - kind: any_of
673 assert:
674 - kind: equals
675 lhs: { path: "$.response.a" }
676 rhs: { value: 1 }
677 - kind: equals
678 lhs: { path: "$.response.a" }
679 rhs: { value: 2 }
680"#;
681 evaluate_yaml(source, json!({}), json!({"a": 2})).unwrap();
682 let err = evaluate_yaml(source, json!({}), json!({"a": 9})).unwrap_err();
683 assert!(matches!(err, RunnerError::Assertion(message) if message.contains("any_of")));
684 }
685
686 #[test]
687 fn not_inverts_assertion_outcome() {
688 let source = r#"
689version: 2
690invariants:
691 - name: t
692 tool: x
693 fixed: {}
694 assert:
695 - kind: not
696 assertion:
697 kind: equals
698 lhs: { path: "$.response.a" }
699 rhs: { value: 0 }
700"#;
701 evaluate_yaml(source, json!({}), json!({"a": 5})).unwrap();
702 let err = evaluate_yaml(source, json!({}), json!({"a": 0})).unwrap_err();
703 assert!(matches!(err, RunnerError::Assertion(_)));
704 }
705
706 #[test]
707 fn for_each_visits_every_node() {
708 let source = r#"
709version: 2
710invariants:
711 - name: t
712 tool: x
713 fixed: {}
714 assert:
715 - kind: for_each
716 path: "$.response.items[*]"
717 assert:
718 - kind: at_least
719 path: "$.item.score"
720 value: { value: 0 }
721"#;
722 evaluate_yaml(
723 source,
724 json!({}),
725 json!({"items": [{"score": 1}, {"score": 5}]}),
726 )
727 .unwrap();
728 let err = evaluate_yaml(
729 source,
730 json!({}),
731 json!({"items": [{"score": 1}, {"score": -3}]}),
732 )
733 .unwrap_err();
734 assert!(matches!(err, RunnerError::Assertion(message) if message.contains("for_each at")));
735 }
736
737 #[test]
738 fn matches_schema_validates_inline_schema() {
739 let source = r#"
740version: 2
741invariants:
742 - name: t
743 tool: x
744 fixed: {}
745 assert:
746 - kind: matches_schema
747 path: "$.response.user"
748 schema:
749 type: object
750 required: [name]
751 properties:
752 name: { type: string }
753 age: { type: integer, minimum: 0 }
754"#;
755 evaluate_yaml(
756 source,
757 json!({}),
758 json!({"user": {"name": "alice", "age": 30}}),
759 )
760 .unwrap();
761 let err = evaluate_yaml(source, json!({}), json!({"user": {"age": -1}})).unwrap_err();
762 assert!(matches!(err, RunnerError::Assertion(message) if message.contains("schema")));
763 }
764}