Skip to main content

wallfacer_core/property/
dsl.rs

1//! YAML invariants DSL.
2//!
3//! Phase D introduces:
4//!
5//! * **Typed operands** — `equals: { lhs: { path: "$.x" }, rhs: { value: 42 } }`
6//!   removes the legacy `starts_with('$')` heuristic. The legacy form
7//!   (`lhs: "$.x"`) keeps working: a bare string starting with `$` is still
8//!   resolved as a path, anything else is a literal.
9//! * **Boolean combinators** — `all_of`, `any_of`, `not`.
10//! * **`for_each`** — runs child assertions for every node matched by a
11//!   wildcard JSONPath.
12//! * **`matches_schema`** — validates the value at a path against an inline
13//!   JSON Schema using `jsonschema 0.46`.
14//! * **Versioning** — `version: 1` and `version: 2` are accepted by the
15//!   same parser; v2 unlocks the new constructs above without changing how
16//!   v1 files parse.
17//!
18//! Phase G adds:
19//!
20//! * **`version: 3`** with a `metadata` block: `name`, `description`,
21//!   `authors`, `tags`, `parameters`, and `extends`.
22//! * **Mustache-style templating** — every `{{var}}` in the file is
23//!   resolved before YAML parsing using parameter defaults overridden by
24//!   the caller. References to undeclared parameters error.
25//! * **`extends`** — pack inheritance with cycle detection and a depth
26//!   cap; resolution lives in `crate::run::pack` because it requires a
27//!   loader closure.
28//!
29//! See `tests/fixtures/invariants/*.yaml` for working examples of each
30//! construct.
31
32use std::collections::BTreeMap;
33
34use regex::Regex;
35use serde::{Deserialize, Serialize};
36use serde_json::Value;
37use thiserror::Error;
38
39/// Highest invariant file version this build understands.
40pub const MAX_VERSION: u64 = 3;
41
42/// Maximum depth of a chain of `metadata.extends` references. The
43/// resolver returns an error past this depth so a malformed pack ring
44/// cannot lock the loader into an unbounded walk.
45pub const MAX_EXTENDS_DEPTH: usize = 4;
46
47#[derive(Debug, Error)]
48pub enum DslError {
49    /// YAML deserialization failed.
50    #[error("failed to parse invariants YAML: {0}")]
51    Parse(#[from] serde_yaml::Error),
52    /// `generate` and `fixed` were both set or both omitted on the same
53    /// invariant.
54    #[error("invariant `{0}` must define exactly one of `generate` or `fixed`")]
55    InvalidInputMode(String),
56    /// File declared a `version` greater than [`MAX_VERSION`].
57    #[error("invariants file declares unsupported version `{0}`; expected ≤ {MAX_VERSION}")]
58    UnsupportedVersion(u64),
59    /// A `{{var}}` reference targets a parameter that the file does not
60    /// declare and the caller did not override.
61    #[error("undefined template parameter(s): {0:?}")]
62    UndefinedParameters(Vec<String>),
63    /// The caller passed an override for a parameter the pack does not
64    /// declare. We reject these to surface typos rather than silently
65    /// ignoring them.
66    #[error("override key `{0}` is not declared in metadata.parameters")]
67    UnknownParameterOverride(String),
68    /// `for_each_tool[*].where.{name,description}_matches` regex failed
69    /// to compile.
70    #[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    /// Pack-style metadata (name, description, parameters, extends).
80    /// Optional: a v1/v2 invariants file omits the block entirely.
81    /// (Phase G — version 3 introduces this; older versions ignore it.)
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub metadata: Option<PackMetadata>,
84    pub invariants: Vec<Invariant>,
85    /// Phase I — schema-aware invariant templates. Each block is
86    /// expanded against the live `client.list_tools()` result at run
87    /// time, producing one concrete [`Invariant`] per matching tool.
88    /// Default empty.
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub for_each_tool: Vec<ForEachToolBlock>,
91}
92
93/// Phase I — `for_each_tool` directive: a tool-name-agnostic template
94/// expanded at run time against the server's tool list.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ForEachToolBlock {
97    /// Invariant name template. May contain `{{tool_name}}`, replaced
98    /// at expansion time with the matched tool's name.
99    pub name: String,
100    /// Filter that decides which tools the template applies to.
101    #[serde(rename = "where")]
102    pub matches: ToolMatch,
103    /// Body of the generated invariant, minus `name` (provided by the
104    /// block) and `tool` (auto-set to the matched tool's name).
105    pub apply: ApplyTemplate,
106}
107
108/// Filter for [`ForEachToolBlock`]. Every set field is an AND condition.
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct ToolMatch {
111    /// Match on `tool.annotations` fields. Each set field requires the
112    /// tool's annotation to equal the given value.
113    #[serde(default, skip_serializing_if = "ToolAnnotationMatch::is_empty")]
114    pub annotations: ToolAnnotationMatch,
115    /// Regex applied to `tool.name`. Tools whose name does not match
116    /// are skipped.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub name_matches: Option<String>,
119    /// Regex applied to `tool.description`. Tools without a description
120    /// or whose description does not match are skipped.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub description_matches: Option<String>,
123}
124
125/// Subset of MCP `ToolAnnotations` fields the filter understands.
126#[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    /// `true` when no annotation constraint is set; used by the serde
156    /// `skip_serializing_if`.
157    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/// Body of a [`ForEachToolBlock`]. Mirrors [`Invariant`] minus `name`
166/// and `tool` — those are supplied by the block at expansion time.
167#[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/// `metadata` block of a v3 invariants file. Acts as the pack header
182/// when the file is loaded as a rule pack.
183#[derive(Debug, Clone, Default, Serialize, Deserialize)]
184pub struct PackMetadata {
185    /// Canonical pack name. When the file is referenced via
186    /// `wallfacer property --pack <name>`, `<name>` should match this.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub name: Option<String>,
189    /// One-paragraph human-readable description.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub description: Option<String>,
192    /// Author identities (free-form strings, e.g. `"wallfacer-core"`,
193    /// `"alice@example.org"`).
194    #[serde(default, skip_serializing_if = "Vec::is_empty")]
195    pub authors: Vec<String>,
196    /// Tags for catalog grouping (e.g. `["security", "auth"]`).
197    #[serde(default, skip_serializing_if = "Vec::is_empty")]
198    pub tags: Vec<String>,
199    /// Declared parameters. Every `{{name}}` referenced in the file
200    /// must be declared here; the value of `default` is substituted
201    /// unless the caller passes an override via `parse_with_overrides`.
202    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
203    pub parameters: BTreeMap<String, Parameter>,
204    /// Names of other packs whose invariants are imported when this
205    /// pack is loaded. Cycles are rejected; depth is capped by
206    /// [`MAX_EXTENDS_DEPTH`]. Resolution lives in
207    /// `crate::run::pack::resolve_extends`.
208    #[serde(default, skip_serializing_if = "Vec::is_empty")]
209    pub extends: Vec<String>,
210}
211
212/// Declaration of a single template parameter inside `metadata.parameters`.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct Parameter {
215    /// One-line operator-facing description, surfaced by
216    /// `wallfacer pack params <name>`.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub description: Option<String>,
219    /// Logical type (currently informational; the substituted value is
220    /// always a string at the YAML source level).
221    #[serde(default = "default_param_kind", rename = "type")]
222    pub kind: ParamKind,
223    /// Default value used when no override is supplied. Always
224    /// stringified before substitution.
225    pub default: Value,
226}
227
228fn default_param_kind() -> ParamKind {
229    ParamKind::String
230}
231
232/// Logical type of a [`Parameter`]. Informational for now; the
233/// substituted value is always inserted as a string at the YAML source
234/// level.
235#[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    /// Phase H — inline test fixtures for `wallfacer pack test`. Each
257    /// fixture provides a synthetic response (and optionally an
258    /// overriding input) along with whether evaluating the invariant
259    /// against it should `pass` or `fail`. The runner compares the
260    /// observed outcome to `expect` and surfaces mismatches as test
261    /// failures, gating CI on pack quality without needing a live MCP
262    /// server.
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    pub test_fixtures: Vec<TestFixture>,
265}
266
267/// One scripted test case for an invariant: a synthetic response (and
268/// optional input override) plus the expected outcome. Phase H —
269/// consumed by `wallfacer pack test`.
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct TestFixture {
272    /// Free-form short label, surfaced in the `pack test` output table.
273    pub name: String,
274    /// Optional override for `$.input`. When omitted, the fixture
275    /// uses the invariant's `fixed` block (or an empty object if both
276    /// are absent).
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub input: Option<Value>,
279    /// Synthetic response value used as `$.response` during evaluation.
280    pub response: Value,
281    /// Outcome the runner expects when evaluating the invariant against
282    /// this fixture.
283    pub expect: FixtureExpect,
284}
285
286/// Outcome enum for [`TestFixture::expect`]. `Pass` means the invariant
287/// must succeed; `Fail` means the invariant must report an assertion
288/// failure (a structural error like a bad path is treated as a third
289/// category and surfaced as a test failure of its own).
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(rename_all = "lowercase")]
292pub enum FixtureExpect {
293    /// Invariant evaluation must return `Ok(())`.
294    Pass,
295    /// Invariant evaluation must return `Err(RunnerError::Assertion(...))`.
296    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/// An operand of a comparison (`equals`, `not_equals`, `at_most`, ...).
330///
331/// Three forms are accepted, selected by structure:
332///
333/// 1. `{ path: "$..." }` — explicit JSONPath, resolved against the
334///    `{input, response}` context.
335/// 2. `{ value: <any> }` — explicit literal.
336/// 3. Anything else — bare value. If it's a string starting with `$` we
337///    resolve it as a path (legacy v1 behaviour); otherwise it is treated
338///    as a literal.
339#[derive(Debug, Clone, Serialize, Deserialize)]
340#[serde(untagged)]
341pub enum Operand {
342    /// `{ path: "$..." }`
343    Path {
344        /// JSONPath expression (RFC 9535 syntax).
345        path: String,
346    },
347    /// `{ value: <any> }`
348    Literal {
349        /// Verbatim value.
350        value: Value,
351    },
352    /// Anything else: number, boolean, plain object, or string. Strings
353    /// starting with `$` are resolved as JSONPath at runtime to preserve
354    /// the v1 contract; everything else is treated as a literal.
355    Direct(Value),
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
359#[serde(tag = "kind", rename_all = "snake_case")]
360pub enum Assertion {
361    /// `lhs == rhs` after operand resolution.
362    Equals { lhs: Operand, rhs: Operand },
363    /// `lhs != rhs` after operand resolution.
364    NotEquals { lhs: Operand, rhs: Operand },
365    /// Numeric `path <= value`.
366    AtMost { path: String, value: Operand },
367    /// Numeric `path >= value`.
368    AtLeast { path: String, value: Operand },
369    /// `len(path) == value` (for arrays / strings).
370    LengthEq { path: String, value: Operand },
371    /// `len(path) <= value`.
372    LengthAtMost { path: String, value: Operand },
373    /// `len(path) >= value`.
374    LengthAtLeast { path: String, value: Operand },
375    /// Type check: the value at `path` has the expected JSON type.
376    IsType {
377        path: String,
378        #[serde(rename = "type")]
379        expected: JsonType,
380    },
381    /// String at `path` matches `pattern` (Rust regex).
382    MatchesRegex { path: String, pattern: String },
383    /// All child assertions must pass (D1).
384    AllOf {
385        #[serde(rename = "assert")]
386        assertions: Vec<Assertion>,
387    },
388    /// At least one child assertion must pass (D1).
389    AnyOf {
390        #[serde(rename = "assert")]
391        assertions: Vec<Assertion>,
392    },
393    /// The single child assertion must fail (D1).
394    Not {
395        /// The assertion that must NOT hold for this invariant to pass.
396        assertion: Box<Assertion>,
397    },
398    /// For every node matched by the wildcard JSONPath, every child
399    /// assertion must pass (D3). The current node is exposed as `$.item`
400    /// inside the `assert` block; the original input/response remain
401    /// accessible via `$.input` / `$.response`.
402    ForEach {
403        path: String,
404        #[serde(rename = "assert")]
405        assertions: Vec<Assertion>,
406    },
407    /// The value at `path` validates against the inline JSON Schema (D4).
408    MatchesSchema {
409        path: String,
410        /// Inline JSON Schema. Compiled with `jsonschema::validator_for`
411        /// at evaluation time.
412        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
428/// Parses an invariants YAML document with no template overrides. v3
429/// files use the `metadata.parameters` defaults verbatim; v1/v2 files
430/// pass through unchanged.
431pub fn parse(source: &str) -> Result<InvariantFile> {
432    parse_with_overrides(source, &BTreeMap::new())
433}
434
435/// Parses an invariants YAML document, applying `{{var}}` substitution
436/// before YAML parsing.
437///
438/// Resolution order for the substituted value:
439///
440/// 1. The caller's `overrides` map (typically built from `--param` CLI
441///    flags or `[packs.<name>]` config tables).
442/// 2. The `default` field of the matching entry under
443///    `metadata.parameters`.
444///
445/// Every `{{var}}` reference must resolve to a declared parameter — a
446/// missing declaration produces [`DslError::UndefinedParameters`]. An
447/// override targeting an undeclared parameter likewise produces
448/// [`DslError::UnknownParameterOverride`] so typos surface immediately.
449pub fn parse_with_overrides(
450    source: &str,
451    overrides: &BTreeMap<String, String>,
452) -> Result<InvariantFile> {
453    // First-pass: tolerant parse to extract `metadata.parameters` (we
454    // need them before substitution so we can check the override map).
455    let raw: serde_yaml::Value = serde_yaml::from_str(source)?;
456    let parameters = extract_parameters(&raw);
457
458    // Reject overrides that target undeclared parameters.
459    for key in overrides.keys() {
460        if !parameters.contains_key(key) {
461            return Err(DslError::UnknownParameterOverride(key.clone()));
462        }
463    }
464
465    // Build the substitution map: defaults first, overrides on top.
466    let mut subst: BTreeMap<String, String> = parameters
467        .iter()
468        .map(|(name, param)| (name.clone(), stringify_default(&param.default)))
469        .collect();
470    for (key, value) in overrides {
471        subst.insert(key.clone(), value.clone());
472    }
473
474    // Phase I — when the file declares `for_each_tool` blocks, we
475    // auto-inject `tool_name` as a no-op substitution: any
476    // `{{tool_name}}` in the source is replaced by itself, so the
477    // literal `{{tool_name}}` survives parse and is later rewritten by
478    // [`expand_for_each_tool`] for each matching tool. Users that
479    // declare `tool_name` themselves (e.g. for testing) keep their own
480    // value.
481    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    // Apply mustache substitution on the raw text. Any `{{var}}` that
488    // is not in `subst` is collected and reported as a single error.
489    let substituted = render_template(source, &subst)?;
490
491    // Final-pass: strict parse + structural validation.
492    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
504/// Phase I — synthesises a concrete [`Invariant`] from a
505/// [`ForEachToolBlock`] template using a placeholder tool name. Used
506/// by `wallfacer pack test` to evaluate `apply.test_fixtures` offline,
507/// without consulting a live MCP server. The `where` filter is
508/// bypassed; the placeholder fills in `{{tool_name}}` everywhere.
509pub 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
530/// Phase I — expands every [`ForEachToolBlock`] against the supplied
531/// tool list, returning one concrete [`Invariant`] per matching tool.
532///
533/// Each generated invariant has its `tool` field set to the matched
534/// tool's name; `{{tool_name}}` in the block's `name` template (or any
535/// string inside the apply body) is rewritten to that name.
536///
537/// Errors propagate from the regex pre-compile step or from the
538/// internal serde round-trip used to substitute `{{tool_name}}` deep
539/// in the apply tree.
540pub 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            // Substitute {{tool_name}} in the apply tree by serializing
570            // the template, doing a string replace, and re-parsing. This
571            // keeps the substitution shallow but catches references in
572            // any nested string (assertion paths, fixtures, regex
573            // patterns, ...).
574            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    /// Returns `true` when the tool satisfies every set field on the
599    /// filter. Pre-compiled regexes are passed by the caller to avoid
600    /// recompiling per tool.
601    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
653/// Returns `true` when the parsed YAML carries a non-empty
654/// `for_each_tool` array at the document root (Phase I).
655fn 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
664/// Walks a parsed YAML value and pulls `metadata.parameters` out as a
665/// strict typed map. Returns an empty map if the path is missing or
666/// malformed; the caller's strict pass will flag genuinely broken docs.
667fn 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(&parameters_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        // Arrays and objects fall back to canonical JSON; users can use
686        // them in templates but should typically pick scalar parameters.
687        other => other.to_string(),
688    }
689}
690
691/// Substitutes every `{{name}}` (with optional surrounding whitespace)
692/// in `template` using `vars`. Identifier syntax matches Rust's loose
693/// snake-case identifiers: `[A-Za-z_][A-Za-z0-9_]*`.
694#[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    // Compile once per call; the regex is small and patterns this short
701    // are fast enough that caching across calls is overkill.
702    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: &regex::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                // Heuristic form: a bare string starting with `$` deserialises
750                // into Operand::Direct, and the runner resolves it as a path.
751                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        // Walking down the tree confirms the structure round-tripped.
813        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    // ---------- Phase G — v3 metadata + templating ----------
897
898    #[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        // No `metadata`, no `{{...}}`. Phase G must not break this.
1031        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    // ---------- Phase I — for_each_tool ----------
1078
1079    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        // The literal `{{tool_name}}` survives parsing because we
1116        // auto-inject it as a no-op substitution.
1117        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}