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