Skip to main content

skilltest_core/
mock.rs

1//! Tool mocking and spying: the `mocks:` block of a test case (and the CLI's
2//! `--mocks` file), compiled down to the oneharness mock ruleset, plus the
3//! [`MockCall`] records that come back — one per tool call the harness's hook
4//! observed, carrying the *original* pre-rewrite input and the verdict applied.
5//!
6//! Vocabulary (sinon's): a **spy** observes without intercepting (a declaration
7//! with no action), a **stub** substitutes a canned shell result, **deny**
8//! blocks with a model-visible message, and **rewrite** substitutes raw input
9//! fields (the primitive under `stub`, and the way to mock file reads). First
10//! matching action wins; a call no action-rule matches is allowed through and
11//! still recorded.
12//!
13//! Two matching engines exist on purpose:
14//!
15//! * Action rules are matched *inside the harness's hook process* by
16//!   `oneharness mock` — [`compile_rules`] renders that ruleset. skilltest
17//!   mirrors the same semantics in [`decide`] so the bundled fake provider (and
18//!   the gate) exercise the identical decision logic without a harness; the
19//!   live per-harness e2e is the drift alarm between the two.
20//! * Spy declarations and eval `where` clauses are matched *locally* over the
21//!   returned records ([`decl_matches`] / [`where_matches`]).
22//!
23//! Everything here is validated loudly at load time — an invalid regex, an
24//! empty needle, or a criterion-less matcher must abort the run, never degrade
25//! to match-nothing (or match-everything).
26
27use std::collections::BTreeMap;
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31use serde_json::{json, Value};
32
33use crate::error::{Error, Result};
34
35/// A predicate on one tool-input field. Written in YAML either as a bare
36/// string (exact equality) or as a map with any of `equals` / `contains` /
37/// `pattern` (all given forms must hold).
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(untagged)]
40pub enum FieldPredicate {
41    /// Bare-string shorthand: the field must equal this exactly.
42    Equals(String),
43    /// The explicit form; every given key must hold.
44    Spec {
45        #[serde(default, skip_serializing_if = "Option::is_none")]
46        equals: Option<String>,
47        #[serde(default, skip_serializing_if = "Option::is_none")]
48        contains: Option<String>,
49        /// Unanchored regex (linear-time; same engine oneharness uses).
50        #[serde(default, skip_serializing_if = "Option::is_none")]
51        pattern: Option<String>,
52    },
53}
54
55impl FieldPredicate {
56    /// Validate the predicate for field `key` of declaration `who`.
57    fn validate(&self, who: &str, key: &str) -> Result<()> {
58        match self {
59            FieldPredicate::Equals(_) => Ok(()),
60            FieldPredicate::Spec {
61                equals,
62                contains,
63                pattern,
64            } => {
65                if equals.is_none() && contains.is_none() && pattern.is_none() {
66                    return Err(Error::Invalid(format!(
67                        "{who}: `input.{key}` needs one of `equals`/`contains`/`pattern`"
68                    )));
69                }
70                if contains.as_deref() == Some("") || pattern.as_deref() == Some("") {
71                    return Err(Error::Invalid(format!(
72                        "{who}: empty `contains`/`pattern` in `input.{key}` would match everything"
73                    )));
74                }
75                if let Some(pattern) = pattern {
76                    compile_pattern(pattern, &format!("{who}: `input.{key}.pattern`"))?;
77                }
78                Ok(())
79            }
80        }
81    }
82
83    /// Whether every given form holds for the field's string value.
84    fn matches(&self, value: &str) -> bool {
85        match self {
86            FieldPredicate::Equals(want) => value == want,
87            FieldPredicate::Spec {
88                equals,
89                contains,
90                pattern,
91            } => {
92                if let Some(want) = equals {
93                    if value != want {
94                        return false;
95                    }
96                }
97                if let Some(needle) = contains {
98                    if needle.is_empty() || !value.contains(needle.as_str()) {
99                        return false;
100                    }
101                }
102                if let Some(pattern) = pattern {
103                    // Validated at load; a compile failure here is a no-match,
104                    // never an accidental match-everything.
105                    match regex::Regex::new(pattern) {
106                        Ok(re) if re.is_match(value) => {}
107                        _ => return false,
108                    }
109                }
110                true
111            }
112        }
113    }
114
115    /// The oneharness `StringPredicate` JSON this compiles to.
116    fn to_oneharness(&self) -> Value {
117        match self {
118            FieldPredicate::Equals(want) => json!({ "equals": want }),
119            FieldPredicate::Spec {
120                equals,
121                contains,
122                pattern,
123            } => {
124                let mut spec = serde_json::Map::new();
125                if let Some(v) = equals {
126                    spec.insert("equals".into(), json!(v));
127                }
128                if let Some(v) = contains {
129                    spec.insert("contains".into(), json!(v));
130                }
131                if let Some(v) = pattern {
132                    spec.insert("regex".into(), json!(v));
133                }
134                Value::Object(spec)
135            }
136        }
137    }
138}
139
140/// What a mock/spy declaration matches on. At least one criterion is required;
141/// every given criterion must hold (AND).
142#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(deny_unknown_fields)]
144pub struct MockMatch {
145    /// Case-insensitive exact match on the tool name. Tool names are
146    /// per-harness (`Bash` on claude-code, `bash` on opencode/crush), so
147    /// cross-harness matchers usually prefer `contains`/`pattern`/`input`.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub tool: Option<String>,
150    /// Substring match over the raw hook event JSON (the tool name and its
151    /// input always serialize into it). Note the haystack is JSON: quotes and
152    /// backslashes inside tool input appear escaped.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub contains: Option<String>,
155    /// Unanchored regex over the same haystack as `contains`, for non-exact
156    /// needles like `git push( --force)?`. Linear-time engine (no lookarounds).
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub pattern: Option<String>,
159    /// Per-field predicates on the tool's input: the key is the argument name
160    /// (`command`, `file_path`, …), and every listed field must match (a field
161    /// absent from the call fails the matcher). Non-string fields are compared
162    /// against their compact JSON form.
163    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
164    pub input: BTreeMap<String, FieldPredicate>,
165}
166
167impl MockMatch {
168    /// Validate the matcher for declaration `who`. Loud on any fault.
169    fn validate(&self, who: &str) -> Result<()> {
170        for (field, value) in [
171            ("tool", &self.tool),
172            ("contains", &self.contains),
173            ("pattern", &self.pattern),
174        ] {
175            if value.as_deref() == Some("") {
176                return Err(Error::Invalid(format!(
177                    "{who}: empty `match.{field}` would match everything"
178                )));
179            }
180        }
181        if let Some(pattern) = &self.pattern {
182            compile_pattern(pattern, &format!("{who}: `match.pattern`"))?;
183        }
184        for (key, pred) in &self.input {
185            pred.validate(who, key)?;
186        }
187        if self.tool.is_none()
188            && self.contains.is_none()
189            && self.pattern.is_none()
190            && self.input.is_empty()
191        {
192            return Err(Error::Invalid(format!(
193                "{who}: `match` needs at least one of `tool`, `contains`, `pattern`, or `input`"
194            )));
195        }
196        Ok(())
197    }
198
199    /// Whether this matcher covers a call with `tool` name and `input` args.
200    /// The `contains`/`pattern` haystack is the synthesized compact event JSON
201    /// (`{"tool_name":…,"tool_input":…}`), mirroring what the hook-side rules
202    /// match against.
203    #[must_use]
204    pub fn matches_call(&self, tool: Option<&str>, input: Option<&Value>) -> bool {
205        if let Some(want) = &self.tool {
206            match tool {
207                Some(name) if name.eq_ignore_ascii_case(want) => {}
208                _ => return false,
209            }
210        }
211        if self.contains.is_some() || self.pattern.is_some() {
212            let event = event_haystack(tool, input);
213            if let Some(needle) = &self.contains {
214                if needle.is_empty() || !event.contains(needle.as_str()) {
215                    return false;
216                }
217            }
218            if let Some(pattern) = &self.pattern {
219                match regex::Regex::new(pattern) {
220                    Ok(re) if re.is_match(&event) => {}
221                    _ => return false,
222                }
223            }
224        }
225        where_matches(&self.input, input)
226    }
227
228    /// The oneharness `MatchSpec` JSON this compiles to.
229    fn to_oneharness(&self) -> Value {
230        let mut spec = serde_json::Map::new();
231        if let Some(tool) = &self.tool {
232            spec.insert("tool".into(), json!(tool));
233        }
234        if let Some(contains) = &self.contains {
235            spec.insert("event_contains".into(), json!(contains));
236        }
237        if let Some(pattern) = &self.pattern {
238            spec.insert("event_regex".into(), json!(pattern));
239        }
240        if !self.input.is_empty() {
241            let input: serde_json::Map<String, Value> = self
242                .input
243                .iter()
244                .map(|(k, p)| (k.clone(), p.to_oneharness()))
245                .collect();
246            spec.insert("input".into(), Value::Object(input));
247        }
248        Value::Object(spec)
249    }
250}
251
252/// A `stub` action: fake a shell call's result by declaring only the output.
253/// Written as a bare string (the output) or a map with `output` + `exit_code`.
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(untagged)]
256pub enum StubSpec {
257    /// Bare-string shorthand: the canned stdout, exit code 0.
258    Text(String),
259    Full {
260        output: String,
261        /// Non-zero fakes a failing command. Default 0.
262        #[serde(default)]
263        exit_code: i32,
264    },
265}
266
267impl StubSpec {
268    /// The canned output text.
269    #[must_use]
270    pub fn output(&self) -> &str {
271        match self {
272            StubSpec::Text(output) | StubSpec::Full { output, .. } => output,
273        }
274    }
275
276    /// The stub's exit code (0 unless faked as failing).
277    #[must_use]
278    pub fn exit_code(&self) -> i32 {
279        match self {
280            StubSpec::Text(_) => 0,
281            StubSpec::Full { exit_code, .. } => *exit_code,
282        }
283    }
284}
285
286/// A `deny` action: block the call with a model-visible message. Written as a
287/// bare string (the message) or a map with `message`.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(untagged)]
290pub enum DenySpec {
291    Text(String),
292    Full { message: String },
293}
294
295impl DenySpec {
296    /// The message the model reads as the tool's feedback.
297    #[must_use]
298    pub fn message(&self) -> &str {
299        match self {
300            DenySpec::Text(message) | DenySpec::Full { message } => message,
301        }
302    }
303}
304
305/// One mock or spy declaration — an entry of a test case's `mocks:` block, of
306/// the CLI's `--mocks` file, or synthesized by an SDK. Exactly one of `stub` /
307/// `deny` / `rewrite` makes it a **mock** (the call is intercepted); none makes
308/// it a **spy** (observed only, matched locally against the returned records).
309#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
310#[serde(deny_unknown_fields)]
311pub struct MockDecl {
312    /// Name evals (`type: called` / `not_called`) reference this declaration
313    /// by. Optional; unnamed declarations get a positional fallback
314    /// (`mock_<i>` / `spy_<i>`) used in reports and error messages.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub name: Option<String>,
317    #[serde(rename = "match")]
318    pub matcher: MockMatch,
319    /// Fake a shell call's result: the real command never runs and the model
320    /// receives this output as the tool's genuine result.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub stub: Option<StubSpec>,
323    /// Block the call; the model reads the message as the tool's feedback.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub deny: Option<DenySpec>,
326    /// Substitute raw input fields (a JSON object) — the low-level escape
327    /// hatch, and the way to mock file reads (rewrite `file_path` to a
328    /// fixture).
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub rewrite: Option<Value>,
331}
332
333impl MockDecl {
334    /// Whether this declaration intercepts (has an action) or only observes.
335    #[must_use]
336    pub fn is_mock(&self) -> bool {
337        self.stub.is_some() || self.deny.is_some() || self.rewrite.is_some()
338    }
339
340    /// Validate the declaration, identified as `who` in errors.
341    ///
342    /// # Errors
343    /// [`Error::Invalid`] on an empty name, an invalid matcher, more than one
344    /// action, or a non-object `rewrite`.
345    pub fn validate(&self, who: &str) -> Result<()> {
346        if self.name.as_deref() == Some("") {
347            return Err(Error::Invalid(format!("{who}: `name` must not be empty")));
348        }
349        self.matcher.validate(who)?;
350        let actions = usize::from(self.stub.is_some())
351            + usize::from(self.deny.is_some())
352            + usize::from(self.rewrite.is_some());
353        if actions > 1 {
354            return Err(Error::Invalid(format!(
355                "{who}: declare at most one of `stub`/`deny`/`rewrite` (omit all three for a spy)"
356            )));
357        }
358        if let Some(rewrite) = &self.rewrite {
359            if !rewrite.is_object() {
360                return Err(Error::Invalid(format!(
361                    "{who}: `rewrite` must be a JSON object (the substituted tool arguments)"
362                )));
363            }
364        }
365        if let Some(StubSpec::Text(text)) = &self.stub {
366            if text.is_empty() {
367                return Err(Error::Invalid(format!(
368                    "{who}: `stub` output must not be empty (use `deny` to block a call)"
369                )));
370            }
371        }
372        Ok(())
373    }
374
375    /// The oneharness rule action this compiles to; `None` for a spy.
376    fn action_json(&self) -> Option<Value> {
377        if let Some(stub) = &self.stub {
378            return Some(json!({
379                "stub": { "output": stub.output(), "exit_code": stub.exit_code() }
380            }));
381        }
382        if let Some(deny) = &self.deny {
383            return Some(json!({ "deny": { "message": deny.message() } }));
384        }
385        self.rewrite
386            .as_ref()
387            .map(|input| json!({ "rewrite": { "input": input } }))
388    }
389
390    /// The action's stable token (`stub`/`deny`/`rewrite`), or `None` for a spy.
391    #[must_use]
392    pub fn action_kind(&self) -> Option<&'static str> {
393        if self.stub.is_some() {
394            Some("stub")
395        } else if self.deny.is_some() {
396            Some("deny")
397        } else if self.rewrite.is_some() {
398            Some("rewrite")
399        } else {
400            None
401        }
402    }
403}
404
405/// One observed tool call, as recorded by the mock/spy channel: the harness
406/// hook's spy log for real runs, or the provider's `mock_calls` response for
407/// the command protocol. Carries the **original, pre-rewrite** input — the
408/// transcript's `events` show post-rewrite reality (the stub that actually
409/// ran); this shows what the skill *attempted*.
410#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
411pub struct MockCall {
412    /// Tool name as the harness reported it; `null` when the event named none.
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub tool: Option<String>,
415    /// The tool's original input arguments; `null` when the event carried none.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub input: Option<Value>,
418    /// The verdict applied: `allow` (fell through every rule), `deny`,
419    /// `rewrite`, or `stub`.
420    pub action: String,
421    /// Index of the compiled rule that intercepted; `null` for `allow`.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub rule: Option<usize>,
424    /// Name of the mock declaration the intercepting rule came from; `null`
425    /// for `allow`. Filled by the runner, so SDKs bind records to mock objects
426    /// by name instead of re-deriving rule indices.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub mock: Option<String>,
429}
430
431impl MockCall {
432    /// True iff a mock's verdict was applied to this call.
433    #[must_use]
434    pub fn mocked(&self) -> bool {
435        self.action != "allow"
436    }
437}
438
439/// The effective mock/spy set for one case run: the CLI/SDK-level declarations
440/// (first, so a test-local rule shadows a shared one — first match wins)
441/// followed by the case's own, validated as a whole and compiled to the
442/// oneharness ruleset.
443#[derive(Debug, Clone, Default)]
444pub struct MockSet {
445    decls: Vec<MockDecl>,
446    /// The compiled oneharness ruleset (`{"rules": […]}`); `None` when no
447    /// declaration carries an action.
448    rules: Option<Value>,
449    /// `rule_to_decl[rule_index]` = index into `decls`.
450    rule_to_decl: Vec<usize>,
451    /// The observation channel was requested even without any declarations
452    /// (`spy: true` on the case, `--spy` on the CLI).
453    spy_requested: bool,
454}
455
456impl MockSet {
457    /// Build and validate the effective set. `shared` (CLI `--mocks` / SDK)
458    /// declarations come first, then the case's own; names must be unique
459    /// across the whole set.
460    ///
461    /// # Errors
462    /// [`Error::Invalid`] on any invalid declaration or a duplicate name.
463    pub fn build(shared: &[MockDecl], case: &[MockDecl], spy_requested: bool) -> Result<MockSet> {
464        let decls: Vec<MockDecl> = shared.iter().chain(case.iter()).cloned().collect();
465        let mut seen = std::collections::BTreeSet::new();
466        for (i, decl) in decls.iter().enumerate() {
467            decl.validate(&format!("mock `{}`", decl_label(decl, i)))?;
468            if let Some(name) = &decl.name {
469                if !seen.insert(name.clone()) {
470                    return Err(Error::Invalid(format!(
471                        "duplicate mock name `{name}` (names must be unique across --mocks and the case)"
472                    )));
473                }
474            }
475        }
476        let mut rules = Vec::new();
477        let mut rule_to_decl = Vec::new();
478        for (i, decl) in decls.iter().enumerate() {
479            if let Some(action) = decl.action_json() {
480                rules.push(json!({ "match": decl.matcher.to_oneharness(), "action": action }));
481                rule_to_decl.push(i);
482            }
483        }
484        Ok(MockSet {
485            decls,
486            rules: (!rules.is_empty()).then(|| json!({ "rules": rules })),
487            rule_to_decl,
488            spy_requested,
489        })
490    }
491
492    /// Whether the observation channel should be enabled for this run.
493    #[must_use]
494    pub fn active(&self) -> bool {
495        self.spy_requested || !self.decls.is_empty()
496    }
497
498    /// The compiled oneharness ruleset, when any declaration intercepts.
499    #[must_use]
500    pub fn rules(&self) -> Option<&Value> {
501        self.rules.as_ref()
502    }
503
504    /// The effective declarations (shared first, then the case's).
505    #[must_use]
506    pub fn decls(&self) -> &[MockDecl] {
507        &self.decls
508    }
509
510    /// Fill each record's `mock` name from the rule index that intercepted it.
511    #[must_use]
512    pub fn resolve(&self, mut records: Vec<MockCall>) -> Vec<MockCall> {
513        for record in &mut records {
514            record.mock = record
515                .rule
516                .and_then(|rule| self.rule_to_decl.get(rule))
517                .map(|&decl| decl_label(&self.decls[decl], decl));
518        }
519        records
520    }
521
522    /// The records belonging to the declaration named `name`: for a mock, the
523    /// calls its rule intercepted; for a spy, every record its matcher covers
524    /// (including calls other rules intercepted — a spy observes everything).
525    ///
526    /// # Errors
527    /// [`Error::Invalid`] when no declaration has that name (listing the
528    /// declared names, so a typo is caught loudly instead of matching nothing).
529    pub fn records_for<'r>(
530        &self,
531        name: &str,
532        records: &'r [MockCall],
533    ) -> Result<Vec<&'r MockCall>> {
534        let (index, decl) = self
535            .decls
536            .iter()
537            .enumerate()
538            .find(|(i, d)| decl_label(d, *i) == name)
539            .ok_or_else(|| {
540                let declared: Vec<String> = self
541                    .decls
542                    .iter()
543                    .enumerate()
544                    .map(|(i, d)| decl_label(d, i))
545                    .collect();
546                Error::Invalid(format!(
547                    "eval references unknown mock `{name}` (declared: {})",
548                    if declared.is_empty() {
549                        "none".to_string()
550                    } else {
551                        declared.join(", ")
552                    }
553                ))
554            })?;
555        if decl.is_mock() {
556            let label = decl_label(decl, index);
557            Ok(records
558                .iter()
559                .filter(|r| r.mock.as_deref() == Some(label.as_str()))
560                .collect())
561        } else {
562            Ok(records
563                .iter()
564                .filter(|r| {
565                    decl.matcher
566                        .matches_call(r.tool.as_deref(), r.input.as_ref())
567                })
568                .collect())
569        }
570    }
571}
572
573/// A declaration's effective name: its explicit `name`, else a positional
574/// `mock_<i>` / `spy_<i>` fallback.
575fn decl_label(decl: &MockDecl, index: usize) -> String {
576    decl.name.clone().unwrap_or_else(|| {
577        if decl.is_mock() {
578            format!("mock_{index}")
579        } else {
580            format!("spy_{index}")
581        }
582    })
583}
584
585/// What the provider should do about mocking for one `respond` call: the
586/// compiled ruleset to enforce (if any) and that the observation channel is on.
587/// Absent entirely (the runner passes `None`) when the case declares nothing.
588#[derive(Debug, Clone, Copy)]
589pub struct MockPlan<'a> {
590    /// The compiled oneharness ruleset; `None` for a spy-only run.
591    pub rules: Option<&'a Value>,
592}
593
594// ---------------------------------------------------------------------------
595// The reference decision engine (fake provider / gate parity)
596// ---------------------------------------------------------------------------
597
598/// A matched action, borrowed from the compiled ruleset.
599#[derive(Debug, Clone, PartialEq)]
600pub enum AppliedAction<'r> {
601    Deny { message: &'r str },
602    Rewrite { input: &'r Value },
603    Stub { output: &'r str, exit_code: i32 },
604}
605
606impl AppliedAction<'_> {
607    /// Stable token for records (`deny`/`rewrite`/`stub`).
608    #[must_use]
609    pub fn kind(&self) -> &'static str {
610        match self {
611            AppliedAction::Deny { .. } => "deny",
612            AppliedAction::Rewrite { .. } => "rewrite",
613            AppliedAction::Stub { .. } => "stub",
614        }
615    }
616}
617
618/// Decide which rule of a compiled ruleset (the `{"rules": […]}` value built by
619/// [`MockSet::rules`]) intercepts a call. First match wins; `None` means allow
620/// through. This mirrors `oneharness mock`'s decision over the same shape so
621/// the fake provider exercises identical semantics.
622#[must_use]
623pub fn decide<'r>(
624    rules: &'r Value,
625    tool: Option<&str>,
626    input: Option<&Value>,
627) -> Option<(usize, AppliedAction<'r>)> {
628    let list = rules.get("rules")?.as_array()?;
629    for (index, rule) in list.iter().enumerate() {
630        if compiled_rule_matches(rule.get("match")?, tool, input) {
631            return parse_action(rule.get("action")?).map(|action| (index, action));
632        }
633    }
634    None
635}
636
637/// Whether a compiled oneharness `MatchSpec` covers the call.
638fn compiled_rule_matches(spec: &Value, tool: Option<&str>, input: Option<&Value>) -> bool {
639    if let Some(want) = spec.get("tool").and_then(Value::as_str) {
640        match tool {
641            Some(name) if name.eq_ignore_ascii_case(want) => {}
642            _ => return false,
643        }
644    }
645    let needs_event = spec.get("event_contains").is_some() || spec.get("event_regex").is_some();
646    if needs_event {
647        let event = event_haystack(tool, input);
648        if let Some(needle) = spec.get("event_contains").and_then(Value::as_str) {
649            if needle.is_empty() || !event.contains(needle) {
650                return false;
651            }
652        }
653        if let Some(pattern) = spec.get("event_regex").and_then(Value::as_str) {
654            match regex::Regex::new(pattern) {
655                Ok(re) if re.is_match(&event) => {}
656                _ => return false,
657            }
658        }
659    }
660    if let Some(preds) = spec.get("input").and_then(Value::as_object) {
661        for (key, pred) in preds {
662            let Some(value) = input.and_then(|i| i.get(key)) else {
663                return false;
664            };
665            let text = coerce_field(value);
666            if let Some(want) = pred.get("equals").and_then(Value::as_str) {
667                if text != want {
668                    return false;
669                }
670            }
671            if let Some(needle) = pred.get("contains").and_then(Value::as_str) {
672                if needle.is_empty() || !text.contains(needle) {
673                    return false;
674                }
675            }
676            if let Some(pattern) = pred.get("regex").and_then(Value::as_str) {
677                match regex::Regex::new(pattern) {
678                    Ok(re) if re.is_match(&text) => {}
679                    _ => return false,
680                }
681            }
682        }
683    }
684    true
685}
686
687fn parse_action(action: &Value) -> Option<AppliedAction<'_>> {
688    if let Some(deny) = action.get("deny") {
689        return Some(AppliedAction::Deny {
690            message: deny.get("message")?.as_str()?,
691        });
692    }
693    if let Some(rewrite) = action.get("rewrite") {
694        return Some(AppliedAction::Rewrite {
695            input: rewrite.get("input")?,
696        });
697    }
698    if let Some(stub) = action.get("stub") {
699        return Some(AppliedAction::Stub {
700            output: stub.get("output")?.as_str()?,
701            exit_code: stub
702                .get("exit_code")
703                .and_then(Value::as_i64)
704                .and_then(|v| i32::try_from(v).ok())
705                .unwrap_or(0),
706        });
707    }
708    None
709}
710
711/// The shell command a `stub` compiles to — a safely single-quoted `printf` of
712/// the declared output, exactly mirroring oneharness's `stub_input` so the fake
713/// provider's post-rewrite `events` look like the real thing.
714#[must_use]
715pub fn stub_command(output: &str, exit_code: i32) -> String {
716    let quoted = format!("'{}'", output.replace('\'', "'\\''"));
717    let mut command = format!("printf '%s\\n' {quoted}");
718    if exit_code != 0 {
719        command.push_str(&format!("; exit {exit_code}"));
720    }
721    command
722}
723
724/// Apply an eval's `where` clause (per-field input predicates) to a call's
725/// input. Every listed field must exist and match; an empty clause matches.
726#[must_use]
727pub fn where_matches(clause: &BTreeMap<String, FieldPredicate>, input: Option<&Value>) -> bool {
728    for (key, pred) in clause {
729        let Some(value) = input.and_then(|i| i.get(key)) else {
730            return false;
731        };
732        if !pred.matches(&coerce_field(value)) {
733            return false;
734        }
735    }
736    true
737}
738
739/// Whether a spy declaration's matcher covers a record (local matching — the
740/// hook never sees spies).
741#[must_use]
742pub fn decl_matches(matcher: &MockMatch, call: &MockCall) -> bool {
743    matcher.matches_call(call.tool.as_deref(), call.input.as_ref())
744}
745
746/// Validate an eval `where` clause, identified as `who` in errors.
747///
748/// # Errors
749/// [`Error::Invalid`] on an invalid predicate (see [`FieldPredicate`]).
750pub fn validate_where(clause: &BTreeMap<String, FieldPredicate>, who: &str) -> Result<()> {
751    for (key, pred) in clause {
752        pred.validate(who, key)?;
753    }
754    Ok(())
755}
756
757// ---------------------------------------------------------------------------
758// Spy-log parsing (the oneharness `--spy-file` JSONL)
759// ---------------------------------------------------------------------------
760
761/// One line of the oneharness spy log, as `oneharness mock` appends it.
762#[derive(Deserialize)]
763struct SpyLine {
764    /// The raw hook event (parsed JSON, or a string for a non-JSON event).
765    event: Value,
766    action: String,
767    #[serde(default)]
768    rule: Option<usize>,
769}
770
771/// Parse an oneharness spy-log (JSONL) into records. Unparseable lines are an
772/// error — a truncated log must not silently read as "fewer calls".
773///
774/// # Errors
775/// [`Error::Provider`] when a line is not valid JSON.
776pub fn parse_spy_log(text: &str) -> Result<Vec<MockCall>> {
777    let mut records = Vec::new();
778    for line in text.lines() {
779        let line = line.trim();
780        if line.is_empty() {
781            continue;
782        }
783        let parsed: SpyLine = serde_json::from_str(line).map_err(|e| {
784            Error::provider(
785                "oneharness",
786                format!("invalid spy-log line: {e}; got: {line}"),
787            )
788        })?;
789        records.push(MockCall {
790            tool: event_tool_name(&parsed.event),
791            input: event_tool_input(&parsed.event).cloned(),
792            action: parsed.action,
793            rule: parsed.rule,
794            mock: None,
795        });
796    }
797    Ok(records)
798}
799
800/// Best-effort tool name from a hook event (`tool_name`, Copilot's `toolName`,
801/// or `tool`) — the same keys `oneharness mock` extracts.
802fn event_tool_name(event: &Value) -> Option<String> {
803    for key in ["tool_name", "toolName", "tool"] {
804        if let Some(name) = event.get(key).and_then(Value::as_str) {
805            if !name.is_empty() {
806                return Some(name.to_string());
807            }
808        }
809    }
810    None
811}
812
813/// The tool's input object from a hook event (`tool_input`, or Copilot's
814/// `toolArgs`).
815fn event_tool_input(event: &Value) -> Option<&Value> {
816    for key in ["tool_input", "toolArgs"] {
817        if let Some(input) = event.get(key) {
818            if input.is_object() {
819                return Some(input);
820            }
821        }
822    }
823    None
824}
825
826/// The synthesized event JSON `contains`/`pattern` match against — compact, in
827/// the same field order every gated harness serializes (`tool_name` first).
828fn event_haystack(tool: Option<&str>, input: Option<&Value>) -> String {
829    let mut event = serde_json::Map::new();
830    if let Some(tool) = tool {
831        event.insert("tool_name".into(), json!(tool));
832    }
833    if let Some(input) = input {
834        event.insert("tool_input".into(), input.clone());
835    }
836    Value::Object(event).to_string()
837}
838
839/// A non-string input field is matched against its compact JSON form (the same
840/// coercion oneharness applies).
841fn coerce_field(value: &Value) -> String {
842    match value.as_str() {
843        Some(s) => s.to_string(),
844        None => value.to_string(),
845    }
846}
847
848/// Compile a regex pattern, mapping the failure to a loud [`Error::Invalid`].
849fn compile_pattern(pattern: &str, who: &str) -> Result<regex::Regex> {
850    regex::Regex::new(pattern)
851        .map_err(|e| Error::Invalid(format!("{who} is not a valid regex: {e}")))
852}
853
854/// A compact one-line description of observed calls for failure messages,
855/// e.g. `bash(git status), read({"file_path":"x"}) [deny]` — capped so a chatty
856/// run doesn't flood the report.
857#[must_use]
858pub fn describe_records(records: &[MockCall]) -> String {
859    const CAP: usize = 5;
860    let mut parts: Vec<String> = records
861        .iter()
862        .take(CAP)
863        .map(|r| {
864            let tool = r.tool.as_deref().unwrap_or("?");
865            let input = r
866                .input
867                .as_ref()
868                .map(|i| match i.get("command").and_then(Value::as_str) {
869                    Some(command) => command.to_string(),
870                    None => i.to_string(),
871                })
872                .unwrap_or_default();
873            if r.mocked() {
874                format!("{tool}({input}) [{}]", r.action)
875            } else {
876                format!("{tool}({input})")
877            }
878        })
879        .collect();
880    if records.len() > CAP {
881        parts.push(format!("… {} more", records.len() - CAP));
882    }
883    parts.join(", ")
884}
885
886#[cfg(test)]
887mod tests {
888    use super::*;
889
890    fn decl(yaml: &str) -> MockDecl {
891        serde_yaml::from_str(yaml).expect("test decl must parse")
892    }
893
894    #[test]
895    fn parses_stub_deny_rewrite_and_spy_shorthands() {
896        let stub = decl("match: { contains: git push }\nstub: Everything up-to-date\n");
897        assert_eq!(
898            stub.stub.as_ref().unwrap().output(),
899            "Everything up-to-date"
900        );
901        assert_eq!(stub.stub.as_ref().unwrap().exit_code(), 0);
902        assert_eq!(stub.action_kind(), Some("stub"));
903
904        let stub_full = decl("match: { tool: bash }\nstub: { output: boom, exit_code: 2 }\n");
905        assert_eq!(stub_full.stub.as_ref().unwrap().exit_code(), 2);
906
907        let deny = decl("match: { contains: rm -rf }\ndeny: blocked\n");
908        assert_eq!(deny.deny.as_ref().unwrap().message(), "blocked");
909
910        let deny_full = decl("match: { tool: bash }\ndeny: { message: nope }\n");
911        assert_eq!(deny_full.deny.as_ref().unwrap().message(), "nope");
912
913        let rewrite = decl("match: { tool: read }\nrewrite: { file_path: /tmp/fixture }\n");
914        assert_eq!(rewrite.action_kind(), Some("rewrite"));
915
916        let spy = decl("name: git\nmatch: { tool: bash, pattern: \"\\\\bgit\\\\b\" }\n");
917        assert!(!spy.is_mock());
918        assert_eq!(spy.action_kind(), None);
919    }
920
921    #[test]
922    fn validate_rejects_faults_loudly() {
923        // No criteria at all.
924        let d = MockDecl {
925            name: None,
926            matcher: MockMatch::default(),
927            stub: None,
928            deny: None,
929            rewrite: None,
930        };
931        assert!(d.validate("mock `x`").is_err());
932        // Empty needles.
933        for yaml in [
934            "match: { tool: \"\" }\n",
935            "match: { contains: \"\" }\n",
936            "match: { pattern: \"\" }\n",
937        ] {
938            assert!(decl(yaml).validate("m").is_err(), "{yaml}");
939        }
940        // Invalid regex is a load-time fault.
941        let err = decl("match: { pattern: \"git push(\" }\n")
942            .validate("mock `p`")
943            .unwrap_err();
944        assert!(err.to_string().contains("not a valid regex"), "{err}");
945        // More than one action.
946        assert!(decl("match: { tool: bash }\nstub: x\ndeny: y\n")
947            .validate("m")
948            .is_err());
949        // Rewrite must be an object.
950        assert!(decl("match: { tool: bash }\nrewrite: 3\n")
951            .validate("m")
952            .is_err());
953        // Input predicate faults.
954        assert!(decl("match: { input: { command: {} } }\n")
955            .validate("m")
956            .is_err());
957        assert!(decl("match: { input: { command: { contains: \"\" } } }\n")
958            .validate("m")
959            .is_err());
960        assert!(decl("match: { input: { command: { pattern: \"(\" } } }\n")
961            .validate("m")
962            .is_err());
963        // Unknown fields are rejected (a typo must never become a spy).
964        assert!(serde_yaml::from_str::<MockDecl>("match: { tool: bash }\nstubb: x\n").is_err());
965    }
966
967    fn set(yaml: &str) -> MockSet {
968        let decls: Vec<MockDecl> = serde_yaml::from_str(yaml).expect("decl list must parse");
969        MockSet::build(&[], &decls, false).expect("set must build")
970    }
971
972    const DECLS: &str = r#"
973- name: push
974  match: { tool: bash, pattern: "git push( --force)?\\b" }
975  stub: Everything up-to-date
976- name: danger
977  match: { contains: "rm -rf" }
978  deny: destructive commands are blocked
979- name: git
980  match: { tool: bash, pattern: "\\bgit\\b" }
981"#;
982
983    #[test]
984    fn compiles_actions_only_in_order_with_oneharness_field_names() {
985        let set = set(DECLS);
986        assert!(set.active());
987        let rules = set.rules().expect("two action decls");
988        let list = rules["rules"].as_array().unwrap();
989        // The spy (`git`) is not compiled — only the two actions, in order.
990        assert_eq!(list.len(), 2);
991        assert_eq!(list[0]["match"]["tool"], "bash");
992        assert_eq!(list[0]["match"]["event_regex"], "git push( --force)?\\b");
993        assert_eq!(list[0]["action"]["stub"]["output"], "Everything up-to-date");
994        assert_eq!(list[0]["action"]["stub"]["exit_code"], 0);
995        assert_eq!(list[1]["match"]["event_contains"], "rm -rf");
996        assert_eq!(
997            list[1]["action"]["deny"]["message"],
998            "destructive commands are blocked"
999        );
1000    }
1001
1002    #[test]
1003    fn compiles_input_predicates_with_pattern_renamed_to_regex() {
1004        let set = set(r#"
1005- name: read
1006  match:
1007    tool: read
1008    input:
1009      file_path: { pattern: "secrets" }
1010      mode: r
1011  rewrite: { file_path: /tmp/fixture }
1012"#);
1013        let rule = &set.rules().unwrap()["rules"][0];
1014        assert_eq!(rule["match"]["input"]["file_path"]["regex"], "secrets");
1015        assert_eq!(rule["match"]["input"]["mode"]["equals"], "r");
1016        assert_eq!(
1017            rule["action"]["rewrite"]["input"]["file_path"],
1018            "/tmp/fixture"
1019        );
1020    }
1021
1022    #[test]
1023    fn spy_only_set_has_no_rules_but_is_active() {
1024        let set = set("- name: git\n  match: { tool: bash }\n");
1025        assert!(set.rules().is_none());
1026        assert!(set.active());
1027        // And a bare `spy: true` (no decls) still activates the channel.
1028        let bare = MockSet::build(&[], &[], true).unwrap();
1029        assert!(bare.active());
1030        assert!(!MockSet::build(&[], &[], false).unwrap().active());
1031    }
1032
1033    #[test]
1034    fn build_rejects_duplicate_names_across_shared_and_case() {
1035        let shared: Vec<MockDecl> =
1036            serde_yaml::from_str("- name: push\n  match: { tool: bash }\n  stub: x\n").unwrap();
1037        let case: Vec<MockDecl> =
1038            serde_yaml::from_str("- name: push\n  match: { tool: bash }\n").unwrap();
1039        let err = MockSet::build(&shared, &case, false).unwrap_err();
1040        assert!(err.to_string().contains("duplicate mock name"), "{err}");
1041    }
1042
1043    fn call(tool: &str, command: &str, action: &str, rule: Option<usize>) -> MockCall {
1044        MockCall {
1045            tool: Some(tool.into()),
1046            input: Some(json!({ "command": command })),
1047            action: action.into(),
1048            rule,
1049            mock: None,
1050        }
1051    }
1052
1053    #[test]
1054    fn resolve_fills_mock_names_and_records_for_binds_by_name() {
1055        let set = set(DECLS);
1056        let records = set.resolve(vec![
1057            call("Bash", "git push origin", "stub", Some(0)),
1058            call("Bash", "git status", "allow", None),
1059            call("Bash", "rm -rf /", "deny", Some(1)),
1060            call("Read", "n/a", "allow", None),
1061        ]);
1062        assert_eq!(records[0].mock.as_deref(), Some("push"));
1063        assert!(records[1].mock.is_none());
1064        assert_eq!(records[2].mock.as_deref(), Some("danger"));
1065
1066        // A mock binds the calls its rule intercepted.
1067        let push = set.records_for("push", &records).unwrap();
1068        assert_eq!(push.len(), 1);
1069        assert!(push[0].mocked());
1070        // The spy observes every matching call — including the mocked push
1071        // (tool matches case-insensitively) but not the Read.
1072        let git = set.records_for("git", &records).unwrap();
1073        assert_eq!(git.len(), 2);
1074        // An unknown name is loud and lists what exists.
1075        let err = set.records_for("psuh", &records).unwrap_err();
1076        assert!(err.to_string().contains("unknown mock `psuh`"), "{err}");
1077        assert!(err.to_string().contains("push, danger, git"), "{err}");
1078    }
1079
1080    #[test]
1081    fn decide_first_match_wins_and_mirrors_matcher_semantics() {
1082        let set = set(DECLS);
1083        let rules = set.rules().unwrap();
1084        // Regex with the optional group: both spellings intercepted.
1085        for command in ["git push origin", "git push --force origin"] {
1086            let (rule, action) =
1087                decide(rules, Some("Bash"), Some(&json!({ "command": command }))).unwrap();
1088            assert_eq!(rule, 0, "{command}");
1089            assert!(
1090                matches!(action, AppliedAction::Stub { output, exit_code: 0 }
1091                if output == "Everything up-to-date")
1092            );
1093        }
1094        // Substring rule.
1095        let (rule, action) = decide(
1096            rules,
1097            Some("Bash"),
1098            Some(&json!({ "command": "rm -rf /tmp" })),
1099        )
1100        .unwrap();
1101        assert_eq!(rule, 1);
1102        assert_eq!(action.kind(), "deny");
1103        // Near-miss falls through (word boundary) and wrong tool falls through.
1104        assert!(decide(
1105            rules,
1106            Some("Bash"),
1107            Some(&json!({ "command": "git pushy" }))
1108        )
1109        .is_none());
1110        assert!(decide(rules, Some("Read"), Some(&json!({ "command": "git push" }))).is_none());
1111        // No tool name at all cannot match a tool criterion.
1112        assert!(decide(rules, None, Some(&json!({ "command": "git push" }))).is_none());
1113    }
1114
1115    #[test]
1116    fn decide_applies_input_predicates_and_coerces_non_strings() {
1117        let set = set(r#"
1118- match:
1119    input:
1120      file_path: { contains: secrets }
1121      depth: { equals: "3" }
1122  deny: no secrets
1123"#);
1124        let rules = set.rules().unwrap();
1125        // `depth` is a number — matched against its compact JSON form.
1126        let input = json!({ "file_path": "/etc/secrets.json", "depth": 3 });
1127        assert!(decide(rules, Some("read"), Some(&input)).is_some());
1128        // A missing field fails the rule.
1129        let input = json!({ "file_path": "/etc/secrets.json" });
1130        assert!(decide(rules, Some("read"), Some(&input)).is_none());
1131    }
1132
1133    #[test]
1134    fn stub_command_quotes_posix_safely() {
1135        assert_eq!(stub_command("clean", 0), "printf '%s\\n' 'clean'");
1136        assert_eq!(
1137            stub_command("it's done", 2),
1138            "printf '%s\\n' 'it'\\''s done'; exit 2"
1139        );
1140    }
1141
1142    #[test]
1143    fn where_matches_field_predicates() {
1144        let clause: BTreeMap<String, FieldPredicate> =
1145            serde_yaml::from_str("command: { contains: \"--force\" }\n").unwrap();
1146        assert!(where_matches(
1147            &clause,
1148            Some(&json!({ "command": "git push --force" }))
1149        ));
1150        assert!(!where_matches(
1151            &clause,
1152            Some(&json!({ "command": "git push" }))
1153        ));
1154        assert!(!where_matches(&clause, None));
1155        // Bare-string predicate = exact equality.
1156        let exact: BTreeMap<String, FieldPredicate> =
1157            serde_yaml::from_str("command: git status\n").unwrap();
1158        assert!(where_matches(
1159            &exact,
1160            Some(&json!({ "command": "git status" }))
1161        ));
1162        assert!(!where_matches(
1163            &exact,
1164            Some(&json!({ "command": "git status -s" }))
1165        ));
1166        // The empty clause matches anything.
1167        assert!(where_matches(&BTreeMap::new(), None));
1168    }
1169
1170    #[test]
1171    fn parse_spy_log_reads_oneharness_lines_and_rejects_garbage() {
1172        let log = concat!(
1173            r#"{"harness":"claude-code","event":{"tool_name":"Bash","tool_input":{"command":"git push"}},"action":"stub","rule":0}"#,
1174            "\n\n",
1175            r#"{"harness":"claude-code","event":{"toolName":"shell","toolArgs":{"command":"ls"}},"action":"allow","rule":null}"#,
1176            "\n",
1177        );
1178        let records = parse_spy_log(log).unwrap();
1179        assert_eq!(records.len(), 2);
1180        assert_eq!(records[0].tool.as_deref(), Some("Bash"));
1181        assert_eq!(records[0].input.as_ref().unwrap()["command"], "git push");
1182        assert_eq!(records[0].action, "stub");
1183        assert_eq!(records[0].rule, Some(0));
1184        assert!(records[0].mocked());
1185        // Copilot-shaped keys are extracted too; allow records carry no rule.
1186        assert_eq!(records[1].tool.as_deref(), Some("shell"));
1187        assert_eq!(records[1].rule, None);
1188        assert!(!records[1].mocked());
1189        // A truncated line is loud, never a silently shorter log.
1190        assert!(parse_spy_log("{\"event\":").is_err());
1191    }
1192
1193    #[test]
1194    fn field_predicate_spec_forms_all_hold_locally() {
1195        // equals + contains + pattern in one Spec — every form must hold.
1196        let clause: BTreeMap<String, FieldPredicate> = serde_yaml::from_str(
1197            "command: { equals: \"git push\", contains: push, pattern: \"^git\" }\n",
1198        )
1199        .unwrap();
1200        assert!(where_matches(
1201            &clause,
1202            Some(&json!({ "command": "git push" }))
1203        ));
1204        // equals mismatch, contains miss, and pattern miss each fail.
1205        assert!(!where_matches(
1206            &clause,
1207            Some(&json!({ "command": "git pushx" }))
1208        ));
1209        let pat_only: BTreeMap<String, FieldPredicate> =
1210            serde_yaml::from_str("command: { pattern: \"^git\" }\n").unwrap();
1211        assert!(!where_matches(
1212            &pat_only,
1213            Some(&json!({ "command": "use git" }))
1214        ));
1215    }
1216
1217    #[test]
1218    fn matches_call_contains_matches_over_event_haystack() {
1219        let matcher: MockMatch = serde_yaml::from_str("contains: \"git push\"\n").unwrap();
1220        assert!(matcher.matches_call(Some("Bash"), Some(&json!({ "command": "git push" }))));
1221        assert!(!matcher.matches_call(Some("Bash"), Some(&json!({ "command": "ls" }))));
1222    }
1223
1224    #[test]
1225    fn unnamed_decls_get_positional_labels_and_empty_set_reports_none() {
1226        // Unnamed action decl -> mock_<i>; unnamed spy -> spy_<i>.
1227        let decls: Vec<MockDecl> =
1228            serde_yaml::from_str("- match: { tool: bash }\n  stub: x\n- match: { tool: read }\n")
1229                .unwrap();
1230        let set = MockSet::build(&[], &decls, false).unwrap();
1231        let records = set.resolve(vec![call("bash", "ls", "stub", Some(0))]);
1232        assert_eq!(records[0].mock.as_deref(), Some("mock_0"));
1233        assert_eq!(set.records_for("spy_1", &records).unwrap().len(), 0);
1234        assert_eq!(set.decls().len(), 2);
1235        // No declarations at all: the unknown-name error says "none".
1236        let empty = MockSet::build(&[], &[], true).unwrap();
1237        let err = empty.records_for("ghost", &[]).unwrap_err();
1238        assert!(err.to_string().contains("declared: none"), "{err}");
1239    }
1240
1241    #[test]
1242    fn decide_applies_rewrite_actions_and_reports_kind() {
1243        let set = set(
1244            "- match: { tool: read, input: { file_path: { pattern: \"secrets\" } } }\n  rewrite: { file_path: /tmp/fixture }\n",
1245        );
1246        let rules = set.rules().unwrap();
1247        let input = json!({ "file_path": "/etc/secrets.json" });
1248        let (rule, action) = decide(rules, Some("read"), Some(&input)).unwrap();
1249        assert_eq!(rule, 0);
1250        assert_eq!(action.kind(), "rewrite");
1251        assert!(matches!(action, AppliedAction::Rewrite { input }
1252            if input["file_path"] == "/tmp/fixture"));
1253        // The regex input-predicate must actually gate: a non-matching path
1254        // falls through.
1255        assert!(decide(
1256            rules,
1257            Some("read"),
1258            Some(&json!({ "file_path": "/etc/ok" }))
1259        )
1260        .is_none());
1261    }
1262
1263    #[test]
1264    fn describe_records_caps_and_shows_verdicts() {
1265        let mut records: Vec<MockCall> = (0..7)
1266            .map(|i| call("bash", &format!("cmd{i}"), "allow", None))
1267            .collect();
1268        records[0] = call("bash", "git push", "deny", Some(0));
1269        // A record with a non-command input falls back to its compact JSON.
1270        records[1] = MockCall {
1271            tool: Some("read".into()),
1272            input: Some(json!({ "file_path": "/x" })),
1273            action: "allow".into(),
1274            rule: None,
1275            mock: None,
1276        };
1277        let text = describe_records(&records);
1278        assert!(text.contains("bash(git push) [deny]"), "{text}");
1279        assert!(text.contains("read({\"file_path\":\"/x\"})"), "{text}");
1280        assert!(text.contains("… 2 more"), "{text}");
1281        // And an input-less record renders without a detail.
1282        let bare = MockCall {
1283            tool: None,
1284            input: None,
1285            action: "allow".into(),
1286            rule: None,
1287            mock: None,
1288        };
1289        assert_eq!(describe_records(&[bare]), "?()");
1290    }
1291
1292    #[test]
1293    fn matches_call_composes_all_criteria() {
1294        let matcher: MockMatch = serde_yaml::from_str(
1295            "tool: bash\npattern: \"git (status|diff)\"\ninput: { command: { contains: git } }\n",
1296        )
1297        .unwrap();
1298        assert!(matcher.matches_call(Some("Bash"), Some(&json!({ "command": "git status" }))));
1299        assert!(!matcher.matches_call(Some("Bash"), Some(&json!({ "command": "git push" }))));
1300        assert!(!matcher.matches_call(Some("read"), Some(&json!({ "command": "git status" }))));
1301        assert!(!matcher.matches_call(None, Some(&json!({ "command": "git status" }))));
1302    }
1303}