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}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ForEachToolBlock {
97 pub name: String,
100 #[serde(rename = "where")]
102 pub matches: ToolMatch,
103 pub apply: ApplyTemplate,
106}
107
108#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct ToolMatch {
111 #[serde(default, skip_serializing_if = "ToolAnnotationMatch::is_empty")]
114 pub annotations: ToolAnnotationMatch,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub name_matches: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub description_matches: Option<String>,
123}
124
125#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127pub struct ToolAnnotationMatch {
128 #[serde(
129 default,
130 rename = "readOnlyHint",
131 skip_serializing_if = "Option::is_none"
132 )]
133 pub read_only_hint: Option<bool>,
134 #[serde(
135 default,
136 rename = "destructiveHint",
137 skip_serializing_if = "Option::is_none"
138 )]
139 pub destructive_hint: Option<bool>,
140 #[serde(
141 default,
142 rename = "idempotentHint",
143 skip_serializing_if = "Option::is_none"
144 )]
145 pub idempotent_hint: Option<bool>,
146 #[serde(
147 default,
148 rename = "openWorldHint",
149 skip_serializing_if = "Option::is_none"
150 )]
151 pub open_world_hint: Option<bool>,
152}
153
154impl ToolAnnotationMatch {
155 pub fn is_empty(&self) -> bool {
158 self.read_only_hint.is_none()
159 && self.destructive_hint.is_none()
160 && self.idempotent_hint.is_none()
161 && self.open_world_hint.is_none()
162 }
163}
164
165#[derive(Debug, Clone, Default, Serialize, Deserialize)]
168pub struct ApplyTemplate {
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub generate: Option<BTreeMap<String, ValueSpec>>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub fixed: Option<BTreeMap<String, Value>>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub cases: Option<u32>,
175 #[serde(rename = "assert")]
176 pub assertions: Vec<Assertion>,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
178 pub test_fixtures: Vec<TestFixture>,
179}
180
181#[derive(Debug, Clone, Default, Serialize, Deserialize)]
184pub struct PackMetadata {
185 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub name: Option<String>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub description: Option<String>,
192 #[serde(default, skip_serializing_if = "Vec::is_empty")]
195 pub authors: Vec<String>,
196 #[serde(default, skip_serializing_if = "Vec::is_empty")]
198 pub tags: Vec<String>,
199 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
203 pub parameters: BTreeMap<String, Parameter>,
204 #[serde(default, skip_serializing_if = "Vec::is_empty")]
209 pub extends: Vec<String>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct Parameter {
215 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub description: Option<String>,
219 #[serde(default = "default_param_kind", rename = "type")]
222 pub kind: ParamKind,
223 pub default: Value,
226}
227
228fn default_param_kind() -> ParamKind {
229 ParamKind::String
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "lowercase")]
237pub enum ParamKind {
238 String,
239 Integer,
240 Number,
241 Boolean,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct Invariant {
246 pub name: String,
247 pub tool: String,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub generate: Option<BTreeMap<String, ValueSpec>>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub fixed: Option<BTreeMap<String, Value>>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub cases: Option<u32>,
254 #[serde(rename = "assert")]
255 pub assertions: Vec<Assertion>,
256 #[serde(default, skip_serializing_if = "Vec::is_empty")]
264 pub test_fixtures: Vec<TestFixture>,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct TestFixture {
272 pub name: String,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub input: Option<Value>,
279 pub response: Value,
281 pub expect: FixtureExpect,
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(rename_all = "lowercase")]
292pub enum FixtureExpect {
293 Pass,
295 Fail,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ValueSpec {
301 #[serde(rename = "type")]
302 pub kind: ValueKind,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub min_length: Option<usize>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub max_length: Option<usize>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub min: Option<i64>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub max: Option<i64>,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub items: Option<Box<ValueSpec>>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub min_items: Option<usize>,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub max_items: Option<usize>,
317}
318
319#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
320#[serde(rename_all = "lowercase")]
321pub enum ValueKind {
322 String,
323 Integer,
324 Number,
325 Boolean,
326 Array,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
340#[serde(untagged)]
341pub enum Operand {
342 Path {
344 path: String,
346 },
347 Literal {
349 value: Value,
351 },
352 Direct(Value),
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
359#[serde(tag = "kind", rename_all = "snake_case")]
360pub enum Assertion {
361 Equals { lhs: Operand, rhs: Operand },
363 NotEquals { lhs: Operand, rhs: Operand },
365 AtMost { path: String, value: Operand },
367 AtLeast { path: String, value: Operand },
369 LengthEq { path: String, value: Operand },
371 LengthAtMost { path: String, value: Operand },
373 LengthAtLeast { path: String, value: Operand },
375 IsType {
377 path: String,
378 #[serde(rename = "type")]
379 expected: JsonType,
380 },
381 MatchesRegex { path: String, pattern: String },
383 AllOf {
385 #[serde(rename = "assert")]
386 assertions: Vec<Assertion>,
387 },
388 AnyOf {
390 #[serde(rename = "assert")]
391 assertions: Vec<Assertion>,
392 },
393 Not {
395 assertion: Box<Assertion>,
397 },
398 ForEach {
403 path: String,
404 #[serde(rename = "assert")]
405 assertions: Vec<Assertion>,
406 },
407 MatchesSchema {
409 path: String,
410 schema: Value,
413 },
414}
415
416#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
417#[serde(rename_all = "lowercase")]
418pub enum JsonType {
419 String,
420 Number,
421 Integer,
422 Boolean,
423 Array,
424 Object,
425 Null,
426}
427
428pub fn parse(source: &str) -> Result<InvariantFile> {
432 parse_with_overrides(source, &BTreeMap::new())
433}
434
435pub fn parse_with_overrides(
450 source: &str,
451 overrides: &BTreeMap<String, String>,
452) -> Result<InvariantFile> {
453 let raw: serde_yaml::Value = serde_yaml::from_str(source)?;
456 let parameters = extract_parameters(&raw);
457
458 for key in overrides.keys() {
460 if !parameters.contains_key(key) {
461 return Err(DslError::UnknownParameterOverride(key.clone()));
462 }
463 }
464
465 let mut subst: BTreeMap<String, String> = parameters
467 .iter()
468 .map(|(name, param)| (name.clone(), stringify_default(¶m.default)))
469 .collect();
470 for (key, value) in overrides {
471 subst.insert(key.clone(), value.clone());
472 }
473
474 if has_for_each_tool(&raw) {
482 subst
483 .entry("tool_name".to_string())
484 .or_insert_with(|| "{{tool_name}}".to_string());
485 }
486
487 let substituted = render_template(source, &subst)?;
490
491 let file: InvariantFile = serde_yaml::from_str(&substituted)?;
493 if file.version == 0 || file.version > MAX_VERSION {
494 return Err(DslError::UnsupportedVersion(file.version));
495 }
496 for invariant in &file.invariants {
497 if invariant.generate.is_some() == invariant.fixed.is_some() {
498 return Err(DslError::InvalidInputMode(invariant.name.clone()));
499 }
500 }
501 Ok(file)
502}
503
504pub fn synthesize_for_test(block: &ForEachToolBlock, placeholder: &str) -> Result<Invariant> {
510 let yaml = serde_yaml::to_string(&block.apply)?;
511 let substituted = yaml
512 .replace("{{tool_name}}", placeholder)
513 .replace("{{ tool_name }}", placeholder);
514 let apply: ApplyTemplate = serde_yaml::from_str(&substituted)?;
515 let name = block
516 .name
517 .replace("{{tool_name}}", placeholder)
518 .replace("{{ tool_name }}", placeholder);
519 Ok(Invariant {
520 name,
521 tool: placeholder.to_string(),
522 generate: apply.generate,
523 fixed: apply.fixed,
524 cases: apply.cases,
525 assertions: apply.assertions,
526 test_fixtures: apply.test_fixtures,
527 })
528}
529
530pub fn expand_for_each_tool(
541 blocks: &[ForEachToolBlock],
542 tools: &[rmcp::model::Tool],
543) -> Result<Vec<Invariant>> {
544 let mut out = Vec::new();
545 for block in blocks {
546 let name_re = block
547 .matches
548 .name_matches
549 .as_deref()
550 .map(Regex::new)
551 .transpose()
552 .map_err(|err| DslError::InvalidWhereRegex(err.to_string()))?;
553 let description_re = block
554 .matches
555 .description_matches
556 .as_deref()
557 .map(Regex::new)
558 .transpose()
559 .map_err(|err| DslError::InvalidWhereRegex(err.to_string()))?;
560
561 for tool in tools {
562 if !block
563 .matches
564 .matches(tool, name_re.as_ref(), description_re.as_ref())
565 {
566 continue;
567 }
568 let tool_name = tool.name.as_ref();
569 let yaml = serde_yaml::to_string(&block.apply)?;
575 let substituted = yaml
576 .replace("{{tool_name}}", tool_name)
577 .replace("{{ tool_name }}", tool_name);
578 let apply: ApplyTemplate = serde_yaml::from_str(&substituted)?;
579 let name = block
580 .name
581 .replace("{{tool_name}}", tool_name)
582 .replace("{{ tool_name }}", tool_name);
583 out.push(Invariant {
584 name,
585 tool: tool_name.to_string(),
586 generate: apply.generate,
587 fixed: apply.fixed,
588 cases: apply.cases,
589 assertions: apply.assertions,
590 test_fixtures: apply.test_fixtures,
591 });
592 }
593 }
594 Ok(out)
595}
596
597impl ToolMatch {
598 pub fn matches(
602 &self,
603 tool: &rmcp::model::Tool,
604 name_re: Option<&Regex>,
605 description_re: Option<&Regex>,
606 ) -> bool {
607 let annotations = tool.annotations.as_ref();
608 let check_bool = |configured: Option<bool>, actual: Option<bool>| -> bool {
609 match configured {
610 Some(want) => actual == Some(want),
611 None => true,
612 }
613 };
614 if !check_bool(
615 self.annotations.read_only_hint,
616 annotations.and_then(|a| a.read_only_hint),
617 ) {
618 return false;
619 }
620 if !check_bool(
621 self.annotations.destructive_hint,
622 annotations.and_then(|a| a.destructive_hint),
623 ) {
624 return false;
625 }
626 if !check_bool(
627 self.annotations.idempotent_hint,
628 annotations.and_then(|a| a.idempotent_hint),
629 ) {
630 return false;
631 }
632 if !check_bool(
633 self.annotations.open_world_hint,
634 annotations.and_then(|a| a.open_world_hint),
635 ) {
636 return false;
637 }
638 if let Some(re) = name_re {
639 if !re.is_match(tool.name.as_ref()) {
640 return false;
641 }
642 }
643 if let Some(re) = description_re {
644 let description = tool.description.as_deref().unwrap_or("");
645 if !re.is_match(description) {
646 return false;
647 }
648 }
649 true
650 }
651}
652
653fn has_for_each_tool(value: &serde_yaml::Value) -> bool {
656 let key = serde_yaml::Value::String("for_each_tool".to_string());
657 value
658 .as_mapping()
659 .and_then(|m| m.get(&key))
660 .and_then(|v| v.as_sequence())
661 .is_some_and(|seq| !seq.is_empty())
662}
663
664fn extract_parameters(value: &serde_yaml::Value) -> BTreeMap<String, Parameter> {
668 let metadata_key = serde_yaml::Value::String("metadata".to_string());
669 let parameters_key = serde_yaml::Value::String("parameters".to_string());
670 let Some(metadata) = value.as_mapping().and_then(|m| m.get(&metadata_key)) else {
671 return BTreeMap::new();
672 };
673 let Some(parameters) = metadata.as_mapping().and_then(|m| m.get(¶meters_key)) else {
674 return BTreeMap::new();
675 };
676 serde_yaml::from_value(parameters.clone()).unwrap_or_default()
677}
678
679fn stringify_default(value: &Value) -> String {
680 match value {
681 Value::String(s) => s.clone(),
682 Value::Bool(b) => b.to_string(),
683 Value::Number(n) => n.to_string(),
684 Value::Null => String::new(),
685 other => other.to_string(),
688 }
689}
690
691#[allow(
695 clippy::expect_used,
696 clippy::unwrap_in_result,
697 reason = "static regex pattern is checked at compile-time review and cannot fail at runtime"
698)]
699fn render_template(template: &str, vars: &BTreeMap<String, String>) -> Result<String> {
700 let re =
703 Regex::new(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}").expect("static regex must compile");
704 let mut missing: Vec<String> = Vec::new();
705 let result = re.replace_all(template, |captures: ®ex::Captures<'_>| {
706 let name = captures.get(1).map(|m| m.as_str()).unwrap_or("");
707 match vars.get(name) {
708 Some(value) => value.clone(),
709 None => {
710 if !missing.iter().any(|existing| existing == name) {
711 missing.push(name.to_string());
712 }
713 captures
714 .get(0)
715 .map(|m| m.as_str().to_string())
716 .unwrap_or_default()
717 }
718 }
719 });
720 if !missing.is_empty() {
721 return Err(DslError::UndefinedParameters(missing));
722 }
723 Ok(result.into_owned())
724}
725
726#[cfg(test)]
727#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
728mod tests {
729 use super::*;
730
731 #[test]
732 fn v1_legacy_form_still_parses() {
733 let source = r#"
734version: 1
735invariants:
736 - name: demo
737 tool: echo
738 fixed: { text: hello }
739 assert:
740 - kind: equals
741 lhs: "$.response.text"
742 rhs: "$.input.text"
743"#;
744 let file = parse(source).unwrap();
745 assert_eq!(file.version, 1);
746 assert_eq!(file.invariants.len(), 1);
747 match &file.invariants[0].assertions[0] {
748 Assertion::Equals { lhs, rhs } => {
749 assert!(matches!(lhs, Operand::Direct(Value::String(s)) if s == "$.response.text"));
752 assert!(matches!(rhs, Operand::Direct(Value::String(s)) if s == "$.input.text"));
753 }
754 other => panic!("unexpected: {other:?}"),
755 }
756 }
757
758 #[test]
759 fn v2_explicit_operands_parse() {
760 let source = r#"
761version: 2
762invariants:
763 - name: demo
764 tool: echo
765 fixed: { text: hello }
766 assert:
767 - kind: equals
768 lhs: { path: "$.response.text" }
769 rhs: { value: hello }
770"#;
771 let file = parse(source).unwrap();
772 match &file.invariants[0].assertions[0] {
773 Assertion::Equals { lhs, rhs } => {
774 assert!(matches!(lhs, Operand::Path { path } if path == "$.response.text"));
775 assert!(
776 matches!(rhs, Operand::Literal { value } if value == &Value::String("hello".into()))
777 );
778 }
779 other => panic!("unexpected: {other:?}"),
780 }
781 }
782
783 #[test]
784 fn combinators_round_trip() {
785 let source = r#"
786version: 2
787invariants:
788 - name: combinators
789 tool: t
790 fixed: {}
791 assert:
792 - kind: all_of
793 assert:
794 - kind: equals
795 lhs: { path: "$.response.a" }
796 rhs: { value: 1 }
797 - kind: any_of
798 assert:
799 - kind: at_least
800 path: "$.response.b"
801 value: { value: 0 }
802 - kind: not
803 assertion:
804 kind: equals
805 lhs: { path: "$.response.b" }
806 rhs: { value: -1 }
807"#;
808 let file = parse(source).unwrap();
809 let serialized = serde_yaml::to_string(&file).unwrap();
810 let reparsed = parse(&serialized).unwrap();
811 assert_eq!(reparsed.invariants.len(), 1);
812 let Assertion::AllOf { assertions } = &reparsed.invariants[0].assertions[0] else {
814 panic!("expected all_of");
815 };
816 assert_eq!(assertions.len(), 2);
817 assert!(matches!(assertions[1], Assertion::AnyOf { .. }));
818 }
819
820 #[test]
821 fn for_each_parses() {
822 let source = r#"
823version: 2
824invariants:
825 - name: items
826 tool: list
827 fixed: {}
828 assert:
829 - kind: for_each
830 path: "$.response.items[*]"
831 assert:
832 - kind: is_type
833 path: "$.item.id"
834 type: integer
835"#;
836 let file = parse(source).unwrap();
837 let Assertion::ForEach { path, assertions } = &file.invariants[0].assertions[0] else {
838 panic!("expected for_each");
839 };
840 assert_eq!(path, "$.response.items[*]");
841 assert_eq!(assertions.len(), 1);
842 }
843
844 #[test]
845 fn matches_schema_carries_inline_schema() {
846 let source = r#"
847version: 2
848invariants:
849 - name: shape
850 tool: t
851 fixed: {}
852 assert:
853 - kind: matches_schema
854 path: "$.response.user"
855 schema:
856 type: object
857 required: [name]
858 properties:
859 name: { type: string }
860"#;
861 let file = parse(source).unwrap();
862 let Assertion::MatchesSchema { path, schema } = &file.invariants[0].assertions[0] else {
863 panic!("expected matches_schema");
864 };
865 assert_eq!(path, "$.response.user");
866 assert_eq!(schema["type"], Value::String("object".into()));
867 let required = schema["required"].as_array().unwrap();
868 assert_eq!(required[0], Value::String("name".into()));
869 }
870
871 #[test]
872 fn unsupported_version_is_rejected() {
873 let source = r#"
874version: 99
875invariants: []
876"#;
877 let err = parse(source).unwrap_err();
878 assert!(matches!(err, DslError::UnsupportedVersion(99)));
879 }
880
881 #[test]
882 fn generate_xor_fixed_is_enforced() {
883 let source = r#"
884version: 2
885invariants:
886 - name: bad
887 tool: t
888 generate: { x: { type: integer, min: 0, max: 1 } }
889 fixed: { x: 0 }
890 assert: []
891"#;
892 let err = parse(source).unwrap_err();
893 assert!(matches!(err, DslError::InvalidInputMode(_)));
894 }
895
896 #[test]
899 fn v3_minimal_pack_parses() {
900 let source = r#"
901version: 3
902metadata:
903 name: demo
904 description: "demo pack"
905 authors: ["wallfacer-core"]
906 tags: [security]
907invariants:
908 - name: t
909 tool: echo
910 fixed: {}
911 assert:
912 - kind: equals
913 lhs: { value: 1 }
914 rhs: { value: 1 }
915"#;
916 let file = parse(source).unwrap();
917 assert_eq!(file.version, 3);
918 let meta = file.metadata.as_ref().expect("metadata");
919 assert_eq!(meta.name.as_deref(), Some("demo"));
920 assert_eq!(meta.tags, vec!["security".to_string()]);
921 }
922
923 #[test]
924 fn templating_substitutes_defaults() {
925 let source = r#"
926version: 3
927metadata:
928 name: demo
929 parameters:
930 whoami_tool:
931 description: tool returning the current user
932 type: string
933 default: whoami
934invariants:
935 - name: t
936 tool: "{{whoami_tool}}"
937 fixed: {}
938 assert: []
939"#;
940 let file = parse(source).unwrap();
941 assert_eq!(file.invariants[0].tool, "whoami");
942 }
943
944 #[test]
945 fn templating_overrides_take_precedence() {
946 let source = r#"
947version: 3
948metadata:
949 name: demo
950 parameters:
951 whoami_tool:
952 type: string
953 default: whoami
954invariants:
955 - name: t
956 tool: "{{whoami_tool}}"
957 fixed: {}
958 assert: []
959"#;
960 let mut overrides = BTreeMap::new();
961 overrides.insert("whoami_tool".to_string(), "getCurrentUser".to_string());
962 let file = parse_with_overrides(source, &overrides).unwrap();
963 assert_eq!(file.invariants[0].tool, "getCurrentUser");
964 }
965
966 #[test]
967 fn templating_undeclared_reference_errors() {
968 let source = r#"
969version: 3
970metadata:
971 name: demo
972invariants:
973 - name: t
974 tool: "{{whoami_tool}}"
975 fixed: {}
976 assert: []
977"#;
978 let err = parse(source).unwrap_err();
979 match err {
980 DslError::UndefinedParameters(names) => {
981 assert_eq!(names, vec!["whoami_tool".to_string()]);
982 }
983 other => panic!("expected UndefinedParameters, got {other:?}"),
984 }
985 }
986
987 #[test]
988 fn templating_unknown_override_errors() {
989 let source = r#"
990version: 3
991metadata:
992 name: demo
993invariants:
994 - name: t
995 tool: echo
996 fixed: {}
997 assert: []
998"#;
999 let mut overrides = BTreeMap::new();
1000 overrides.insert("typoed".to_string(), "x".to_string());
1001 let err = parse_with_overrides(source, &overrides).unwrap_err();
1002 assert!(matches!(err, DslError::UnknownParameterOverride(name) if name == "typoed"));
1003 }
1004
1005 #[test]
1006 fn templating_handles_repeated_references() {
1007 let source = r#"
1008version: 3
1009metadata:
1010 name: demo
1011 parameters:
1012 user_tool:
1013 type: string
1014 default: whoami
1015invariants:
1016 - name: same
1017 tool: "{{user_tool}}"
1018 fixed: {}
1019 assert:
1020 - kind: equals
1021 lhs: { path: "$.input" }
1022 rhs: { value: "{{ user_tool }}" }
1023"#;
1024 let file = parse(source).unwrap();
1025 assert_eq!(file.invariants[0].tool, "whoami");
1026 }
1027
1028 #[test]
1029 fn v2_packs_remain_valid_under_v3_parser() {
1030 let source = r#"
1032version: 2
1033invariants:
1034 - name: legacy
1035 tool: echo
1036 fixed: { x: 1 }
1037 assert:
1038 - kind: equals
1039 lhs: { path: "$.input.x" }
1040 rhs: { value: 1 }
1041"#;
1042 let file = parse(source).unwrap();
1043 assert_eq!(file.version, 2);
1044 assert!(file.metadata.is_none());
1045 }
1046
1047 #[test]
1048 fn v3_round_trip_serde_preserves_metadata_and_invariants() {
1049 let source = r#"
1050version: 3
1051metadata:
1052 name: roundtrip
1053 description: probe for serde drift
1054 authors: [w]
1055 tags: [t]
1056 parameters:
1057 a: { type: string, default: foo }
1058 extends: [parent]
1059invariants:
1060 - name: i1
1061 tool: "{{a}}"
1062 fixed: {}
1063 assert: []
1064"#;
1065 let parsed = parse(source).unwrap();
1066 let yaml = serde_yaml::to_string(&parsed).unwrap();
1067 let reparsed = parse(&yaml).unwrap();
1068 assert_eq!(parsed.invariants.len(), reparsed.invariants.len());
1069 let m1 = parsed.metadata.unwrap();
1070 let m2 = reparsed.metadata.unwrap();
1071 assert_eq!(m1.name, m2.name);
1072 assert_eq!(m1.tags, m2.tags);
1073 assert_eq!(m1.extends, m2.extends);
1074 assert_eq!(m1.parameters.len(), m2.parameters.len());
1075 }
1076
1077 fn make_tool(name: &str, read_only: Option<bool>) -> rmcp::model::Tool {
1080 let mut tool = rmcp::model::Tool::new(
1081 name.to_string(),
1082 "test tool".to_string(),
1083 std::sync::Arc::new(serde_json::Map::new()),
1084 );
1085 if let Some(read_only) = read_only {
1086 let mut annotations = rmcp::model::ToolAnnotations::default();
1087 annotations.read_only_hint = Some(read_only);
1088 tool = tool.annotate(annotations);
1089 }
1090 tool
1091 }
1092
1093 #[test]
1094 fn for_each_tool_parses_with_auto_injected_tool_name() {
1095 let source = r#"
1096version: 3
1097metadata:
1098 name: tool-annotations
1099for_each_tool:
1100 - name: "tool-annotations.read_only_does_not_mutate.{{tool_name}}"
1101 where:
1102 annotations:
1103 readOnlyHint: true
1104 apply:
1105 fixed: {}
1106 assert:
1107 - kind: matches_schema
1108 path: "$.response.structuredContent"
1109 schema: { type: object }
1110invariants: []
1111"#;
1112 let file = parse(source).expect("parse");
1113 assert_eq!(file.for_each_tool.len(), 1);
1114 let block = &file.for_each_tool[0];
1115 assert!(block.name.contains("{{tool_name}}"));
1118 assert_eq!(
1119 block.matches.annotations.read_only_hint,
1120 Some(true),
1121 "where clause didn't deserialise"
1122 );
1123 }
1124
1125 #[test]
1126 fn for_each_tool_expands_per_matching_tool() {
1127 let source = r#"
1128version: 3
1129metadata:
1130 name: tool-annotations
1131for_each_tool:
1132 - name: "rule.{{tool_name}}"
1133 where:
1134 annotations:
1135 readOnlyHint: true
1136 apply:
1137 fixed: {}
1138 assert:
1139 - kind: equals
1140 lhs: { value: 1 }
1141 rhs: { value: 1 }
1142invariants: []
1143"#;
1144 let file = parse(source).unwrap();
1145 let tools = vec![
1146 make_tool("read_user", Some(true)),
1147 make_tool("delete_user", Some(false)),
1148 make_tool("get_status", Some(true)),
1149 make_tool("no_annotations", None),
1150 ];
1151 let expanded = expand_for_each_tool(&file.for_each_tool, &tools).unwrap();
1152 let names: Vec<String> = expanded.iter().map(|i| i.name.clone()).collect();
1153 assert_eq!(
1154 names,
1155 vec!["rule.read_user".to_string(), "rule.get_status".to_string()]
1156 );
1157 assert_eq!(expanded[0].tool, "read_user");
1158 }
1159
1160 #[test]
1161 fn for_each_tool_filter_by_name_regex() {
1162 let source = r#"
1163version: 3
1164for_each_tool:
1165 - name: "rule.{{tool_name}}"
1166 where:
1167 name_matches: "^read_"
1168 apply:
1169 fixed: {}
1170 assert: []
1171invariants: []
1172"#;
1173 let file = parse(source).unwrap();
1174 let tools = vec![
1175 make_tool("read_user", None),
1176 make_tool("write_user", None),
1177 make_tool("read_post", None),
1178 ];
1179 let expanded = expand_for_each_tool(&file.for_each_tool, &tools).unwrap();
1180 let names: Vec<String> = expanded.iter().map(|i| i.name.clone()).collect();
1181 assert_eq!(
1182 names,
1183 vec!["rule.read_user".to_string(), "rule.read_post".to_string()]
1184 );
1185 }
1186
1187 #[test]
1188 fn for_each_tool_substitutes_in_apply_body() {
1189 let source = r#"
1190version: 3
1191for_each_tool:
1192 - name: "{{tool_name}}.contract"
1193 where: {}
1194 apply:
1195 fixed:
1196 echo_back: "{{tool_name}}"
1197 assert:
1198 - kind: equals
1199 lhs: { path: "$.input.echo_back" }
1200 rhs: { value: "{{tool_name}}" }
1201invariants: []
1202"#;
1203 let file = parse(source).unwrap();
1204 let tools = vec![make_tool("only_one", None)];
1205 let expanded = expand_for_each_tool(&file.for_each_tool, &tools).unwrap();
1206 assert_eq!(expanded.len(), 1);
1207 assert_eq!(expanded[0].name, "only_one.contract");
1208 let fixed = expanded[0].fixed.as_ref().unwrap();
1209 assert_eq!(fixed["echo_back"], serde_json::json!("only_one"));
1210 }
1211}