1use std::collections::BTreeMap;
33
34use regex::Regex;
35use serde::{Deserialize, Serialize};
36use serde_json::Value;
37use thiserror::Error;
38
39pub const MAX_VERSION: u64 = 3;
41
42pub const MAX_EXTENDS_DEPTH: usize = 4;
46
47#[derive(Debug, Error)]
48pub enum DslError {
49 #[error("failed to parse invariants YAML: {0}")]
51 Parse(#[from] serde_yaml::Error),
52 #[error("invariant `{0}` must define exactly one of `generate` or `fixed`")]
55 InvalidInputMode(String),
56 #[error("invariants file declares unsupported version `{0}`; expected ≤ {MAX_VERSION}")]
58 UnsupportedVersion(u64),
59 #[error("undefined template parameter(s): {0:?}")]
62 UndefinedParameters(Vec<String>),
63 #[error("override key `{0}` is not declared in metadata.parameters")]
67 UnknownParameterOverride(String),
68 #[error("invalid `where` regex: {0}")]
71 InvalidWhereRegex(String),
72}
73
74pub type Result<T> = std::result::Result<T, DslError>;
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct InvariantFile {
78 pub version: u64,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub metadata: Option<PackMetadata>,
84 pub invariants: Vec<Invariant>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
90 pub for_each_tool: Vec<ForEachToolBlock>,
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub sequences: Vec<Sequence>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct Sequence {
110 pub name: String,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub description: Option<String>,
118 pub steps: Vec<SequenceStep>,
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
124 pub test_fixtures: Vec<SequenceFixture>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct SequenceStep {
133 pub call: String,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub with: Option<BTreeMap<String, Value>>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub bind: Option<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub expect: Option<StepOutcome>,
153 #[serde(default, rename = "assert", skip_serializing_if = "Vec::is_empty")]
157 pub assertions: Vec<Assertion>,
158}
159
160#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum StepOutcome {
166 #[default]
169 Ok,
170 Error,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct SequenceFixture {
183 pub name: String,
185 pub responses: Vec<Value>,
190 pub expect: FixtureExpect,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct ForEachToolBlock {
199 pub name: String,
202 #[serde(rename = "where", default)]
207 pub matches: ToolMatch,
208 pub apply: ApplyTemplate,
211}
212
213#[derive(Debug, Clone, Default, Serialize, Deserialize)]
215pub struct ToolMatch {
216 #[serde(default, skip_serializing_if = "ToolAnnotationMatch::is_empty")]
219 pub annotations: ToolAnnotationMatch,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub name_matches: Option<String>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub description_matches: Option<String>,
228}
229
230#[derive(Debug, Clone, Default, Serialize, Deserialize)]
232pub struct ToolAnnotationMatch {
233 #[serde(
234 default,
235 rename = "readOnlyHint",
236 skip_serializing_if = "Option::is_none"
237 )]
238 pub read_only_hint: Option<bool>,
239 #[serde(
240 default,
241 rename = "destructiveHint",
242 skip_serializing_if = "Option::is_none"
243 )]
244 pub destructive_hint: Option<bool>,
245 #[serde(
246 default,
247 rename = "idempotentHint",
248 skip_serializing_if = "Option::is_none"
249 )]
250 pub idempotent_hint: Option<bool>,
251 #[serde(
252 default,
253 rename = "openWorldHint",
254 skip_serializing_if = "Option::is_none"
255 )]
256 pub open_world_hint: Option<bool>,
257}
258
259impl ToolAnnotationMatch {
260 pub fn is_empty(&self) -> bool {
263 self.read_only_hint.is_none()
264 && self.destructive_hint.is_none()
265 && self.idempotent_hint.is_none()
266 && self.open_world_hint.is_none()
267 }
268}
269
270#[derive(Debug, Clone, Default, Serialize, Deserialize)]
273pub struct ApplyTemplate {
274 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub input: Option<InputMode>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub generate: Option<BTreeMap<String, ValueSpec>>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub fixed: Option<BTreeMap<String, Value>>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub cases: Option<u32>,
284 #[serde(rename = "assert")]
285 pub assertions: Vec<Assertion>,
286 #[serde(default, skip_serializing_if = "Vec::is_empty")]
287 pub test_fixtures: Vec<TestFixture>,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
298#[serde(rename_all = "snake_case")]
299pub enum InputMode {
300 SchemaValid,
306}
307
308#[derive(Debug, Clone, Default, Serialize, Deserialize)]
311pub struct PackMetadata {
312 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub name: Option<String>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub description: Option<String>,
319 #[serde(default, skip_serializing_if = "Vec::is_empty")]
322 pub authors: Vec<String>,
323 #[serde(default, skip_serializing_if = "Vec::is_empty")]
325 pub tags: Vec<String>,
326 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
330 pub parameters: BTreeMap<String, Parameter>,
331 #[serde(default, skip_serializing_if = "Vec::is_empty")]
336 pub extends: Vec<String>,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct Parameter {
342 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub description: Option<String>,
346 #[serde(default = "default_param_kind", rename = "type")]
349 pub kind: ParamKind,
350 pub default: Value,
353}
354
355fn default_param_kind() -> ParamKind {
356 ParamKind::String
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "lowercase")]
364pub enum ParamKind {
365 String,
366 Integer,
367 Number,
368 Boolean,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct Invariant {
373 pub name: String,
374 pub tool: String,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub input: Option<InputMode>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub generate: Option<BTreeMap<String, ValueSpec>>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub fixed: Option<BTreeMap<String, Value>>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub cases: Option<u32>,
386 #[serde(rename = "assert")]
387 pub assertions: Vec<Assertion>,
388 #[serde(default, skip_serializing_if = "Vec::is_empty")]
396 pub test_fixtures: Vec<TestFixture>,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct TestFixture {
404 pub name: String,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub input: Option<Value>,
411 pub response: Value,
413 pub expect: FixtureExpect,
416}
417
418#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
423#[serde(rename_all = "lowercase")]
424pub enum FixtureExpect {
425 Pass,
427 Fail,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct ValueSpec {
433 #[serde(rename = "type")]
434 pub kind: ValueKind,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub min_length: Option<usize>,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub max_length: Option<usize>,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub min: Option<i64>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub max: Option<i64>,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub items: Option<Box<ValueSpec>>,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub min_items: Option<usize>,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub max_items: Option<usize>,
449}
450
451#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
452#[serde(rename_all = "lowercase")]
453pub enum ValueKind {
454 String,
455 Integer,
456 Number,
457 Boolean,
458 Array,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
472#[serde(untagged)]
473pub enum Operand {
474 Path {
476 path: String,
478 },
479 Literal {
481 value: Value,
483 },
484 Direct(Value),
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
491#[serde(tag = "kind", rename_all = "snake_case")]
492pub enum Assertion {
493 Equals { lhs: Operand, rhs: Operand },
495 NotEquals { lhs: Operand, rhs: Operand },
497 AtMost { path: String, value: Operand },
499 AtLeast { path: String, value: Operand },
501 LengthEq { path: String, value: Operand },
503 LengthAtMost { path: String, value: Operand },
505 LengthAtLeast { path: String, value: Operand },
507 IsType {
509 path: String,
510 #[serde(rename = "type")]
511 expected: JsonType,
512 },
513 MatchesRegex { path: String, pattern: String },
515 AllOf {
517 #[serde(rename = "assert")]
518 assertions: Vec<Assertion>,
519 },
520 AnyOf {
522 #[serde(rename = "assert")]
523 assertions: Vec<Assertion>,
524 },
525 Not {
527 assertion: Box<Assertion>,
529 },
530 ForEach {
535 path: String,
536 #[serde(rename = "assert")]
537 assertions: Vec<Assertion>,
538 },
539 MatchesSchema {
541 path: String,
542 schema: Value,
545 },
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
549#[serde(rename_all = "lowercase")]
550pub enum JsonType {
551 String,
552 Number,
553 Integer,
554 Boolean,
555 Array,
556 Object,
557 Null,
558}
559
560pub fn parse(source: &str) -> Result<InvariantFile> {
564 parse_with_overrides(source, &BTreeMap::new())
565}
566
567pub fn parse_with_overrides(
582 source: &str,
583 overrides: &BTreeMap<String, String>,
584) -> Result<InvariantFile> {
585 let raw: serde_yaml::Value = serde_yaml::from_str(source)?;
588 let parameters = extract_parameters(&raw);
589
590 for key in overrides.keys() {
592 if !parameters.contains_key(key) {
593 return Err(DslError::UnknownParameterOverride(key.clone()));
594 }
595 }
596
597 let mut subst: BTreeMap<String, String> = parameters
599 .iter()
600 .map(|(name, param)| (name.clone(), stringify_default(¶m.default)))
601 .collect();
602 for (key, value) in overrides {
603 subst.insert(key.clone(), value.clone());
604 }
605
606 if has_for_each_tool(&raw) {
614 subst
615 .entry("tool_name".to_string())
616 .or_insert_with(|| "{{tool_name}}".to_string());
617 }
618
619 let substituted = render_template(source, &subst)?;
622
623 let file: InvariantFile = serde_yaml::from_str(&substituted)?;
625 if file.version == 0 || file.version > MAX_VERSION {
626 return Err(DslError::UnsupportedVersion(file.version));
627 }
628 for invariant in &file.invariants {
629 if invariant.generate.is_some() == invariant.fixed.is_some() {
630 return Err(DslError::InvalidInputMode(invariant.name.clone()));
631 }
632 }
633 Ok(file)
634}
635
636pub fn synthesize_for_test(block: &ForEachToolBlock, placeholder: &str) -> Result<Invariant> {
642 let yaml = serde_yaml::to_string(&block.apply)?;
643 let substituted = yaml
644 .replace("{{tool_name}}", placeholder)
645 .replace("{{ tool_name }}", placeholder);
646 let apply: ApplyTemplate = serde_yaml::from_str(&substituted)?;
647 let name = block
648 .name
649 .replace("{{tool_name}}", placeholder)
650 .replace("{{ tool_name }}", placeholder);
651 Ok(Invariant {
652 name,
653 tool: placeholder.to_string(),
654 input: apply.input,
655 generate: apply.generate,
656 fixed: apply.fixed,
657 cases: apply.cases,
658 assertions: apply.assertions,
659 test_fixtures: apply.test_fixtures,
660 })
661}
662
663pub fn expand_for_each_tool(
674 blocks: &[ForEachToolBlock],
675 tools: &[rmcp::model::Tool],
676) -> Result<Vec<Invariant>> {
677 let mut out = Vec::new();
678 for block in blocks {
679 let name_re = block
680 .matches
681 .name_matches
682 .as_deref()
683 .map(Regex::new)
684 .transpose()
685 .map_err(|err| DslError::InvalidWhereRegex(err.to_string()))?;
686 let description_re = block
687 .matches
688 .description_matches
689 .as_deref()
690 .map(Regex::new)
691 .transpose()
692 .map_err(|err| DslError::InvalidWhereRegex(err.to_string()))?;
693
694 for tool in tools {
695 if !block
696 .matches
697 .matches(tool, name_re.as_ref(), description_re.as_ref())
698 {
699 continue;
700 }
701 let tool_name = tool.name.as_ref();
702 let yaml = serde_yaml::to_string(&block.apply)?;
708 let substituted = yaml
709 .replace("{{tool_name}}", tool_name)
710 .replace("{{ tool_name }}", tool_name);
711 let apply: ApplyTemplate = serde_yaml::from_str(&substituted)?;
712 let name = block
713 .name
714 .replace("{{tool_name}}", tool_name)
715 .replace("{{ tool_name }}", tool_name);
716 out.push(Invariant {
717 name,
718 tool: tool_name.to_string(),
719 input: apply.input,
720 generate: apply.generate,
721 fixed: apply.fixed,
722 cases: apply.cases,
723 assertions: apply.assertions,
724 test_fixtures: apply.test_fixtures,
725 });
726 }
727 }
728 Ok(out)
729}
730
731impl ToolMatch {
732 pub fn matches(
736 &self,
737 tool: &rmcp::model::Tool,
738 name_re: Option<&Regex>,
739 description_re: Option<&Regex>,
740 ) -> bool {
741 let annotations = tool.annotations.as_ref();
742 let check_bool = |configured: Option<bool>, actual: Option<bool>| -> bool {
743 match configured {
744 Some(want) => actual == Some(want),
745 None => true,
746 }
747 };
748 if !check_bool(
749 self.annotations.read_only_hint,
750 annotations.and_then(|a| a.read_only_hint),
751 ) {
752 return false;
753 }
754 if !check_bool(
755 self.annotations.destructive_hint,
756 annotations.and_then(|a| a.destructive_hint),
757 ) {
758 return false;
759 }
760 if !check_bool(
761 self.annotations.idempotent_hint,
762 annotations.and_then(|a| a.idempotent_hint),
763 ) {
764 return false;
765 }
766 if !check_bool(
767 self.annotations.open_world_hint,
768 annotations.and_then(|a| a.open_world_hint),
769 ) {
770 return false;
771 }
772 if let Some(re) = name_re {
773 if !re.is_match(tool.name.as_ref()) {
774 return false;
775 }
776 }
777 if let Some(re) = description_re {
778 let description = tool.description.as_deref().unwrap_or("");
779 if !re.is_match(description) {
780 return false;
781 }
782 }
783 true
784 }
785}
786
787fn has_for_each_tool(value: &serde_yaml::Value) -> bool {
790 let key = serde_yaml::Value::String("for_each_tool".to_string());
791 value
792 .as_mapping()
793 .and_then(|m| m.get(&key))
794 .and_then(|v| v.as_sequence())
795 .is_some_and(|seq| !seq.is_empty())
796}
797
798fn extract_parameters(value: &serde_yaml::Value) -> BTreeMap<String, Parameter> {
802 let metadata_key = serde_yaml::Value::String("metadata".to_string());
803 let parameters_key = serde_yaml::Value::String("parameters".to_string());
804 let Some(metadata) = value.as_mapping().and_then(|m| m.get(&metadata_key)) else {
805 return BTreeMap::new();
806 };
807 let Some(parameters) = metadata.as_mapping().and_then(|m| m.get(¶meters_key)) else {
808 return BTreeMap::new();
809 };
810 serde_yaml::from_value(parameters.clone()).unwrap_or_default()
811}
812
813fn stringify_default(value: &Value) -> String {
814 match value {
815 Value::String(s) => s.clone(),
816 Value::Bool(b) => b.to_string(),
817 Value::Number(n) => n.to_string(),
818 Value::Null => String::new(),
819 other => other.to_string(),
822 }
823}
824
825#[allow(
829 clippy::expect_used,
830 clippy::unwrap_in_result,
831 reason = "static regex pattern is checked at compile-time review and cannot fail at runtime"
832)]
833fn render_template(template: &str, vars: &BTreeMap<String, String>) -> Result<String> {
834 let re =
837 Regex::new(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}").expect("static regex must compile");
838 let mut missing: Vec<String> = Vec::new();
839 let result = re.replace_all(template, |captures: ®ex::Captures<'_>| {
840 let name = captures.get(1).map(|m| m.as_str()).unwrap_or("");
841 match vars.get(name) {
842 Some(value) => value.clone(),
843 None => {
844 if !missing.iter().any(|existing| existing == name) {
845 missing.push(name.to_string());
846 }
847 captures
848 .get(0)
849 .map(|m| m.as_str().to_string())
850 .unwrap_or_default()
851 }
852 }
853 });
854 if !missing.is_empty() {
855 return Err(DslError::UndefinedParameters(missing));
856 }
857 Ok(result.into_owned())
858}
859
860#[cfg(test)]
861#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
862mod tests {
863 use super::*;
864
865 #[test]
866 fn v1_legacy_form_still_parses() {
867 let source = r#"
868version: 1
869invariants:
870 - name: demo
871 tool: echo
872 fixed: { text: hello }
873 assert:
874 - kind: equals
875 lhs: "$.response.text"
876 rhs: "$.input.text"
877"#;
878 let file = parse(source).unwrap();
879 assert_eq!(file.version, 1);
880 assert_eq!(file.invariants.len(), 1);
881 match &file.invariants[0].assertions[0] {
882 Assertion::Equals { lhs, rhs } => {
883 assert!(matches!(lhs, Operand::Direct(Value::String(s)) if s == "$.response.text"));
886 assert!(matches!(rhs, Operand::Direct(Value::String(s)) if s == "$.input.text"));
887 }
888 other => panic!("unexpected: {other:?}"),
889 }
890 }
891
892 #[test]
893 fn v2_explicit_operands_parse() {
894 let source = r#"
895version: 2
896invariants:
897 - name: demo
898 tool: echo
899 fixed: { text: hello }
900 assert:
901 - kind: equals
902 lhs: { path: "$.response.text" }
903 rhs: { value: hello }
904"#;
905 let file = parse(source).unwrap();
906 match &file.invariants[0].assertions[0] {
907 Assertion::Equals { lhs, rhs } => {
908 assert!(matches!(lhs, Operand::Path { path } if path == "$.response.text"));
909 assert!(
910 matches!(rhs, Operand::Literal { value } if value == &Value::String("hello".into()))
911 );
912 }
913 other => panic!("unexpected: {other:?}"),
914 }
915 }
916
917 #[test]
918 fn combinators_round_trip() {
919 let source = r#"
920version: 2
921invariants:
922 - name: combinators
923 tool: t
924 fixed: {}
925 assert:
926 - kind: all_of
927 assert:
928 - kind: equals
929 lhs: { path: "$.response.a" }
930 rhs: { value: 1 }
931 - kind: any_of
932 assert:
933 - kind: at_least
934 path: "$.response.b"
935 value: { value: 0 }
936 - kind: not
937 assertion:
938 kind: equals
939 lhs: { path: "$.response.b" }
940 rhs: { value: -1 }
941"#;
942 let file = parse(source).unwrap();
943 let serialized = serde_yaml::to_string(&file).unwrap();
944 let reparsed = parse(&serialized).unwrap();
945 assert_eq!(reparsed.invariants.len(), 1);
946 let Assertion::AllOf { assertions } = &reparsed.invariants[0].assertions[0] else {
948 panic!("expected all_of");
949 };
950 assert_eq!(assertions.len(), 2);
951 assert!(matches!(assertions[1], Assertion::AnyOf { .. }));
952 }
953
954 #[test]
955 fn for_each_parses() {
956 let source = r#"
957version: 2
958invariants:
959 - name: items
960 tool: list
961 fixed: {}
962 assert:
963 - kind: for_each
964 path: "$.response.items[*]"
965 assert:
966 - kind: is_type
967 path: "$.item.id"
968 type: integer
969"#;
970 let file = parse(source).unwrap();
971 let Assertion::ForEach { path, assertions } = &file.invariants[0].assertions[0] else {
972 panic!("expected for_each");
973 };
974 assert_eq!(path, "$.response.items[*]");
975 assert_eq!(assertions.len(), 1);
976 }
977
978 #[test]
979 fn matches_schema_carries_inline_schema() {
980 let source = r#"
981version: 2
982invariants:
983 - name: shape
984 tool: t
985 fixed: {}
986 assert:
987 - kind: matches_schema
988 path: "$.response.user"
989 schema:
990 type: object
991 required: [name]
992 properties:
993 name: { type: string }
994"#;
995 let file = parse(source).unwrap();
996 let Assertion::MatchesSchema { path, schema } = &file.invariants[0].assertions[0] else {
997 panic!("expected matches_schema");
998 };
999 assert_eq!(path, "$.response.user");
1000 assert_eq!(schema["type"], Value::String("object".into()));
1001 let required = schema["required"].as_array().unwrap();
1002 assert_eq!(required[0], Value::String("name".into()));
1003 }
1004
1005 #[test]
1006 fn unsupported_version_is_rejected() {
1007 let source = r#"
1008version: 99
1009invariants: []
1010"#;
1011 let err = parse(source).unwrap_err();
1012 assert!(matches!(err, DslError::UnsupportedVersion(99)));
1013 }
1014
1015 #[test]
1016 fn generate_xor_fixed_is_enforced() {
1017 let source = r#"
1018version: 2
1019invariants:
1020 - name: bad
1021 tool: t
1022 generate: { x: { type: integer, min: 0, max: 1 } }
1023 fixed: { x: 0 }
1024 assert: []
1025"#;
1026 let err = parse(source).unwrap_err();
1027 assert!(matches!(err, DslError::InvalidInputMode(_)));
1028 }
1029
1030 #[test]
1033 fn v3_minimal_pack_parses() {
1034 let source = r#"
1035version: 3
1036metadata:
1037 name: demo
1038 description: "demo pack"
1039 authors: ["wallfacer-core"]
1040 tags: [security]
1041invariants:
1042 - name: t
1043 tool: echo
1044 fixed: {}
1045 assert:
1046 - kind: equals
1047 lhs: { value: 1 }
1048 rhs: { value: 1 }
1049"#;
1050 let file = parse(source).unwrap();
1051 assert_eq!(file.version, 3);
1052 let meta = file.metadata.as_ref().expect("metadata");
1053 assert_eq!(meta.name.as_deref(), Some("demo"));
1054 assert_eq!(meta.tags, vec!["security".to_string()]);
1055 }
1056
1057 #[test]
1058 fn templating_substitutes_defaults() {
1059 let source = r#"
1060version: 3
1061metadata:
1062 name: demo
1063 parameters:
1064 whoami_tool:
1065 description: tool returning the current user
1066 type: string
1067 default: whoami
1068invariants:
1069 - name: t
1070 tool: "{{whoami_tool}}"
1071 fixed: {}
1072 assert: []
1073"#;
1074 let file = parse(source).unwrap();
1075 assert_eq!(file.invariants[0].tool, "whoami");
1076 }
1077
1078 #[test]
1079 fn templating_overrides_take_precedence() {
1080 let source = r#"
1081version: 3
1082metadata:
1083 name: demo
1084 parameters:
1085 whoami_tool:
1086 type: string
1087 default: whoami
1088invariants:
1089 - name: t
1090 tool: "{{whoami_tool}}"
1091 fixed: {}
1092 assert: []
1093"#;
1094 let mut overrides = BTreeMap::new();
1095 overrides.insert("whoami_tool".to_string(), "getCurrentUser".to_string());
1096 let file = parse_with_overrides(source, &overrides).unwrap();
1097 assert_eq!(file.invariants[0].tool, "getCurrentUser");
1098 }
1099
1100 #[test]
1101 fn templating_undeclared_reference_errors() {
1102 let source = r#"
1103version: 3
1104metadata:
1105 name: demo
1106invariants:
1107 - name: t
1108 tool: "{{whoami_tool}}"
1109 fixed: {}
1110 assert: []
1111"#;
1112 let err = parse(source).unwrap_err();
1113 match err {
1114 DslError::UndefinedParameters(names) => {
1115 assert_eq!(names, vec!["whoami_tool".to_string()]);
1116 }
1117 other => panic!("expected UndefinedParameters, got {other:?}"),
1118 }
1119 }
1120
1121 #[test]
1122 fn templating_unknown_override_errors() {
1123 let source = r#"
1124version: 3
1125metadata:
1126 name: demo
1127invariants:
1128 - name: t
1129 tool: echo
1130 fixed: {}
1131 assert: []
1132"#;
1133 let mut overrides = BTreeMap::new();
1134 overrides.insert("typoed".to_string(), "x".to_string());
1135 let err = parse_with_overrides(source, &overrides).unwrap_err();
1136 assert!(matches!(err, DslError::UnknownParameterOverride(name) if name == "typoed"));
1137 }
1138
1139 #[test]
1140 fn templating_handles_repeated_references() {
1141 let source = r#"
1142version: 3
1143metadata:
1144 name: demo
1145 parameters:
1146 user_tool:
1147 type: string
1148 default: whoami
1149invariants:
1150 - name: same
1151 tool: "{{user_tool}}"
1152 fixed: {}
1153 assert:
1154 - kind: equals
1155 lhs: { path: "$.input" }
1156 rhs: { value: "{{ user_tool }}" }
1157"#;
1158 let file = parse(source).unwrap();
1159 assert_eq!(file.invariants[0].tool, "whoami");
1160 }
1161
1162 #[test]
1163 fn v2_packs_remain_valid_under_v3_parser() {
1164 let source = r#"
1166version: 2
1167invariants:
1168 - name: legacy
1169 tool: echo
1170 fixed: { x: 1 }
1171 assert:
1172 - kind: equals
1173 lhs: { path: "$.input.x" }
1174 rhs: { value: 1 }
1175"#;
1176 let file = parse(source).unwrap();
1177 assert_eq!(file.version, 2);
1178 assert!(file.metadata.is_none());
1179 }
1180
1181 #[test]
1182 fn v3_round_trip_serde_preserves_metadata_and_invariants() {
1183 let source = r#"
1184version: 3
1185metadata:
1186 name: roundtrip
1187 description: probe for serde drift
1188 authors: [w]
1189 tags: [t]
1190 parameters:
1191 a: { type: string, default: foo }
1192 extends: [parent]
1193invariants:
1194 - name: i1
1195 tool: "{{a}}"
1196 fixed: {}
1197 assert: []
1198"#;
1199 let parsed = parse(source).unwrap();
1200 let yaml = serde_yaml::to_string(&parsed).unwrap();
1201 let reparsed = parse(&yaml).unwrap();
1202 assert_eq!(parsed.invariants.len(), reparsed.invariants.len());
1203 let m1 = parsed.metadata.unwrap();
1204 let m2 = reparsed.metadata.unwrap();
1205 assert_eq!(m1.name, m2.name);
1206 assert_eq!(m1.tags, m2.tags);
1207 assert_eq!(m1.extends, m2.extends);
1208 assert_eq!(m1.parameters.len(), m2.parameters.len());
1209 }
1210
1211 fn make_tool(name: &str, read_only: Option<bool>) -> rmcp::model::Tool {
1214 let mut tool = rmcp::model::Tool::new(
1215 name.to_string(),
1216 "test tool".to_string(),
1217 std::sync::Arc::new(serde_json::Map::new()),
1218 );
1219 if let Some(read_only) = read_only {
1220 let mut annotations = rmcp::model::ToolAnnotations::default();
1221 annotations.read_only_hint = Some(read_only);
1222 tool = tool.annotate(annotations);
1223 }
1224 tool
1225 }
1226
1227 #[test]
1228 fn for_each_tool_apply_parses_input_schema_valid() {
1229 let source = r#"
1230version: 3
1231metadata:
1232 name: schema-valid
1233for_each_tool:
1234 - name: "schema-valid.{{tool_name}}"
1235 where:
1236 annotations:
1237 readOnlyHint: true
1238 apply:
1239 input: schema_valid
1240 assert:
1241 - kind: equals
1242 lhs: { path: "$.response.isError" }
1243 rhs: { value: false }
1244invariants: []
1245"#;
1246 let file = parse(source).expect("parse");
1247 assert_eq!(file.for_each_tool.len(), 1);
1248 assert_eq!(
1249 file.for_each_tool[0].apply.input,
1250 Some(InputMode::SchemaValid),
1251 "`input: schema_valid` did not deserialise into the new enum"
1252 );
1253 assert!(file.for_each_tool[0].apply.fixed.is_none());
1255 assert!(file.for_each_tool[0].apply.generate.is_none());
1256 }
1257
1258 #[test]
1259 fn for_each_tool_parses_with_auto_injected_tool_name() {
1260 let source = r#"
1261version: 3
1262metadata:
1263 name: tool-annotations
1264for_each_tool:
1265 - name: "tool-annotations.read_only_does_not_mutate.{{tool_name}}"
1266 where:
1267 annotations:
1268 readOnlyHint: true
1269 apply:
1270 fixed: {}
1271 assert:
1272 - kind: matches_schema
1273 path: "$.response.structuredContent"
1274 schema: { type: object }
1275invariants: []
1276"#;
1277 let file = parse(source).expect("parse");
1278 assert_eq!(file.for_each_tool.len(), 1);
1279 let block = &file.for_each_tool[0];
1280 assert!(block.name.contains("{{tool_name}}"));
1283 assert_eq!(
1284 block.matches.annotations.read_only_hint,
1285 Some(true),
1286 "where clause didn't deserialise"
1287 );
1288 }
1289
1290 #[test]
1291 fn for_each_tool_expands_per_matching_tool() {
1292 let source = r#"
1293version: 3
1294metadata:
1295 name: tool-annotations
1296for_each_tool:
1297 - name: "rule.{{tool_name}}"
1298 where:
1299 annotations:
1300 readOnlyHint: true
1301 apply:
1302 fixed: {}
1303 assert:
1304 - kind: equals
1305 lhs: { value: 1 }
1306 rhs: { value: 1 }
1307invariants: []
1308"#;
1309 let file = parse(source).unwrap();
1310 let tools = vec![
1311 make_tool("read_user", Some(true)),
1312 make_tool("delete_user", Some(false)),
1313 make_tool("get_status", Some(true)),
1314 make_tool("no_annotations", None),
1315 ];
1316 let expanded = expand_for_each_tool(&file.for_each_tool, &tools).unwrap();
1317 let names: Vec<String> = expanded.iter().map(|i| i.name.clone()).collect();
1318 assert_eq!(
1319 names,
1320 vec!["rule.read_user".to_string(), "rule.get_status".to_string()]
1321 );
1322 assert_eq!(expanded[0].tool, "read_user");
1323 }
1324
1325 #[test]
1326 fn for_each_tool_filter_by_name_regex() {
1327 let source = r#"
1328version: 3
1329for_each_tool:
1330 - name: "rule.{{tool_name}}"
1331 where:
1332 name_matches: "^read_"
1333 apply:
1334 fixed: {}
1335 assert: []
1336invariants: []
1337"#;
1338 let file = parse(source).unwrap();
1339 let tools = vec![
1340 make_tool("read_user", None),
1341 make_tool("write_user", None),
1342 make_tool("read_post", None),
1343 ];
1344 let expanded = expand_for_each_tool(&file.for_each_tool, &tools).unwrap();
1345 let names: Vec<String> = expanded.iter().map(|i| i.name.clone()).collect();
1346 assert_eq!(
1347 names,
1348 vec!["rule.read_user".to_string(), "rule.read_post".to_string()]
1349 );
1350 }
1351
1352 #[test]
1353 fn for_each_tool_substitutes_in_apply_body() {
1354 let source = r#"
1355version: 3
1356for_each_tool:
1357 - name: "{{tool_name}}.contract"
1358 where: {}
1359 apply:
1360 fixed:
1361 echo_back: "{{tool_name}}"
1362 assert:
1363 - kind: equals
1364 lhs: { path: "$.input.echo_back" }
1365 rhs: { value: "{{tool_name}}" }
1366invariants: []
1367"#;
1368 let file = parse(source).unwrap();
1369 let tools = vec![make_tool("only_one", None)];
1370 let expanded = expand_for_each_tool(&file.for_each_tool, &tools).unwrap();
1371 assert_eq!(expanded.len(), 1);
1372 assert_eq!(expanded[0].name, "only_one.contract");
1373 let fixed = expanded[0].fixed.as_ref().unwrap();
1374 assert_eq!(fixed["echo_back"], serde_json::json!("only_one"));
1375 }
1376}