Skip to main content

coding_tools/
rules.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! The rule surface shared by `ct-rules` (say what the rules are) and
5//! `ct-check` (verify them): the `.ct/rules.jsonc` store model, upward
6//! discovery, def expansion, the probe gate, the external-tool **bridge**,
7//! and the `expect` outcome adapters.
8//!
9//! A **rule** is one recorded, framed observation: an `id`, the `question` it
10//! answers, the **probe** (an argv vector, never a shell) that answers it by
11//! scanning for known violations, and the `why` behind it. **Defs** are the
12//! store's named vocabulary, expanded as `{def:NAME}` inside probe argvs.
13//! Probes are gated to the suite's fixed read-only set plus the compiled-in
14//! [`BRIDGE`] of known read-only invocations of established Rust tools — a
15//! store entry *selects from* the gate and can never extend it.
16//!
17//! The full specification is `docs/specs/rules.md`.
18
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22use crate::allowlist;
23use crate::pattern;
24
25/// The store's path relative to the `.ct` directory.
26pub const STORE_FILE: &str = "rules.jsonc";
27
28/// Walk upward from `start` to the nearest directory containing `.ct`,
29/// git-style. Returns that project root, or `None` when no `.ct` exists up
30/// to the filesystem root.
31///
32/// # Examples
33///
34/// ```
35/// use coding_tools::rules::discover_root;
36/// // No `.ct` above the filesystem root:
37/// assert_eq!(discover_root(std::path::Path::new("/")), None);
38/// ```
39pub fn discover_root(start: &Path) -> Option<PathBuf> {
40    let mut dir = Some(start.to_path_buf());
41    while let Some(d) = dir {
42        if d.join(".ct").is_dir() {
43            return Some(d);
44        }
45        dir = d.parent().map(Path::to_path_buf);
46    }
47    None
48}
49
50/// The store path under a project root.
51pub fn store_path(root: &Path) -> PathBuf {
52    root.join(".ct").join(STORE_FILE)
53}
54
55/// The directory probes run from: store paths are **root-relative**, so a
56/// probe's working directory is the project root (the parent of the `.ct`
57/// directory holding the store), regardless of where the tool was invoked.
58/// For a `--file` outside a `.ct` directory, the file's own directory.
59pub fn probe_root(store: &Path) -> PathBuf {
60    match store.parent() {
61        Some(dir) if dir.file_name().is_some_and(|n| n == ".ct") => dir
62            .parent()
63            .map(Path::to_path_buf)
64            .unwrap_or_else(|| dir.to_path_buf()),
65        Some(dir) => dir.to_path_buf(),
66        None => PathBuf::from("."),
67    }
68}
69
70// ----- Store model ----------------------------------------------------------------
71
72/// A def: the store's named vocabulary. Untyped — a string expands in place;
73/// a list expands to multiple argv elements.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum Def {
76    /// Expands inside an argv element.
77    One(String),
78    /// Expands to multiple argv elements (the element must be exactly the
79    /// `{def:NAME}` token).
80    Many(Vec<String>),
81}
82
83/// Rule severity: whether a violation reddens the exit status.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub enum Severity {
86    /// A violation fails the run (exit `1`). The default.
87    #[default]
88    Fail,
89    /// A violation is reported (`WARN` lane) but never affects exit status.
90    Warn,
91}
92
93impl Severity {
94    /// Parse the store's `severity` field.
95    pub fn parse(s: &str) -> Result<Severity, String> {
96        match s {
97            "fail" => Ok(Severity::Fail),
98            "warn" => Ok(Severity::Warn),
99            other => Err(format!("invalid severity '{other}' (use fail or warn)")),
100        }
101    }
102}
103
104/// How a probe's outcome is read as a verdict. Observers speak the suite's
105/// exit contract (`Exit`); bridge tools need an adapter.
106#[derive(Debug, Clone, PartialEq, Eq, Default)]
107pub enum Adapter {
108    /// Exit status under the suite contract: `0` holds, `1` violated,
109    /// anything else broken. The default.
110    #[default]
111    Exit,
112    /// Holds iff the probe exited `0` and printed nothing to stdout
113    /// (whitespace ignored); exited `0` with output = violated; nonzero =
114    /// broken. The `cargo tree -d` shape.
115    Empty,
116    /// `ct-test`-style matchers over the captured streams, with identical
117    /// promotion and fail-closed precedence: an `err` hit is decisively a
118    /// violation; an `ok` hit decisively holds; a supplied `ok` that did not
119    /// appear is a violation; otherwise fall back to `Exit`.
120    Match {
121        /// Pattern whose presence (stdout or stderr) means the rule holds.
122        ok: Option<String>,
123        /// Pattern whose presence (stdout or stderr) means a violation.
124        err: Option<String>,
125    },
126}
127
128impl Adapter {
129    /// Parse the store's `expect` field: `"exit"`, `"empty"`, or an object
130    /// with `ok-match` / `err-match` keys.
131    pub fn from_value(v: &serde_json::Value) -> Result<Adapter, String> {
132        match v {
133            serde_json::Value::String(s) => match s.as_str() {
134                "exit" => Ok(Adapter::Exit),
135                "empty" => Ok(Adapter::Empty),
136                other => Err(format!("invalid expect '{other}' (use exit, empty, or a matcher object)")),
137            },
138            serde_json::Value::Object(o) => {
139                let get = |k: &str| -> Result<Option<String>, String> {
140                    match o.get(k) {
141                        None => Ok(None),
142                        Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
143                        Some(_) => Err(format!("expect.{k} must be a string")),
144                    }
145                };
146                let ok = get("ok-match")?;
147                let err = get("err-match")?;
148                if ok.is_none() && err.is_none() {
149                    return Err("expect object needs ok-match and/or err-match".to_string());
150                }
151                for key in o.keys() {
152                    if key != "ok-match" && key != "err-match" {
153                        return Err(format!("unknown expect key '{key}'"));
154                    }
155                }
156                Ok(Adapter::Match { ok, err })
157            }
158            _ => Err("expect must be a string or a matcher object".to_string()),
159        }
160    }
161}
162
163/// One recorded rule.
164#[derive(Debug, Clone)]
165pub struct Rule {
166    /// Unique slug; the rule's name everywhere.
167    pub id: String,
168    /// What this rule answers — the report line.
169    pub question: String,
170    /// The probe argv (pre-def-expansion, as stored).
171    pub probe: Vec<String>,
172    /// Why the invariant exists; printed when it fails.
173    pub why: Option<String>,
174    /// The verbatim human request that led to this rule, retained so the
175    /// intent can be understood or revised later. Provenance only — never
176    /// used by verification; stripped wholesale by `ct-rules --flatten`.
177    pub prompt: Option<String>,
178    /// Labels for `--tag` selection.
179    pub tags: Vec<String>,
180    /// Provenance date.
181    pub added: Option<String>,
182    /// Per-rule bound in seconds; overrides the CLI `--timeout`.
183    pub timeout: Option<f64>,
184    /// An aspiration, not yet held: reported, never enforced.
185    pub pending: bool,
186    /// Whether a violation reddens the exit status.
187    pub severity: Severity,
188    /// How the probe's outcome is read.
189    pub expect: Adapter,
190    /// Permit network access where the bridge entry deems it meaningful.
191    pub network: bool,
192}
193
194/// The parsed store.
195#[derive(Debug, Default)]
196pub struct Store {
197    /// Named vocabulary, expanded as `{def:NAME}` in probe argvs.
198    pub defs: BTreeMap<String, Def>,
199    /// The rules, in store (= run) order.
200    pub rules: Vec<Rule>,
201}
202
203fn as_str(v: &serde_json::Value, what: &str) -> Result<String, String> {
204    v.as_str()
205        .map(String::from)
206        .ok_or_else(|| format!("{what} must be a string"))
207}
208
209fn as_str_list(v: &serde_json::Value, what: &str) -> Result<Vec<String>, String> {
210    v.as_array()
211        .ok_or_else(|| format!("{what} must be an array of strings"))?
212        .iter()
213        .map(|e| as_str(e, what))
214        .collect()
215}
216
217/// Parse and validate the JSONC store text.
218pub fn parse_store(text: &str) -> Result<Store, String> {
219    let value = jsonc_parser::parse_to_serde_value(text, &jsonc_parser::ParseOptions::default())
220        .map_err(|e| format!("store parse error: {e}"))?
221        .ok_or("store is empty")?;
222    let obj = value.as_object().ok_or("store root must be an object")?;
223    for key in obj.keys() {
224        if key != "defs" && key != "rules" {
225            return Err(format!("unknown store key '{key}' (expected defs/rules)"));
226        }
227    }
228
229    let mut defs = BTreeMap::new();
230    if let Some(d) = obj.get("defs") {
231        let d = d.as_object().ok_or("defs must be an object")?;
232        for (name, val) in d {
233            let def = match val {
234                serde_json::Value::String(s) => Def::One(s.clone()),
235                serde_json::Value::Array(_) => {
236                    Def::Many(as_str_list(val, &format!("def '{name}'"))?)
237                }
238                _ => return Err(format!("def '{name}' must be a string or a list of strings")),
239            };
240            defs.insert(name.clone(), def);
241        }
242    }
243
244    let mut rules = Vec::new();
245    let mut seen = std::collections::HashSet::new();
246    if let Some(r) = obj.get("rules") {
247        let arr = r.as_array().ok_or("rules must be an array")?;
248        for (i, entry) in arr.iter().enumerate() {
249            let o = entry
250                .as_object()
251                .ok_or_else(|| format!("rules[{i}] must be an object"))?;
252            let id = as_str(o.get("id").ok_or_else(|| format!("rules[{i}]: missing id"))?, "id")?;
253            if id.is_empty() || id.contains(char::is_whitespace) {
254                return Err(format!("rules[{i}]: invalid id '{id}'"));
255            }
256            if !seen.insert(id.clone()) {
257                return Err(format!("duplicate rule id '{id}'"));
258            }
259            let where_ = format!("rule '{id}'");
260            let question = as_str(
261                o.get("question")
262                    .ok_or_else(|| format!("{where_}: missing question"))?,
263                "question",
264            )?;
265            let probe = as_str_list(
266                o.get("probe").ok_or_else(|| format!("{where_}: missing probe"))?,
267                "probe",
268            )?;
269            if probe.is_empty() {
270                return Err(format!("{where_}: probe must not be empty"));
271            }
272            let rule = Rule {
273                id,
274                question,
275                probe,
276                why: o.get("why").map(|v| as_str(v, "why")).transpose()?,
277                prompt: o.get("prompt").map(|v| as_str(v, "prompt")).transpose()?,
278                tags: match o.get("tags") {
279                    Some(v) => as_str_list(v, "tags")?,
280                    None => Vec::new(),
281                },
282                added: o.get("added").map(|v| as_str(v, "added")).transpose()?,
283                timeout: match o.get("timeout") {
284                    Some(v) => Some(v.as_f64().ok_or_else(|| format!("{where_}: timeout must be a number"))?),
285                    None => None,
286                },
287                pending: o.get("pending").and_then(|v| v.as_bool()).unwrap_or(false),
288                severity: match o.get("severity") {
289                    Some(v) => Severity::parse(&as_str(v, "severity")?)
290                        .map_err(|e| format!("{where_}: {e}"))?,
291                    None => Severity::Fail,
292                },
293                expect: match o.get("expect") {
294                    Some(v) => Adapter::from_value(v).map_err(|e| format!("{where_}: {e}"))?,
295                    None => Adapter::Exit,
296                },
297                network: o.get("network").and_then(|v| v.as_bool()).unwrap_or(false),
298            };
299            rules.push(rule);
300        }
301    }
302    Ok(Store { defs, rules })
303}
304
305// ----- Def expansion --------------------------------------------------------------
306
307/// Expand `{def:NAME}` tokens in a probe argv. An element that is exactly one
308/// `{def:NAME}` token whose def is a list splices to multiple elements; a
309/// string def expands inside elements. A list def referenced inside a larger
310/// element, or an unknown def, is an error (the rule is broken).
311///
312/// # Examples
313///
314/// ```
315/// use std::collections::BTreeMap;
316/// use coding_tools::rules::{expand_defs, Def};
317///
318/// let mut defs = BTreeMap::new();
319/// defs.insert("layer".into(), Def::One("src/domain".into()));
320/// defs.insert("types".into(), Def::Many(vec!["A".into(), "B".into()]));
321///
322/// let argv: Vec<String> = ["--base", "{def:layer}", "--items", "{def:types}"]
323///     .iter().map(|s| s.to_string()).collect();
324/// assert_eq!(
325///     expand_defs(&argv, &defs).unwrap(),
326///     ["--base", "src/domain", "--items", "A", "B"]
327/// );
328/// assert!(expand_defs(&["x{def:types}".to_string()], &defs).is_err());
329/// assert!(expand_defs(&["{def:nope}".to_string()], &defs).is_err());
330/// ```
331pub fn expand_defs(argv: &[String], defs: &BTreeMap<String, Def>) -> Result<Vec<String>, String> {
332    let mut out = Vec::with_capacity(argv.len());
333    for element in argv {
334        // Whole-element list splice.
335        if let Some(name) = element
336            .strip_prefix("{def:")
337            .and_then(|r| r.strip_suffix('}'))
338            && !name.contains('{')
339        {
340            match defs.get(name) {
341                Some(Def::Many(items)) => {
342                    out.extend(items.iter().cloned());
343                    continue;
344                }
345                Some(Def::One(s)) => {
346                    out.push(s.clone());
347                    continue;
348                }
349                None => return Err(format!("unknown def '{name}'")),
350            }
351        }
352        // In-place string expansion (possibly several defs per element).
353        let mut text = element.clone();
354        while let Some(start) = text.find("{def:") {
355            let rest = &text[start + 5..];
356            let Some(end) = rest.find('}') else {
357                break; // unbalanced: leave verbatim
358            };
359            let name = &rest[..end];
360            match defs.get(name) {
361                Some(Def::One(s)) => {
362                    text = format!("{}{}{}", &text[..start], s, &rest[end + 1..]);
363                }
364                Some(Def::Many(_)) => {
365                    return Err(format!(
366                        "def '{name}' is a list and can only stand alone as one argv element"
367                    ));
368                }
369                None => return Err(format!("unknown def '{name}'")),
370            }
371        }
372        out.push(text);
373    }
374    Ok(out)
375}
376
377// ----- The bridge -----------------------------------------------------------------
378
379/// One compiled-in external invocation rules may leverage. The table is
380/// immutable: a store entry selects from it and can never extend it.
381pub struct BridgeEntry {
382    /// The argv prefix that must match (program name gated by basename).
383    pub prefix: &'static [&'static str],
384    /// Flags appended unconditionally (when not already present).
385    pub enforced: &'static [&'static str],
386    /// The hermetic flag appended unless the rule's `network` opt-in applies.
387    pub offline_flag: Option<&'static str>,
388    /// Whether `network: true` is meaningful for this entry.
389    pub network_meaningful: bool,
390}
391
392/// The compiled-in bridge: known read-only invocations of established Rust
393/// tools. See `docs/specs/rules.md` §5.
394pub const BRIDGE: &[BridgeEntry] = &[
395    BridgeEntry {
396        prefix: &["cargo", "metadata"],
397        enforced: &["--locked", "--offline", "--format-version", "1"],
398        offline_flag: None, // already in enforced
399        network_meaningful: false,
400    },
401    BridgeEntry {
402        prefix: &["cargo", "tree"],
403        enforced: &["--locked"],
404        offline_flag: Some("--offline"),
405        network_meaningful: false,
406    },
407    BridgeEntry {
408        prefix: &["cargo", "deny", "check"],
409        enforced: &[],
410        offline_flag: Some("--offline"),
411        network_meaningful: true,
412    },
413    BridgeEntry {
414        prefix: &["rust-analyzer", "search"],
415        enforced: &[],
416        offline_flag: None,
417        network_meaningful: false,
418    },
419    BridgeEntry {
420        prefix: &["rust-analyzer", "symbols"],
421        enforced: &[],
422        offline_flag: None,
423        network_meaningful: false,
424    },
425];
426
427/// What the gate resolved a probe to.
428pub enum Gated<'a> {
429    /// A suite observer (read-only tools, `ct-test`, `ct-each`).
430    Observer,
431    /// A bridge entry; run with [`bridge_argv`]-adjusted arguments.
432    Bridge(&'a BridgeEntry),
433}
434
435/// Gate a (def-expanded) probe argv. Returns how it may run, or a refusal
436/// naming the reason. The gate is fail-closed and compiled in.
437///
438/// # Examples
439///
440/// ```
441/// use coding_tools::rules::{gate_probe, Gated};
442///
443/// let ok = |argv: &[&str]| gate_probe(&argv.iter().map(|s| s.to_string()).collect::<Vec<_>>());
444/// assert!(matches!(ok(&["ct-search", "--base", "src"]), Ok(Gated::Observer)));
445/// assert!(matches!(ok(&["cargo", "tree", "-d"]), Ok(Gated::Bridge(_))));
446/// assert!(ok(&["ct-each", "--mutating", "--", "ct-edit"]).is_err()); // mutating never
447/// assert!(ok(&["ct-check"]).is_err());                               // no self-recursion
448/// assert!(ok(&["cargo", "publish"]).is_err());                       // unlisted prefix
449/// assert!(ok(&["rm", "-rf", "x"]).is_err());
450/// ```
451pub fn gate_probe(argv: &[String]) -> Result<Gated<'static>, String> {
452    let name = allowlist::gated_name(&argv[0]);
453    if name == "ct-check" {
454        return Err("a probe may not run ct-check (no self-recursion through the store)".to_string());
455    }
456    if name == "ct-rules" {
457        return Err("a probe may not run ct-rules (probes observe; they never write)".to_string());
458    }
459    if name == "ct-each" {
460        if argv.iter().any(|a| a == "--mutating") {
461            return Err("a probe may not pass --mutating (rules observe; they never change anything)"
462                .to_string());
463        }
464        return Ok(Gated::Observer);
465    }
466    if allowlist::is_allowed(&name) || name == "ct-test" {
467        return Ok(Gated::Observer);
468    }
469    for entry in BRIDGE {
470        if name == entry.prefix[0]
471            && argv.len() >= entry.prefix.len()
472            && argv[1..entry.prefix.len()]
473                .iter()
474                .zip(&entry.prefix[1..])
475                .all(|(a, p)| a == p)
476        {
477            return Ok(Gated::Bridge(entry));
478        }
479    }
480    Err(format!(
481        "'{}' is not a permitted probe: probes run the suite's read-only tools \
482         or a compiled-in bridge invocation ({}); the gate is immutable",
483        argv.iter().take(3).cloned().collect::<Vec<_>>().join(" "),
484        BRIDGE
485            .iter()
486            .map(|b| b.prefix.join(" "))
487            .collect::<Vec<_>>()
488            .join(", ")
489    ))
490}
491
492/// The argv actually launched for a bridge probe: the rule's argv plus the
493/// entry's enforced flags and hermetic flag (skipping flags already present).
494/// `network` drops the hermetic flag only where the entry deems it meaningful.
495///
496/// # Examples
497///
498/// ```
499/// use coding_tools::rules::{bridge_argv, BRIDGE};
500///
501/// let deny = &BRIDGE[2]; // cargo deny check
502/// let argv: Vec<String> = ["cargo", "deny", "check", "bans"].iter().map(|s| s.to_string()).collect();
503/// assert!(bridge_argv(deny, &argv, false).contains(&"--offline".to_string()));
504/// assert!(!bridge_argv(deny, &argv, true).contains(&"--offline".to_string()));
505///
506/// let tree = &BRIDGE[1]; // cargo tree: network is not meaningful — offline stays
507/// let argv: Vec<String> = ["cargo", "tree", "-d"].iter().map(|s| s.to_string()).collect();
508/// assert!(bridge_argv(tree, &argv, true).contains(&"--offline".to_string()));
509/// assert!(bridge_argv(tree, &argv, true).contains(&"--locked".to_string()));
510/// ```
511pub fn bridge_argv(entry: &BridgeEntry, argv: &[String], network: bool) -> Vec<String> {
512    fn append(out: &mut Vec<String>, flag: &str) {
513        if !out.iter().any(|a| a == flag) {
514            out.push(flag.to_string());
515        }
516    }
517    let mut out = argv.to_vec();
518    let mut i = 0;
519    while i < entry.enforced.len() {
520        let flag = entry.enforced[i];
521        // A flag taking a value (next entry not starting with '-') is
522        // appended as a pair when absent.
523        if i + 1 < entry.enforced.len() && !entry.enforced[i + 1].starts_with('-') {
524            if !argv.iter().any(|a| a == flag) {
525                out.push(flag.to_string());
526                out.push(entry.enforced[i + 1].to_string());
527            }
528            i += 2;
529        } else {
530            append(&mut out, flag);
531            i += 1;
532        }
533    }
534    if let Some(offline) = entry.offline_flag
535        && !(network && entry.network_meaningful)
536    {
537        append(&mut out, offline);
538    }
539    out
540}
541
542// ----- Probe execution ---------------------------------------------------------------
543
544/// Run one gated, def-expanded probe to completion and classify it. The
545/// probe runs from `root` — store paths are root-relative, so rules behave
546/// identically wherever the tool was invoked. Launch failures (e.g. a bridge
547/// binary not installed) and timeouts are *broken*, never errors: a
548/// defective probe is a maintenance signal the caller reports, not a crash.
549pub fn run_probe(
550    expanded: &[String],
551    gated: &Gated,
552    root: &Path,
553    network: bool,
554    timeout: Option<std::time::Duration>,
555    adapter: &Adapter,
556) -> (ProbeOutcome, String, crate::supervise::Outcome) {
557    let argv = match gated {
558        Gated::Observer => expanded.to_vec(),
559        Gated::Bridge(entry) => bridge_argv(entry, expanded, network),
560    };
561    let name = allowlist::gated_name(&argv[0]);
562    let mut command = std::process::Command::new(crate::supervise::resolve_program(&argv[0], &name));
563    command.args(&argv[1..]).current_dir(root);
564    let empty = || crate::supervise::Outcome {
565        stdout: String::new(),
566        stderr: String::new(),
567        status: None,
568        timed_out: false,
569    };
570    match crate::supervise::run_captured(command, None, timeout) {
571        Err(e) => (
572            ProbeOutcome::Broken,
573            format!("could not launch '{}': {e}", argv[0]),
574            empty(),
575        ),
576        Ok(outcome) if outcome.timed_out => {
577            let label = timeout.map(crate::pulse::limit_label).unwrap_or_default();
578            (
579                ProbeOutcome::Broken,
580                format!("timed out after {label}; probe killed"),
581                outcome,
582            )
583        }
584        Ok(outcome) => {
585            let code = outcome.status.and_then(|s| s.code());
586            let (result, reason) = classify(adapter, code, &outcome.stdout, &outcome.stderr);
587            (result, reason, outcome)
588        }
589    }
590}
591
592// ----- Outcome classification -------------------------------------------------------
593
594/// A probe's classified outcome (before lane mapping).
595#[derive(Debug, Clone, PartialEq, Eq)]
596pub enum ProbeOutcome {
597    /// Zero violations: the rule holds.
598    Holds,
599    /// Violations found.
600    Violated,
601    /// The probe itself is defective (could not conclude).
602    Broken,
603}
604
605/// Classify a finished probe through its adapter. `code` is the exit code
606/// (`None` for a signal death — broken). Returns the outcome and a one-line
607/// reason. Timeouts are handled by the caller (always broken).
608pub fn classify(
609    adapter: &Adapter,
610    code: Option<i32>,
611    stdout: &str,
612    stderr: &str,
613) -> (ProbeOutcome, String) {
614    let by_exit = |code: Option<i32>| -> (ProbeOutcome, String) {
615        match code {
616            Some(0) => (ProbeOutcome::Holds, "probe exited 0".to_string()),
617            Some(1) => (ProbeOutcome::Violated, "probe exited 1".to_string()),
618            Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
619            None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
620        }
621    };
622    match adapter {
623        Adapter::Exit => by_exit(code),
624        Adapter::Empty => match code {
625            Some(0) if stdout.trim().is_empty() => {
626                (ProbeOutcome::Holds, "probe printed nothing".to_string())
627            }
628            Some(0) => (
629                ProbeOutcome::Violated,
630                "expect empty: probe printed output".to_string(),
631            ),
632            Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
633            None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
634        },
635        Adapter::Match { ok, err } => {
636            let hit = |pat: &str| -> Result<bool, String> {
637                Ok(pattern::compile(pat)
638                    .map_err(|e| format!("invalid expect pattern '{pat}': {e}"))?
639                    .is_match(stdout)
640                    || pattern::compile(pat).unwrap().is_match(stderr))
641            };
642            if let Some(p) = err {
643                match hit(p) {
644                    Ok(true) => {
645                        return (ProbeOutcome::Violated, format!("err-match '{p}' matched"));
646                    }
647                    Ok(false) => {}
648                    Err(e) => return (ProbeOutcome::Broken, e),
649                }
650            }
651            if let Some(p) = ok {
652                return match hit(p) {
653                    Ok(true) => (ProbeOutcome::Holds, format!("ok-match '{p}' matched")),
654                    Ok(false) => (
655                        ProbeOutcome::Violated,
656                        format!("ok-match '{p}' not found"),
657                    ),
658                    Err(e) => (ProbeOutcome::Broken, e),
659                };
660            }
661            by_exit(code)
662        }
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    const SAMPLE: &str = r#"{
671  // a comment, because the store is JSONC
672  "defs": {
673    "layer": "src/domain",
674    "types": ["A", "B"]
675  },
676  "rules": [
677    {
678      "id": "one",
679      "question": "Q1?",
680      "probe": ["ct-search", "--base", "{def:layer}", "--expect", "none"],
681      "why": "because",
682      "tags": ["t1"]
683    },
684    {
685      "id": "two",
686      "question": "Q2?",
687      "probe": ["cargo", "tree", "-d"],
688      "expect": "empty",
689      "severity": "warn",
690      "pending": true,
691      "timeout": 5
692    }
693  ]
694}"#;
695
696    #[test]
697    fn parses_defs_rules_and_optional_fields() {
698        let store = parse_store(SAMPLE).unwrap();
699        assert_eq!(store.defs.len(), 2);
700        assert_eq!(store.rules.len(), 2);
701        let two = &store.rules[1];
702        assert_eq!(two.severity, Severity::Warn);
703        assert!(two.pending);
704        assert_eq!(two.expect, Adapter::Empty);
705        assert_eq!(two.timeout, Some(5.0));
706        assert_eq!(store.rules[0].severity, Severity::Fail);
707        assert_eq!(store.rules[0].expect, Adapter::Exit);
708    }
709
710    #[test]
711    fn rejects_duplicates_and_malformed_entries() {
712        let dup = r#"{"rules":[
713          {"id":"x","question":"q","probe":["ls"]},
714          {"id":"x","question":"q","probe":["ls"]}]}"#;
715        assert!(parse_store(dup).unwrap_err().contains("duplicate rule id"));
716        let bad = r#"{"rules":[{"id":"x","question":"q","probe":[]}]}"#;
717        assert!(parse_store(bad).unwrap_err().contains("must not be empty"));
718        let unknown = r#"{"stuff": 1}"#;
719        assert!(parse_store(unknown).unwrap_err().contains("unknown store key"));
720        let badsev = r#"{"rules":[{"id":"x","question":"q","probe":["ls"],"severity":"high"}]}"#;
721        assert!(parse_store(badsev).unwrap_err().contains("invalid severity"));
722    }
723
724    #[test]
725    fn adapter_parsing_accepts_strings_and_matcher_objects() {
726        assert_eq!(Adapter::from_value(&serde_json::json!("exit")).unwrap(), Adapter::Exit);
727        assert_eq!(Adapter::from_value(&serde_json::json!("empty")).unwrap(), Adapter::Empty);
728        let m = Adapter::from_value(&serde_json::json!({"ok-match": "fine"})).unwrap();
729        assert_eq!(m, Adapter::Match { ok: Some("fine".into()), err: None });
730        assert!(Adapter::from_value(&serde_json::json!("sometimes")).is_err());
731        assert!(Adapter::from_value(&serde_json::json!({})).is_err());
732        assert!(Adapter::from_value(&serde_json::json!({"oops": "x"})).is_err());
733    }
734
735    #[test]
736    fn gate_admits_observers_and_bridge_only() {
737        let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
738        assert!(matches!(gate_probe(&argv(&["ct-outline", "--base", "."])), Ok(Gated::Observer)));
739        assert!(matches!(gate_probe(&argv(&["ct-test", "--cmd", "cat"])), Ok(Gated::Observer)));
740        assert!(matches!(gate_probe(&argv(&["cargo", "deny", "check", "bans"])), Ok(Gated::Bridge(_))));
741        assert!(matches!(gate_probe(&argv(&["rust-analyzer", "symbols"])), Ok(Gated::Bridge(_))));
742        // Refusals: mutating tools, self-recursion, unlisted prefixes.
743        assert!(gate_probe(&argv(&["ct-edit", "--find", "a", "--replace", "b"])).is_err());
744        assert!(gate_probe(&argv(&["cargo", "build"])).is_err());
745        assert!(gate_probe(&argv(&["cargo"])).is_err());
746        assert!(gate_probe(&argv(&["sh", "-c", "true"])).is_err());
747    }
748
749    #[test]
750    fn classify_exit_empty_and_matchers() {
751        use ProbeOutcome::*;
752        assert_eq!(classify(&Adapter::Exit, Some(0), "", "").0, Holds);
753        assert_eq!(classify(&Adapter::Exit, Some(1), "", "").0, Violated);
754        assert_eq!(classify(&Adapter::Exit, Some(101), "", "").0, Broken);
755
756        assert_eq!(classify(&Adapter::Empty, Some(0), " \n", "").0, Holds);
757        assert_eq!(classify(&Adapter::Empty, Some(0), "dupe v1\n", "").0, Violated);
758        assert_eq!(classify(&Adapter::Empty, Some(2), "", "").0, Broken);
759
760        let m = Adapter::Match { ok: Some("did not match any packages".into()), err: None };
761        // cargo tree -i on an absent crate: error exit, but the ok proof appears.
762        assert_eq!(classify(&m, Some(101), "", "error: ... did not match any packages").0, Holds);
763        let m = Adapter::Match { ok: None, err: Some("^openssl".into()) };
764        assert_eq!(classify(&m, Some(0), "openssl v1.0\n", "").0, Violated);
765        // No hit, only err supplied: fall back to exit.
766        assert_eq!(classify(&m, Some(0), "clean", "").0, Holds);
767        // Required ok absent: violated even on exit 0 (fail-closed).
768        let m = Adapter::Match { ok: Some("proof".into()), err: None };
769        assert_eq!(classify(&m, Some(0), "no luck", "").0, Violated);
770    }
771}