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    /// Phase L — multi-step sequences. Each [`Sequence`] runs a chain of
92    /// tool calls against a single shared client, with later steps able
93    /// to reference earlier steps' inputs and responses via
94    /// `{{steps.<bind>.<jsonpath>}}` substitutions. Default empty.
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub sequences: Vec<Sequence>,
97}
98
99/// Phase L — multi-step invariant. The runner executes [`Self::steps`]
100/// in order on a single `Client`, threading each step's
101/// `{input, response}` envelope through a shared context that later
102/// steps can reference via `{{steps.<bind>.<jsonpath>}}` placeholders
103/// inside their `with:` arguments and assertion paths.
104///
105/// A sequence fails as soon as any step's `expect`/`assert` is
106/// violated, producing a [`crate::finding::FindingKind::SequenceFailure`]
107/// finding tagged with the offending step index.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct Sequence {
110    /// Canonical sequence name. Used as the finding identifier and as
111    /// the corpus folder name; sanitised by the corpus writer like any
112    /// tool name.
113    pub name: String,
114    /// Optional human-readable description; surfaced by `wallfacer pack
115    /// show` and the human reporter.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub description: Option<String>,
118    /// Ordered list of steps. Must be non-empty.
119    pub steps: Vec<SequenceStep>,
120    /// Synthetic step-by-step fixtures for `wallfacer pack test`. Each
121    /// fixture supplies one canned response per step plus the expected
122    /// final outcome. Default empty.
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub test_fixtures: Vec<SequenceFixture>,
125}
126
127/// One step inside a [`Sequence`]. Steps share a `Client` — no
128/// reconnect happens between adjacent steps unless the runner
129/// explicitly calls one — so authentication tokens, session ids, and
130/// any other per-connection state survives across the chain.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct SequenceStep {
133    /// Tool name to invoke. Like single-tool invariants, the runner
134    /// skips the whole sequence (and the rest of its steps) if the tool
135    /// isn't advertised by the server.
136    pub call: String,
137    /// Input arguments. May contain `{{steps.<name>.<jsonpath>}}`
138    /// placeholders resolved at step-execution time against the
139    /// already-bound steps' `{input, response}` envelopes.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub with: Option<BTreeMap<String, Value>>,
142    /// Optional binding name. When set, the step's
143    /// `{input, response}` envelope is stored under this key in the
144    /// shared context. The default of `None` means later steps can't
145    /// reference this step.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub bind: Option<String>,
148    /// Expected outcome class. Defaults to [`StepOutcome::Ok`].
149    /// `Error` lets the sequence assert the step *should* fail (e.g.
150    /// post-logout, the protected resource must reject the call).
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub expect: Option<StepOutcome>,
153    /// Per-step assertions. Evaluated against the same
154    /// `{input, response}` context the [`Invariant`] runner uses, so
155    /// the existing assertion vocabulary applies unchanged.
156    #[serde(default, rename = "assert", skip_serializing_if = "Vec::is_empty")]
157    pub assertions: Vec<Assertion>,
158}
159
160/// Coarse outcome class for a [`SequenceStep`]. Distinguishes the
161/// happy-path "tool returned cleanly" from the negative-test
162/// "tool was *expected* to error" (e.g. post-logout request).
163#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum StepOutcome {
166    /// Step must produce a non-error response (`isError` not `true`)
167    /// AND the call must not crash / hang. Default.
168    #[default]
169    Ok,
170    /// Step must return `isError: true` OR the response envelope must
171    /// indicate a clean failure. Useful for asserting that the server
172    /// rejects an action — e.g. reading a deleted record.
173    Error,
174}
175
176/// Synthetic fixture for `wallfacer pack test`. The fixture supplies
177/// one canned response per step (in the same order as
178/// [`Sequence::steps`]) plus the expected final outcome of the entire
179/// sequence. Step assertions are evaluated against the canned
180/// responses so the pack author can gate CI without a live MCP target.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct SequenceFixture {
183    /// Fixture name (free-form, used in test reports).
184    pub name: String,
185    /// One synthetic response per [`Sequence::steps`] entry, in the
186    /// same order. Length mismatches surface as a
187    /// [`crate::property::runner::FixtureOutcome::Structural`]
188    /// failure.
189    pub responses: Vec<Value>,
190    /// Whether the sequence as a whole should pass or fail when run
191    /// against `responses`.
192    pub expect: FixtureExpect,
193}
194
195/// Phase I — `for_each_tool` directive: a tool-name-agnostic template
196/// expanded at run time against the server's tool list.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct ForEachToolBlock {
199    /// Invariant name template. May contain `{{tool_name}}`, replaced
200    /// at expansion time with the matched tool's name.
201    pub name: String,
202    /// Filter that decides which tools the template applies to. When
203    /// omitted, the template applies to *every* tool the server
204    /// declares — useful for envelope-shape invariants that don't care
205    /// about specific annotations.
206    #[serde(rename = "where", default)]
207    pub matches: ToolMatch,
208    /// Body of the generated invariant, minus `name` (provided by the
209    /// block) and `tool` (auto-set to the matched tool's name).
210    pub apply: ApplyTemplate,
211}
212
213/// Filter for [`ForEachToolBlock`]. Every set field is an AND condition.
214#[derive(Debug, Clone, Default, Serialize, Deserialize)]
215pub struct ToolMatch {
216    /// Match on `tool.annotations` fields. Each set field requires the
217    /// tool's annotation to equal the given value.
218    #[serde(default, skip_serializing_if = "ToolAnnotationMatch::is_empty")]
219    pub annotations: ToolAnnotationMatch,
220    /// Regex applied to `tool.name`. Tools whose name does not match
221    /// are skipped.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub name_matches: Option<String>,
224    /// Regex applied to `tool.description`. Tools without a description
225    /// or whose description does not match are skipped.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub description_matches: Option<String>,
228}
229
230/// Subset of MCP `ToolAnnotations` fields the filter understands.
231#[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    /// `true` when no annotation constraint is set; used by the serde
261    /// `skip_serializing_if`.
262    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/// Body of a [`ForEachToolBlock`]. Mirrors [`Invariant`] minus `name`
271/// and `tool` — those are supplied by the block at expansion time.
272#[derive(Debug, Clone, Default, Serialize, Deserialize)]
273pub struct ApplyTemplate {
274    /// Optional input strategy. When set, overrides `fixed` and
275    /// `generate`. See [`InputMode`].
276    #[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/// Input strategy for a `for_each_tool` invariant. Resolves at run
291/// time against the live tool list (the runner has access to each
292/// tool's input schema and annotations).
293///
294/// The default behaviour (when `input` is omitted) is unchanged from
295/// v0.3.0–v0.3.2: the runner uses `fixed:` if set, otherwise
296/// `generate:`, otherwise an empty `{}` payload.
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
298#[serde(rename_all = "snake_case")]
299pub enum InputMode {
300    /// Generate a payload matching the live tool's input schema with
301    /// [`crate::mutate::schema_gen::GenMode::Conform`]. Use when an
302    /// invariant tests the *valid-input* contract of a tool: the
303    /// pre-v0.3.3 default of `fixed: {}` would trigger schema-validation
304    /// errors on tools with required parameters and mask real bugs.
305    SchemaValid,
306}
307
308/// `metadata` block of a v3 invariants file. Acts as the pack header
309/// when the file is loaded as a rule pack.
310#[derive(Debug, Clone, Default, Serialize, Deserialize)]
311pub struct PackMetadata {
312    /// Canonical pack name. When the file is referenced via
313    /// `wallfacer property --pack <name>`, `<name>` should match this.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub name: Option<String>,
316    /// One-paragraph human-readable description.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub description: Option<String>,
319    /// Author identities (free-form strings, e.g. `"wallfacer-core"`,
320    /// `"alice@example.org"`).
321    #[serde(default, skip_serializing_if = "Vec::is_empty")]
322    pub authors: Vec<String>,
323    /// Tags for catalog grouping (e.g. `["security", "auth"]`).
324    #[serde(default, skip_serializing_if = "Vec::is_empty")]
325    pub tags: Vec<String>,
326    /// Declared parameters. Every `{{name}}` referenced in the file
327    /// must be declared here; the value of `default` is substituted
328    /// unless the caller passes an override via `parse_with_overrides`.
329    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
330    pub parameters: BTreeMap<String, Parameter>,
331    /// Names of other packs whose invariants are imported when this
332    /// pack is loaded. Cycles are rejected; depth is capped by
333    /// [`MAX_EXTENDS_DEPTH`]. Resolution lives in
334    /// `crate::run::pack::resolve_extends`.
335    #[serde(default, skip_serializing_if = "Vec::is_empty")]
336    pub extends: Vec<String>,
337}
338
339/// Declaration of a single template parameter inside `metadata.parameters`.
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct Parameter {
342    /// One-line operator-facing description, surfaced by
343    /// `wallfacer pack params <name>`.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub description: Option<String>,
346    /// Logical type (currently informational; the substituted value is
347    /// always a string at the YAML source level).
348    #[serde(default = "default_param_kind", rename = "type")]
349    pub kind: ParamKind,
350    /// Default value used when no override is supplied. Always
351    /// stringified before substitution.
352    pub default: Value,
353}
354
355fn default_param_kind() -> ParamKind {
356    ParamKind::String
357}
358
359/// Logical type of a [`Parameter`]. Informational for now; the
360/// substituted value is always inserted as a string at the YAML source
361/// level.
362#[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    /// Input strategy. See [`InputMode`]. When `Some`, the runner
376    /// derives the per-case input from the live tool's metadata
377    /// instead of from `fixed`/`generate`.
378    #[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    /// Phase H — inline test fixtures for `wallfacer pack test`. Each
389    /// fixture provides a synthetic response (and optionally an
390    /// overriding input) along with whether evaluating the invariant
391    /// against it should `pass` or `fail`. The runner compares the
392    /// observed outcome to `expect` and surfaces mismatches as test
393    /// failures, gating CI on pack quality without needing a live MCP
394    /// server.
395    #[serde(default, skip_serializing_if = "Vec::is_empty")]
396    pub test_fixtures: Vec<TestFixture>,
397}
398
399/// One scripted test case for an invariant: a synthetic response (and
400/// optional input override) plus the expected outcome. Phase H —
401/// consumed by `wallfacer pack test`.
402#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct TestFixture {
404    /// Free-form short label, surfaced in the `pack test` output table.
405    pub name: String,
406    /// Optional override for `$.input`. When omitted, the fixture
407    /// uses the invariant's `fixed` block (or an empty object if both
408    /// are absent).
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub input: Option<Value>,
411    /// Synthetic response value used as `$.response` during evaluation.
412    pub response: Value,
413    /// Outcome the runner expects when evaluating the invariant against
414    /// this fixture.
415    pub expect: FixtureExpect,
416}
417
418/// Outcome enum for [`TestFixture::expect`]. `Pass` means the invariant
419/// must succeed; `Fail` means the invariant must report an assertion
420/// failure (a structural error like a bad path is treated as a third
421/// category and surfaced as a test failure of its own).
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
423#[serde(rename_all = "lowercase")]
424pub enum FixtureExpect {
425    /// Invariant evaluation must return `Ok(())`.
426    Pass,
427    /// Invariant evaluation must return `Err(RunnerError::Assertion(...))`.
428    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/// An operand of a comparison (`equals`, `not_equals`, `at_most`, ...).
462///
463/// Three forms are accepted, selected by structure:
464///
465/// 1. `{ path: "$..." }` — explicit JSONPath, resolved against the
466///    `{input, response}` context.
467/// 2. `{ value: <any> }` — explicit literal.
468/// 3. Anything else — bare value. If it's a string starting with `$` we
469///    resolve it as a path (legacy v1 behaviour); otherwise it is treated
470///    as a literal.
471#[derive(Debug, Clone, Serialize, Deserialize)]
472#[serde(untagged)]
473pub enum Operand {
474    /// `{ path: "$..." }`
475    Path {
476        /// JSONPath expression (RFC 9535 syntax).
477        path: String,
478    },
479    /// `{ value: <any> }`
480    Literal {
481        /// Verbatim value.
482        value: Value,
483    },
484    /// Anything else: number, boolean, plain object, or string. Strings
485    /// starting with `$` are resolved as JSONPath at runtime to preserve
486    /// the v1 contract; everything else is treated as a literal.
487    Direct(Value),
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
491#[serde(tag = "kind", rename_all = "snake_case")]
492pub enum Assertion {
493    /// `lhs == rhs` after operand resolution.
494    Equals { lhs: Operand, rhs: Operand },
495    /// `lhs != rhs` after operand resolution.
496    NotEquals { lhs: Operand, rhs: Operand },
497    /// Numeric `path <= value`.
498    AtMost { path: String, value: Operand },
499    /// Numeric `path >= value`.
500    AtLeast { path: String, value: Operand },
501    /// `len(path) == value` (for arrays / strings).
502    LengthEq { path: String, value: Operand },
503    /// `len(path) <= value`.
504    LengthAtMost { path: String, value: Operand },
505    /// `len(path) >= value`.
506    LengthAtLeast { path: String, value: Operand },
507    /// Type check: the value at `path` has the expected JSON type.
508    IsType {
509        path: String,
510        #[serde(rename = "type")]
511        expected: JsonType,
512    },
513    /// String at `path` matches `pattern` (Rust regex).
514    MatchesRegex { path: String, pattern: String },
515    /// All child assertions must pass (D1).
516    AllOf {
517        #[serde(rename = "assert")]
518        assertions: Vec<Assertion>,
519    },
520    /// At least one child assertion must pass (D1).
521    AnyOf {
522        #[serde(rename = "assert")]
523        assertions: Vec<Assertion>,
524    },
525    /// The single child assertion must fail (D1).
526    Not {
527        /// The assertion that must NOT hold for this invariant to pass.
528        assertion: Box<Assertion>,
529    },
530    /// For every node matched by the wildcard JSONPath, every child
531    /// assertion must pass (D3). The current node is exposed as `$.item`
532    /// inside the `assert` block; the original input/response remain
533    /// accessible via `$.input` / `$.response`.
534    ForEach {
535        path: String,
536        #[serde(rename = "assert")]
537        assertions: Vec<Assertion>,
538    },
539    /// The value at `path` validates against the inline JSON Schema (D4).
540    MatchesSchema {
541        path: String,
542        /// Inline JSON Schema. Compiled with `jsonschema::validator_for`
543        /// at evaluation time.
544        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
560/// Parses an invariants YAML document with no template overrides. v3
561/// files use the `metadata.parameters` defaults verbatim; v1/v2 files
562/// pass through unchanged.
563pub fn parse(source: &str) -> Result<InvariantFile> {
564    parse_with_overrides(source, &BTreeMap::new())
565}
566
567/// Parses an invariants YAML document, applying `{{var}}` substitution
568/// before YAML parsing.
569///
570/// Resolution order for the substituted value:
571///
572/// 1. The caller's `overrides` map (typically built from `--param` CLI
573///    flags or `[packs.<name>]` config tables).
574/// 2. The `default` field of the matching entry under
575///    `metadata.parameters`.
576///
577/// Every `{{var}}` reference must resolve to a declared parameter — a
578/// missing declaration produces [`DslError::UndefinedParameters`]. An
579/// override targeting an undeclared parameter likewise produces
580/// [`DslError::UnknownParameterOverride`] so typos surface immediately.
581pub fn parse_with_overrides(
582    source: &str,
583    overrides: &BTreeMap<String, String>,
584) -> Result<InvariantFile> {
585    // First-pass: tolerant parse to extract `metadata.parameters` (we
586    // need them before substitution so we can check the override map).
587    let raw: serde_yaml::Value = serde_yaml::from_str(source)?;
588    let parameters = extract_parameters(&raw);
589
590    // Reject overrides that target undeclared parameters.
591    for key in overrides.keys() {
592        if !parameters.contains_key(key) {
593            return Err(DslError::UnknownParameterOverride(key.clone()));
594        }
595    }
596
597    // Build the substitution map: defaults first, overrides on top.
598    let mut subst: BTreeMap<String, String> = parameters
599        .iter()
600        .map(|(name, param)| (name.clone(), stringify_default(&param.default)))
601        .collect();
602    for (key, value) in overrides {
603        subst.insert(key.clone(), value.clone());
604    }
605
606    // Phase I — when the file declares `for_each_tool` blocks, we
607    // auto-inject `tool_name` as a no-op substitution: any
608    // `{{tool_name}}` in the source is replaced by itself, so the
609    // literal `{{tool_name}}` survives parse and is later rewritten by
610    // [`expand_for_each_tool`] for each matching tool. Users that
611    // declare `tool_name` themselves (e.g. for testing) keep their own
612    // value.
613    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    // Apply mustache substitution on the raw text. Any `{{var}}` that
620    // is not in `subst` is collected and reported as a single error.
621    let substituted = render_template(source, &subst)?;
622
623    // Final-pass: strict parse + structural validation.
624    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
636/// Phase I — synthesises a concrete [`Invariant`] from a
637/// [`ForEachToolBlock`] template using a placeholder tool name. Used
638/// by `wallfacer pack test` to evaluate `apply.test_fixtures` offline,
639/// without consulting a live MCP server. The `where` filter is
640/// bypassed; the placeholder fills in `{{tool_name}}` everywhere.
641pub 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
663/// Phase I — expands every [`ForEachToolBlock`] against the supplied
664/// tool list, returning one concrete [`Invariant`] per matching tool.
665///
666/// Each generated invariant has its `tool` field set to the matched
667/// tool's name; `{{tool_name}}` in the block's `name` template (or any
668/// string inside the apply body) is rewritten to that name.
669///
670/// Errors propagate from the regex pre-compile step or from the
671/// internal serde round-trip used to substitute `{{tool_name}}` deep
672/// in the apply tree.
673pub 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            // Substitute {{tool_name}} in the apply tree by serializing
703            // the template, doing a string replace, and re-parsing. This
704            // keeps the substitution shallow but catches references in
705            // any nested string (assertion paths, fixtures, regex
706            // patterns, ...).
707            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    /// Returns `true` when the tool satisfies every set field on the
733    /// filter. Pre-compiled regexes are passed by the caller to avoid
734    /// recompiling per tool.
735    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
787/// Returns `true` when the parsed YAML carries a non-empty
788/// `for_each_tool` array at the document root (Phase I).
789fn 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
798/// Walks a parsed YAML value and pulls `metadata.parameters` out as a
799/// strict typed map. Returns an empty map if the path is missing or
800/// malformed; the caller's strict pass will flag genuinely broken docs.
801fn 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(&parameters_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        // Arrays and objects fall back to canonical JSON; users can use
820        // them in templates but should typically pick scalar parameters.
821        other => other.to_string(),
822    }
823}
824
825/// Substitutes every `{{name}}` (with optional surrounding whitespace)
826/// in `template` using `vars`. Identifier syntax matches Rust's loose
827/// snake-case identifiers: `[A-Za-z_][A-Za-z0-9_]*`.
828#[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    // Compile once per call; the regex is small and patterns this short
835    // are fast enough that caching across calls is overkill.
836    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: &regex::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                // Heuristic form: a bare string starting with `$` deserialises
884                // into Operand::Direct, and the runner resolves it as a path.
885                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        // Walking down the tree confirms the structure round-tripped.
947        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    // ---------- Phase G — v3 metadata + templating ----------
1031
1032    #[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        // No `metadata`, no `{{...}}`. Phase G must not break this.
1165        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    // ---------- Phase I — for_each_tool ----------
1212
1213    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        // `fixed` and `generate` stay None when only `input:` is set.
1254        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        // The literal `{{tool_name}}` survives parsing because we
1281        // auto-inject it as a no-op substitution.
1282        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}