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            // Built-in checks (`deps`/`mods`) classify their own outcome, so an
300            // `expect` adapter is meaningless for them — reject it at load
301            // rather than silently ignore it (matches the ct-rules add guard).
302            if matches!(rule.probe.first().map(String::as_str), Some("deps") | Some("mods"))
303                && rule.expect != Adapter::Exit
304            {
305                return Err(format!(
306                    "{where_}: built-in check '{}' takes no expect adapter (it classifies its own outcome)",
307                    rule.probe[0]
308                ));
309            }
310            rules.push(rule);
311        }
312    }
313    Ok(Store { defs, rules })
314}
315
316// ----- Def expansion --------------------------------------------------------------
317
318/// Expand `{def:NAME}` tokens in a probe argv. An element that is exactly one
319/// `{def:NAME}` token whose def is a list splices to multiple elements; a
320/// string def expands inside elements. A list def referenced inside a larger
321/// element, or an unknown def, is an error (the rule is broken).
322///
323/// # Examples
324///
325/// ```
326/// use std::collections::BTreeMap;
327/// use coding_tools::rules::{expand_defs, Def};
328///
329/// let mut defs = BTreeMap::new();
330/// defs.insert("layer".into(), Def::One("src/domain".into()));
331/// defs.insert("types".into(), Def::Many(vec!["A".into(), "B".into()]));
332///
333/// let argv: Vec<String> = ["--base", "{def:layer}", "--items", "{def:types}"]
334///     .iter().map(|s| s.to_string()).collect();
335/// assert_eq!(
336///     expand_defs(&argv, &defs).unwrap(),
337///     ["--base", "src/domain", "--items", "A", "B"]
338/// );
339/// assert!(expand_defs(&["x{def:types}".to_string()], &defs).is_err());
340/// assert!(expand_defs(&["{def:nope}".to_string()], &defs).is_err());
341/// ```
342pub fn expand_defs(argv: &[String], defs: &BTreeMap<String, Def>) -> Result<Vec<String>, String> {
343    let mut out = Vec::with_capacity(argv.len());
344    for element in argv {
345        // Whole-element list splice.
346        if let Some(name) = element
347            .strip_prefix("{def:")
348            .and_then(|r| r.strip_suffix('}'))
349            && !name.contains('{')
350        {
351            match defs.get(name) {
352                Some(Def::Many(items)) => {
353                    out.extend(items.iter().cloned());
354                    continue;
355                }
356                Some(Def::One(s)) => {
357                    out.push(s.clone());
358                    continue;
359                }
360                None => return Err(format!("unknown def '{name}'")),
361            }
362        }
363        // In-place string expansion (possibly several defs per element).
364        let mut text = element.clone();
365        while let Some(start) = text.find("{def:") {
366            let rest = &text[start + 5..];
367            let Some(end) = rest.find('}') else {
368                break; // unbalanced: leave verbatim
369            };
370            let name = &rest[..end];
371            match defs.get(name) {
372                Some(Def::One(s)) => {
373                    text = format!("{}{}{}", &text[..start], s, &rest[end + 1..]);
374                }
375                Some(Def::Many(_)) => {
376                    return Err(format!(
377                        "def '{name}' is a list and can only stand alone as one argv element"
378                    ));
379                }
380                None => return Err(format!("unknown def '{name}'")),
381            }
382        }
383        out.push(text);
384    }
385    Ok(out)
386}
387
388// ----- The bridge -----------------------------------------------------------------
389
390/// One compiled-in external invocation rules may leverage. The table is
391/// immutable: a store entry selects from it and can never extend it.
392pub struct BridgeEntry {
393    /// The argv prefix that must match (program name gated by basename).
394    pub prefix: &'static [&'static str],
395    /// Flags appended unconditionally (when not already present).
396    pub enforced: &'static [&'static str],
397    /// The hermetic flag appended unless the rule's `network` opt-in applies.
398    pub offline_flag: Option<&'static str>,
399    /// Whether `network: true` is meaningful for this entry.
400    pub network_meaningful: bool,
401}
402
403/// The compiled-in bridge: known read-only invocations of established Rust
404/// tools. See `docs/specs/rules.md` §5.
405pub const BRIDGE: &[BridgeEntry] = &[
406    BridgeEntry {
407        prefix: &["cargo", "metadata"],
408        enforced: &["--locked", "--offline", "--format-version", "1"],
409        offline_flag: None, // already in enforced
410        network_meaningful: false,
411    },
412    BridgeEntry {
413        prefix: &["cargo", "tree"],
414        enforced: &["--locked"],
415        offline_flag: Some("--offline"),
416        network_meaningful: false,
417    },
418    BridgeEntry {
419        prefix: &["cargo", "deny", "check"],
420        enforced: &[],
421        offline_flag: Some("--offline"),
422        network_meaningful: true,
423    },
424    BridgeEntry {
425        prefix: &["rust-analyzer", "search"],
426        enforced: &[],
427        offline_flag: None,
428        network_meaningful: false,
429    },
430    BridgeEntry {
431        prefix: &["rust-analyzer", "symbols"],
432        enforced: &[],
433        offline_flag: None,
434        network_meaningful: false,
435    },
436];
437
438/// A built-in check type — run in-process (not spawned) by [`run_probe`].
439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440pub enum Builtin {
441    /// The crate-graph check ([`crate::deps::check`]).
442    Deps,
443    /// The module-graph check ([`crate::modgraph::check`]).
444    Mods,
445}
446
447/// What the gate resolved a probe to.
448pub enum Gated<'a> {
449    /// A suite observer (read-only tools, `ct-test`, `ct-each`).
450    Observer,
451    /// A bridge entry; run with [`bridge_argv`]-adjusted arguments.
452    Bridge(&'a BridgeEntry),
453    /// A built-in check (`deps`/`mods`) run in-process from the rule layer.
454    Builtin(Builtin),
455}
456
457/// Gate a (def-expanded) probe argv. Returns how it may run, or a refusal
458/// naming the reason. The gate is fail-closed and compiled in.
459///
460/// # Examples
461///
462/// ```
463/// use coding_tools::rules::{gate_probe, Gated};
464///
465/// let ok = |argv: &[&str]| gate_probe(&argv.iter().map(|s| s.to_string()).collect::<Vec<_>>());
466/// assert!(matches!(ok(&["ct-search", "--base", "src"]), Ok(Gated::Observer)));
467/// assert!(matches!(ok(&["cargo", "tree", "-d"]), Ok(Gated::Bridge(_))));
468/// assert!(ok(&["ct-each", "--mutating", "--", "ct-edit"]).is_err()); // mutating never
469/// assert!(ok(&["ct-check"]).is_err());                               // no self-recursion
470/// assert!(ok(&["cargo", "publish"]).is_err());                       // unlisted prefix
471/// assert!(ok(&["rm", "-rf", "x"]).is_err());
472/// ```
473pub fn gate_probe(argv: &[String]) -> Result<Gated<'static>, String> {
474    let name = allowlist::gated_name(&argv[0]);
475    if name == "deps" {
476        return Ok(Gated::Builtin(Builtin::Deps));
477    }
478    if name == "mods" {
479        return Ok(Gated::Builtin(Builtin::Mods));
480    }
481    if name == "ct-check" {
482        return Err("a probe may not run ct-check (no self-recursion through the store)".to_string());
483    }
484    if name == "ct-rules" {
485        return Err("a probe may not run ct-rules (probes observe; they never write)".to_string());
486    }
487    if name == "ct-each" {
488        if argv.iter().any(|a| a == "--mutating") {
489            return Err("a probe may not pass --mutating (rules observe; they never change anything)"
490                .to_string());
491        }
492        return Ok(Gated::Observer);
493    }
494    if allowlist::is_allowed(&name) || name == "ct-test" {
495        return Ok(Gated::Observer);
496    }
497    for entry in BRIDGE {
498        if name == entry.prefix[0]
499            && argv.len() >= entry.prefix.len()
500            && argv[1..entry.prefix.len()]
501                .iter()
502                .zip(&entry.prefix[1..])
503                .all(|(a, p)| a == p)
504        {
505            return Ok(Gated::Bridge(entry));
506        }
507    }
508    Err(format!(
509        "'{}' is not a permitted probe: probes run the suite's read-only tools \
510         or a compiled-in bridge invocation ({}); the gate is immutable",
511        argv.iter().take(3).cloned().collect::<Vec<_>>().join(" "),
512        BRIDGE
513            .iter()
514            .map(|b| b.prefix.join(" "))
515            .collect::<Vec<_>>()
516            .join(", ")
517    ))
518}
519
520/// The argv actually launched for a bridge probe: the rule's argv plus the
521/// entry's enforced flags and hermetic flag (skipping flags already present).
522/// `network` drops the hermetic flag only where the entry deems it meaningful.
523///
524/// # Examples
525///
526/// ```
527/// use coding_tools::rules::{bridge_argv, BRIDGE};
528///
529/// let deny = &BRIDGE[2]; // cargo deny check
530/// let argv: Vec<String> = ["cargo", "deny", "check", "bans"].iter().map(|s| s.to_string()).collect();
531/// assert!(bridge_argv(deny, &argv, false).contains(&"--offline".to_string()));
532/// assert!(!bridge_argv(deny, &argv, true).contains(&"--offline".to_string()));
533///
534/// let tree = &BRIDGE[1]; // cargo tree: network is not meaningful — offline stays
535/// let argv: Vec<String> = ["cargo", "tree", "-d"].iter().map(|s| s.to_string()).collect();
536/// assert!(bridge_argv(tree, &argv, true).contains(&"--offline".to_string()));
537/// assert!(bridge_argv(tree, &argv, true).contains(&"--locked".to_string()));
538/// ```
539pub fn bridge_argv(entry: &BridgeEntry, argv: &[String], network: bool) -> Vec<String> {
540    fn append(out: &mut Vec<String>, flag: &str) {
541        if !out.iter().any(|a| a == flag) {
542            out.push(flag.to_string());
543        }
544    }
545    let mut out = argv.to_vec();
546    let mut i = 0;
547    while i < entry.enforced.len() {
548        let flag = entry.enforced[i];
549        // A flag taking a value (next entry not starting with '-') is
550        // appended as a pair when absent.
551        if i + 1 < entry.enforced.len() && !entry.enforced[i + 1].starts_with('-') {
552            if !argv.iter().any(|a| a == flag) {
553                out.push(flag.to_string());
554                out.push(entry.enforced[i + 1].to_string());
555            }
556            i += 2;
557        } else {
558            append(&mut out, flag);
559            i += 1;
560        }
561    }
562    if let Some(offline) = entry.offline_flag
563        && !(network && entry.network_meaningful)
564    {
565        append(&mut out, offline);
566    }
567    out
568}
569
570// ----- Probe execution ---------------------------------------------------------------
571
572/// Run one gated, def-expanded probe to completion and classify it. The
573/// probe runs from `root` — store paths are root-relative, so rules behave
574/// identically wherever the tool was invoked. Launch failures (e.g. a bridge
575/// binary not installed) and timeouts are *broken*, never errors: a
576/// defective probe is a maintenance signal the caller reports, not a crash.
577pub fn run_probe(
578    expanded: &[String],
579    gated: &Gated,
580    root: &Path,
581    network: bool,
582    timeout: Option<std::time::Duration>,
583    adapter: &Adapter,
584) -> (ProbeOutcome, String, crate::supervise::Outcome) {
585    if let Gated::Builtin(kind) = gated {
586        let (outcome, reason, report) = match kind {
587            Builtin::Deps => crate::deps::check(&expanded[1..], root, timeout),
588            Builtin::Mods => crate::modgraph::check(&expanded[1..], root, timeout),
589        };
590        return (
591            outcome,
592            reason,
593            crate::supervise::Outcome {
594                stdout: report,
595                stderr: String::new(),
596                status: None,
597                timed_out: false,
598            },
599        );
600    }
601    let argv = match gated {
602        Gated::Observer => expanded.to_vec(),
603        Gated::Bridge(entry) => bridge_argv(entry, expanded, network),
604        Gated::Builtin(_) => unreachable!("built-in checks handled above"),
605    };
606    let name = allowlist::gated_name(&argv[0]);
607    let mut command = std::process::Command::new(crate::supervise::resolve_program(&argv[0], &name));
608    command.args(&argv[1..]).current_dir(root);
609    let empty = || crate::supervise::Outcome {
610        stdout: String::new(),
611        stderr: String::new(),
612        status: None,
613        timed_out: false,
614    };
615    match crate::supervise::run_captured(command, None, timeout) {
616        Err(e) => (
617            ProbeOutcome::Broken,
618            format!("could not launch '{}': {e}", argv[0]),
619            empty(),
620        ),
621        Ok(outcome) if outcome.timed_out => {
622            let label = timeout.map(crate::pulse::limit_label).unwrap_or_default();
623            (
624                ProbeOutcome::Broken,
625                format!("timed out after {label}; probe killed"),
626                outcome,
627            )
628        }
629        Ok(outcome) => {
630            let code = outcome.status.and_then(|s| s.code());
631            let (result, reason) = classify(adapter, code, &outcome.stdout, &outcome.stderr);
632            (result, reason, outcome)
633        }
634    }
635}
636
637// ----- Outcome classification -------------------------------------------------------
638
639/// A probe's classified outcome (before lane mapping).
640#[derive(Debug, Clone, PartialEq, Eq)]
641pub enum ProbeOutcome {
642    /// Zero violations: the rule holds.
643    Holds,
644    /// Violations found.
645    Violated,
646    /// The probe itself is defective (could not conclude).
647    Broken,
648}
649
650/// Classify a finished probe through its adapter. `code` is the exit code
651/// (`None` for a signal death — broken). Returns the outcome and a one-line
652/// reason. Timeouts are handled by the caller (always broken).
653pub fn classify(
654    adapter: &Adapter,
655    code: Option<i32>,
656    stdout: &str,
657    stderr: &str,
658) -> (ProbeOutcome, String) {
659    let by_exit = |code: Option<i32>| -> (ProbeOutcome, String) {
660        match code {
661            Some(0) => (ProbeOutcome::Holds, "probe exited 0".to_string()),
662            Some(1) => (ProbeOutcome::Violated, "probe exited 1".to_string()),
663            Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
664            None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
665        }
666    };
667    match adapter {
668        Adapter::Exit => by_exit(code),
669        Adapter::Empty => match code {
670            Some(0) if stdout.trim().is_empty() => {
671                (ProbeOutcome::Holds, "probe printed nothing".to_string())
672            }
673            Some(0) => (
674                ProbeOutcome::Violated,
675                "expect empty: probe printed output".to_string(),
676            ),
677            Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
678            None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
679        },
680        Adapter::Match { ok, err } => {
681            let hit = |pat: &str| -> Result<bool, String> {
682                Ok(pattern::compile(pat)
683                    .map_err(|e| format!("invalid expect pattern '{pat}': {e}"))?
684                    .is_match(stdout)
685                    || pattern::compile(pat).unwrap().is_match(stderr))
686            };
687            if let Some(p) = err {
688                match hit(p) {
689                    Ok(true) => {
690                        return (ProbeOutcome::Violated, format!("err-match '{p}' matched"));
691                    }
692                    Ok(false) => {}
693                    Err(e) => return (ProbeOutcome::Broken, e),
694                }
695            }
696            if let Some(p) = ok {
697                return match hit(p) {
698                    Ok(true) => (ProbeOutcome::Holds, format!("ok-match '{p}' matched")),
699                    Ok(false) => (
700                        ProbeOutcome::Violated,
701                        format!("ok-match '{p}' not found"),
702                    ),
703                    Err(e) => (ProbeOutcome::Broken, e),
704                };
705            }
706            by_exit(code)
707        }
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    const SAMPLE: &str = r#"{
716  // a comment, because the store is JSONC
717  "defs": {
718    "layer": "src/domain",
719    "types": ["A", "B"]
720  },
721  "rules": [
722    {
723      "id": "one",
724      "question": "Q1?",
725      "probe": ["ct-search", "--base", "{def:layer}", "--expect", "none"],
726      "why": "because",
727      "tags": ["t1"]
728    },
729    {
730      "id": "two",
731      "question": "Q2?",
732      "probe": ["cargo", "tree", "-d"],
733      "expect": "empty",
734      "severity": "warn",
735      "pending": true,
736      "timeout": 5
737    }
738  ]
739}"#;
740
741    #[test]
742    fn parses_defs_rules_and_optional_fields() {
743        let store = parse_store(SAMPLE).unwrap();
744        assert_eq!(store.defs.len(), 2);
745        assert_eq!(store.rules.len(), 2);
746        let two = &store.rules[1];
747        assert_eq!(two.severity, Severity::Warn);
748        assert!(two.pending);
749        assert_eq!(two.expect, Adapter::Empty);
750        assert_eq!(two.timeout, Some(5.0));
751        assert_eq!(store.rules[0].severity, Severity::Fail);
752        assert_eq!(store.rules[0].expect, Adapter::Exit);
753    }
754
755    #[test]
756    fn rejects_duplicates_and_malformed_entries() {
757        let dup = r#"{"rules":[
758          {"id":"x","question":"q","probe":["ls"]},
759          {"id":"x","question":"q","probe":["ls"]}]}"#;
760        assert!(parse_store(dup).unwrap_err().contains("duplicate rule id"));
761        let bad = r#"{"rules":[{"id":"x","question":"q","probe":[]}]}"#;
762        assert!(parse_store(bad).unwrap_err().contains("must not be empty"));
763        let unknown = r#"{"stuff": 1}"#;
764        assert!(parse_store(unknown).unwrap_err().contains("unknown store key"));
765        let badsev = r#"{"rules":[{"id":"x","question":"q","probe":["ls"],"severity":"high"}]}"#;
766        assert!(parse_store(badsev).unwrap_err().contains("invalid severity"));
767        // A built-in check carries its own outcome; an expect adapter on one is
768        // rejected at load, not silently ignored (mirrors the ct-rules guard).
769        let builtin_adapter =
770            r#"{"rules":[{"id":"x","question":"q","probe":["deps","--acyclic"],"expect":"empty"}]}"#;
771        assert!(parse_store(builtin_adapter)
772            .unwrap_err()
773            .contains("takes no expect adapter"));
774    }
775
776    #[test]
777    fn adapter_parsing_accepts_strings_and_matcher_objects() {
778        assert_eq!(Adapter::from_value(&serde_json::json!("exit")).unwrap(), Adapter::Exit);
779        assert_eq!(Adapter::from_value(&serde_json::json!("empty")).unwrap(), Adapter::Empty);
780        let m = Adapter::from_value(&serde_json::json!({"ok-match": "fine"})).unwrap();
781        assert_eq!(m, Adapter::Match { ok: Some("fine".into()), err: None });
782        assert!(Adapter::from_value(&serde_json::json!("sometimes")).is_err());
783        assert!(Adapter::from_value(&serde_json::json!({})).is_err());
784        assert!(Adapter::from_value(&serde_json::json!({"oops": "x"})).is_err());
785    }
786
787    #[test]
788    fn gate_admits_observers_and_bridge_only() {
789        let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
790        assert!(matches!(gate_probe(&argv(&["ct-outline", "--base", "."])), Ok(Gated::Observer)));
791        assert!(matches!(gate_probe(&argv(&["ct-test", "--cmd", "cat"])), Ok(Gated::Observer)));
792        assert!(matches!(gate_probe(&argv(&["cargo", "deny", "check", "bans"])), Ok(Gated::Bridge(_))));
793        assert!(matches!(gate_probe(&argv(&["rust-analyzer", "symbols"])), Ok(Gated::Bridge(_))));
794        // Refusals: mutating tools, self-recursion, unlisted prefixes.
795        assert!(gate_probe(&argv(&["ct-edit", "--find", "a", "--replace", "b"])).is_err());
796        assert!(gate_probe(&argv(&["cargo", "build"])).is_err());
797        assert!(gate_probe(&argv(&["cargo"])).is_err());
798        assert!(gate_probe(&argv(&["sh", "-c", "true"])).is_err());
799    }
800
801    #[test]
802    fn gate_classifies_builtin_checks() {
803        let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
804        // `deps`/`mods` are reserved heads → built-in checks run in-process.
805        assert!(matches!(
806            gate_probe(&argv(&["deps", "--acyclic"])),
807            Ok(Gated::Builtin(Builtin::Deps))
808        ));
809        assert!(matches!(
810            gate_probe(&argv(&["mods", "--forbid", "a=>b"])),
811            Ok(Gated::Builtin(Builtin::Mods))
812        ));
813        // The retired binary names are not probes (no longer allowlisted).
814        assert!(gate_probe(&argv(&["ct-deps", "--acyclic"])).is_err());
815        assert!(gate_probe(&argv(&["ct-mods", "--acyclic"])).is_err());
816    }
817
818    #[test]
819    fn classify_exit_empty_and_matchers() {
820        use ProbeOutcome::*;
821        assert_eq!(classify(&Adapter::Exit, Some(0), "", "").0, Holds);
822        assert_eq!(classify(&Adapter::Exit, Some(1), "", "").0, Violated);
823        assert_eq!(classify(&Adapter::Exit, Some(101), "", "").0, Broken);
824
825        assert_eq!(classify(&Adapter::Empty, Some(0), " \n", "").0, Holds);
826        assert_eq!(classify(&Adapter::Empty, Some(0), "dupe v1\n", "").0, Violated);
827        assert_eq!(classify(&Adapter::Empty, Some(2), "", "").0, Broken);
828
829        let m = Adapter::Match { ok: Some("did not match any packages".into()), err: None };
830        // cargo tree -i on an absent crate: error exit, but the ok proof appears.
831        assert_eq!(classify(&m, Some(101), "", "error: ... did not match any packages").0, Holds);
832        let m = Adapter::Match { ok: None, err: Some("^openssl".into()) };
833        assert_eq!(classify(&m, Some(0), "openssl v1.0\n", "").0, Violated);
834        // No hit, only err supplied: fall back to exit.
835        assert_eq!(classify(&m, Some(0), "clean", "").0, Holds);
836        // Required ok absent: violated even on exit 0 (fail-closed).
837        let m = Adapter::Match { ok: Some("proof".into()), err: None };
838        assert_eq!(classify(&m, Some(0), "no luck", "").0, Violated);
839    }
840}