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!(
137                    "invalid expect '{other}' (use exit, empty, or a matcher object)"
138                )),
139            },
140            serde_json::Value::Object(o) => {
141                let get = |k: &str| -> Result<Option<String>, String> {
142                    match o.get(k) {
143                        None => Ok(None),
144                        Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
145                        Some(_) => Err(format!("expect.{k} must be a string")),
146                    }
147                };
148                let ok = get("ok-match")?;
149                let err = get("err-match")?;
150                if ok.is_none() && err.is_none() {
151                    return Err("expect object needs ok-match and/or err-match".to_string());
152                }
153                for key in o.keys() {
154                    if key != "ok-match" && key != "err-match" {
155                        return Err(format!("unknown expect key '{key}'"));
156                    }
157                }
158                Ok(Adapter::Match { ok, err })
159            }
160            _ => Err("expect must be a string or a matcher object".to_string()),
161        }
162    }
163}
164
165/// One recorded rule.
166#[derive(Debug, Clone)]
167pub struct Rule {
168    /// Unique slug; the rule's name everywhere.
169    pub id: String,
170    /// What this rule answers — the report line.
171    pub question: String,
172    /// The probe argv (pre-def-expansion, as stored).
173    pub probe: Vec<String>,
174    /// Why the invariant exists; printed when it fails.
175    pub why: Option<String>,
176    /// The verbatim human request that led to this rule, retained so the
177    /// intent can be understood or revised later. Provenance only — never
178    /// used by verification; stripped wholesale by `ct-rules --flatten`.
179    pub prompt: Option<String>,
180    /// Labels for `--tag` selection.
181    pub tags: Vec<String>,
182    /// Provenance date.
183    pub added: Option<String>,
184    /// Per-rule bound in seconds; overrides the CLI `--timeout`.
185    pub timeout: Option<f64>,
186    /// An aspiration, not yet held: reported, never enforced.
187    pub pending: bool,
188    /// Whether a violation reddens the exit status.
189    pub severity: Severity,
190    /// How the probe's outcome is read.
191    pub expect: Adapter,
192    /// Permit network access where the bridge entry deems it meaningful.
193    pub network: bool,
194}
195
196/// The parsed store.
197#[derive(Debug, Default)]
198pub struct Store {
199    /// Named vocabulary, expanded as `{def:NAME}` in probe argvs.
200    pub defs: BTreeMap<String, Def>,
201    /// The rules, in store (= run) order.
202    pub rules: Vec<Rule>,
203}
204
205fn as_str(v: &serde_json::Value, what: &str) -> Result<String, String> {
206    v.as_str()
207        .map(String::from)
208        .ok_or_else(|| format!("{what} must be a string"))
209}
210
211fn as_str_list(v: &serde_json::Value, what: &str) -> Result<Vec<String>, String> {
212    v.as_array()
213        .ok_or_else(|| format!("{what} must be an array of strings"))?
214        .iter()
215        .map(|e| as_str(e, what))
216        .collect()
217}
218
219/// Parse and validate the JSONC store text.
220pub fn parse_store(text: &str) -> Result<Store, String> {
221    let value = jsonc_parser::parse_to_serde_value(text, &jsonc_parser::ParseOptions::default())
222        .map_err(|e| format!("store parse error: {e}"))?
223        .ok_or("store is empty")?;
224    let obj = value.as_object().ok_or("store root must be an object")?;
225    for key in obj.keys() {
226        if key != "defs" && key != "rules" {
227            return Err(format!("unknown store key '{key}' (expected defs/rules)"));
228        }
229    }
230
231    let mut defs = BTreeMap::new();
232    if let Some(d) = obj.get("defs") {
233        let d = d.as_object().ok_or("defs must be an object")?;
234        for (name, val) in d {
235            let def = match val {
236                serde_json::Value::String(s) => Def::One(s.clone()),
237                serde_json::Value::Array(_) => {
238                    Def::Many(as_str_list(val, &format!("def '{name}'"))?)
239                }
240                _ => {
241                    return Err(format!(
242                        "def '{name}' must be a string or a list of strings"
243                    ));
244                }
245            };
246            defs.insert(name.clone(), def);
247        }
248    }
249
250    let mut rules = Vec::new();
251    let mut seen = std::collections::HashSet::new();
252    if let Some(r) = obj.get("rules") {
253        let arr = r.as_array().ok_or("rules must be an array")?;
254        for (i, entry) in arr.iter().enumerate() {
255            let o = entry
256                .as_object()
257                .ok_or_else(|| format!("rules[{i}] must be an object"))?;
258            let id = as_str(
259                o.get("id")
260                    .ok_or_else(|| format!("rules[{i}]: missing id"))?,
261                "id",
262            )?;
263            if id.is_empty() || id.contains(char::is_whitespace) {
264                return Err(format!("rules[{i}]: invalid id '{id}'"));
265            }
266            if !seen.insert(id.clone()) {
267                return Err(format!("duplicate rule id '{id}'"));
268            }
269            let where_ = format!("rule '{id}'");
270            let question = as_str(
271                o.get("question")
272                    .ok_or_else(|| format!("{where_}: missing question"))?,
273                "question",
274            )?;
275            let probe = as_str_list(
276                o.get("probe")
277                    .ok_or_else(|| format!("{where_}: missing probe"))?,
278                "probe",
279            )?;
280            if probe.is_empty() {
281                return Err(format!("{where_}: probe must not be empty"));
282            }
283            let rule = Rule {
284                id,
285                question,
286                probe,
287                why: o.get("why").map(|v| as_str(v, "why")).transpose()?,
288                prompt: o.get("prompt").map(|v| as_str(v, "prompt")).transpose()?,
289                tags: match o.get("tags") {
290                    Some(v) => as_str_list(v, "tags")?,
291                    None => Vec::new(),
292                },
293                added: o.get("added").map(|v| as_str(v, "added")).transpose()?,
294                timeout: match o.get("timeout") {
295                    Some(v) => Some(
296                        v.as_f64()
297                            .ok_or_else(|| format!("{where_}: timeout must be a number"))?,
298                    ),
299                    None => None,
300                },
301                pending: o.get("pending").and_then(|v| v.as_bool()).unwrap_or(false),
302                severity: match o.get("severity") {
303                    Some(v) => Severity::parse(&as_str(v, "severity")?)
304                        .map_err(|e| format!("{where_}: {e}"))?,
305                    None => Severity::Fail,
306                },
307                expect: match o.get("expect") {
308                    Some(v) => Adapter::from_value(v).map_err(|e| format!("{where_}: {e}"))?,
309                    None => Adapter::Exit,
310                },
311                network: o.get("network").and_then(|v| v.as_bool()).unwrap_or(false),
312            };
313            // Built-in checks (`deps`/`mods`) classify their own outcome, so an
314            // `expect` adapter is meaningless for them — reject it at load
315            // rather than silently ignore it (matches the ct-rules add guard).
316            if matches!(
317                rule.probe.first().map(String::as_str),
318                Some("deps") | Some("mods")
319            ) && rule.expect != Adapter::Exit
320            {
321                return Err(format!(
322                    "{where_}: built-in check '{}' takes no expect adapter (it classifies its own outcome)",
323                    rule.probe[0]
324                ));
325            }
326            rules.push(rule);
327        }
328    }
329    Ok(Store { defs, rules })
330}
331
332// ----- Def expansion --------------------------------------------------------------
333
334/// Expand `{def:NAME}` tokens in a probe argv. An element that is exactly one
335/// `{def:NAME}` token whose def is a list splices to multiple elements; a
336/// string def expands inside elements. A list def referenced inside a larger
337/// element, or an unknown def, is an error (the rule is broken).
338///
339/// # Examples
340///
341/// ```
342/// use std::collections::BTreeMap;
343/// use coding_tools::rules::{expand_defs, Def};
344///
345/// let mut defs = BTreeMap::new();
346/// defs.insert("layer".into(), Def::One("src/domain".into()));
347/// defs.insert("types".into(), Def::Many(vec!["A".into(), "B".into()]));
348///
349/// let argv: Vec<String> = ["--base", "{def:layer}", "--items", "{def:types}"]
350///     .iter().map(|s| s.to_string()).collect();
351/// assert_eq!(
352///     expand_defs(&argv, &defs).unwrap(),
353///     ["--base", "src/domain", "--items", "A", "B"]
354/// );
355/// assert!(expand_defs(&["x{def:types}".to_string()], &defs).is_err());
356/// assert!(expand_defs(&["{def:nope}".to_string()], &defs).is_err());
357/// ```
358pub fn expand_defs(argv: &[String], defs: &BTreeMap<String, Def>) -> Result<Vec<String>, String> {
359    let mut out = Vec::with_capacity(argv.len());
360    for element in argv {
361        // Whole-element list splice.
362        if let Some(name) = element
363            .strip_prefix("{def:")
364            .and_then(|r| r.strip_suffix('}'))
365            && !name.contains('{')
366        {
367            match defs.get(name) {
368                Some(Def::Many(items)) => {
369                    out.extend(items.iter().cloned());
370                    continue;
371                }
372                Some(Def::One(s)) => {
373                    out.push(s.clone());
374                    continue;
375                }
376                None => return Err(format!("unknown def '{name}'")),
377            }
378        }
379        // In-place string expansion (possibly several defs per element).
380        let mut text = element.clone();
381        while let Some(start) = text.find("{def:") {
382            let rest = &text[start + 5..];
383            let Some(end) = rest.find('}') else {
384                break; // unbalanced: leave verbatim
385            };
386            let name = &rest[..end];
387            match defs.get(name) {
388                Some(Def::One(s)) => {
389                    text = format!("{}{}{}", &text[..start], s, &rest[end + 1..]);
390                }
391                Some(Def::Many(_)) => {
392                    return Err(format!(
393                        "def '{name}' is a list and can only stand alone as one argv element"
394                    ));
395                }
396                None => return Err(format!("unknown def '{name}'")),
397            }
398        }
399        out.push(text);
400    }
401    Ok(out)
402}
403
404// ----- The bridge -----------------------------------------------------------------
405
406/// One compiled-in external invocation rules may leverage. The table is
407/// immutable: a store entry selects from it and can never extend it.
408pub struct BridgeEntry {
409    /// The argv prefix that must match (program name gated by basename).
410    pub prefix: &'static [&'static str],
411    /// Flags appended unconditionally (when not already present).
412    pub enforced: &'static [&'static str],
413    /// The hermetic flag appended unless the rule's `network` opt-in applies.
414    pub offline_flag: Option<&'static str>,
415    /// Whether `network: true` is meaningful for this entry.
416    pub network_meaningful: bool,
417}
418
419/// The compiled-in bridge: known read-only invocations of established Rust
420/// tools. See `docs/specs/rules.md` §5.
421pub const BRIDGE: &[BridgeEntry] = &[
422    BridgeEntry {
423        prefix: &["cargo", "metadata"],
424        enforced: &["--locked", "--offline", "--format-version", "1"],
425        offline_flag: None, // already in enforced
426        network_meaningful: false,
427    },
428    BridgeEntry {
429        prefix: &["cargo", "tree"],
430        enforced: &["--locked"],
431        offline_flag: Some("--offline"),
432        network_meaningful: false,
433    },
434    BridgeEntry {
435        prefix: &["cargo", "deny", "check"],
436        enforced: &[],
437        offline_flag: Some("--offline"),
438        network_meaningful: true,
439    },
440    BridgeEntry {
441        prefix: &["rust-analyzer", "search"],
442        enforced: &[],
443        offline_flag: None,
444        network_meaningful: false,
445    },
446    BridgeEntry {
447        prefix: &["rust-analyzer", "symbols"],
448        enforced: &[],
449        offline_flag: None,
450        network_meaningful: false,
451    },
452];
453
454/// A built-in check type — run in-process (not spawned) by [`run_probe`].
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum Builtin {
457    /// The crate-graph check ([`crate::deps::check`]).
458    Deps,
459    /// The module-graph check ([`crate::modgraph::check`]).
460    Mods,
461}
462
463/// What the gate resolved a probe to.
464pub enum Gated<'a> {
465    /// A suite observer (read-only tools, `ct-test`, `ct-each`).
466    Observer,
467    /// A bridge entry; run with [`bridge_argv`]-adjusted arguments.
468    Bridge(&'a BridgeEntry),
469    /// A built-in check (`deps`/`mods`) run in-process from the rule layer.
470    Builtin(Builtin),
471}
472
473/// Gate a (def-expanded) probe argv. Returns how it may run, or a refusal
474/// naming the reason. The gate is fail-closed and compiled in.
475///
476/// # Examples
477///
478/// ```
479/// use coding_tools::rules::{gate_probe, Gated};
480///
481/// let ok = |argv: &[&str]| gate_probe(&argv.iter().map(|s| s.to_string()).collect::<Vec<_>>());
482/// assert!(matches!(ok(&["ct-search", "--base", "src"]), Ok(Gated::Observer)));
483/// assert!(matches!(ok(&["cargo", "tree", "-d"]), Ok(Gated::Bridge(_))));
484/// assert!(ok(&["ct-each", "--mutating", "--", "ct-edit"]).is_err()); // mutating never
485/// assert!(ok(&["ct-check"]).is_err());                               // no self-recursion
486/// assert!(ok(&["cargo", "publish"]).is_err());                       // unlisted prefix
487/// assert!(ok(&["rm", "-rf", "x"]).is_err());
488/// ```
489pub fn gate_probe(argv: &[String]) -> Result<Gated<'static>, String> {
490    let name = allowlist::gated_name(&argv[0]);
491    if name == "deps" {
492        return Ok(Gated::Builtin(Builtin::Deps));
493    }
494    if name == "mods" {
495        return Ok(Gated::Builtin(Builtin::Mods));
496    }
497    if name == "ct-check" {
498        return Err(
499            "a probe may not run ct-check (no self-recursion through the store)".to_string(),
500        );
501    }
502    if name == "ct-rules" {
503        return Err("a probe may not run ct-rules (probes observe; they never write)".to_string());
504    }
505    if name == "ct-each" {
506        if argv.iter().any(|a| a == "--mutating") {
507            return Err(
508                "a probe may not pass --mutating (rules observe; they never change anything)"
509                    .to_string(),
510            );
511        }
512        return Ok(Gated::Observer);
513    }
514    if allowlist::is_allowed(&name) || name == "ct-test" {
515        return Ok(Gated::Observer);
516    }
517    for entry in BRIDGE {
518        if name == entry.prefix[0]
519            && argv.len() >= entry.prefix.len()
520            && argv[1..entry.prefix.len()]
521                .iter()
522                .zip(&entry.prefix[1..])
523                .all(|(a, p)| a == p)
524        {
525            return Ok(Gated::Bridge(entry));
526        }
527    }
528    Err(format!(
529        "'{}' is not a permitted probe: probes run the suite's read-only tools \
530         or a compiled-in bridge invocation ({}); the gate is immutable",
531        argv.iter().take(3).cloned().collect::<Vec<_>>().join(" "),
532        BRIDGE
533            .iter()
534            .map(|b| b.prefix.join(" "))
535            .collect::<Vec<_>>()
536            .join(", ")
537    ))
538}
539
540/// The argv actually launched for a bridge probe: the rule's argv plus the
541/// entry's enforced flags and hermetic flag (skipping flags already present).
542/// `network` drops the hermetic flag only where the entry deems it meaningful.
543///
544/// # Examples
545///
546/// ```
547/// use coding_tools::rules::{bridge_argv, BRIDGE};
548///
549/// let deny = &BRIDGE[2]; // cargo deny check
550/// let argv: Vec<String> = ["cargo", "deny", "check", "bans"].iter().map(|s| s.to_string()).collect();
551/// assert!(bridge_argv(deny, &argv, false).contains(&"--offline".to_string()));
552/// assert!(!bridge_argv(deny, &argv, true).contains(&"--offline".to_string()));
553///
554/// let tree = &BRIDGE[1]; // cargo tree: network is not meaningful — offline stays
555/// let argv: Vec<String> = ["cargo", "tree", "-d"].iter().map(|s| s.to_string()).collect();
556/// assert!(bridge_argv(tree, &argv, true).contains(&"--offline".to_string()));
557/// assert!(bridge_argv(tree, &argv, true).contains(&"--locked".to_string()));
558/// ```
559pub fn bridge_argv(entry: &BridgeEntry, argv: &[String], network: bool) -> Vec<String> {
560    fn append(out: &mut Vec<String>, flag: &str) {
561        if !out.iter().any(|a| a == flag) {
562            out.push(flag.to_string());
563        }
564    }
565    let mut out = argv.to_vec();
566    let mut i = 0;
567    while i < entry.enforced.len() {
568        let flag = entry.enforced[i];
569        // A flag taking a value (next entry not starting with '-') is
570        // appended as a pair when absent.
571        if i + 1 < entry.enforced.len() && !entry.enforced[i + 1].starts_with('-') {
572            if !argv.iter().any(|a| a == flag) {
573                out.push(flag.to_string());
574                out.push(entry.enforced[i + 1].to_string());
575            }
576            i += 2;
577        } else {
578            append(&mut out, flag);
579            i += 1;
580        }
581    }
582    if let Some(offline) = entry.offline_flag
583        && !(network && entry.network_meaningful)
584    {
585        append(&mut out, offline);
586    }
587    out
588}
589
590// ----- Probe execution ---------------------------------------------------------------
591
592/// Run one gated, def-expanded probe to completion and classify it. The
593/// probe runs from `root` — store paths are root-relative, so rules behave
594/// identically wherever the tool was invoked. Launch failures (e.g. a bridge
595/// binary not installed) and timeouts are *broken*, never errors: a
596/// defective probe is a maintenance signal the caller reports, not a crash.
597pub fn run_probe(
598    expanded: &[String],
599    gated: &Gated,
600    root: &Path,
601    network: bool,
602    timeout: Option<std::time::Duration>,
603    adapter: &Adapter,
604) -> (ProbeOutcome, String, crate::supervise::Outcome) {
605    if let Gated::Builtin(kind) = gated {
606        let (outcome, reason, report) = match kind {
607            Builtin::Deps => crate::deps::check(&expanded[1..], root, timeout),
608            Builtin::Mods => crate::modgraph::check(&expanded[1..], root, timeout),
609        };
610        return (
611            outcome,
612            reason,
613            crate::supervise::Outcome {
614                stdout: report,
615                stderr: String::new(),
616                status: None,
617                timed_out: false,
618            },
619        );
620    }
621    let argv = match gated {
622        Gated::Observer => expanded.to_vec(),
623        Gated::Bridge(entry) => bridge_argv(entry, expanded, network),
624        Gated::Builtin(_) => unreachable!("built-in checks handled above"),
625    };
626    let name = allowlist::gated_name(&argv[0]);
627    let mut command =
628        std::process::Command::new(crate::supervise::resolve_program(&argv[0], &name));
629    command.args(&argv[1..]).current_dir(root);
630    let empty = || crate::supervise::Outcome {
631        stdout: String::new(),
632        stderr: String::new(),
633        status: None,
634        timed_out: false,
635    };
636    match crate::supervise::run_captured(command, None, timeout) {
637        Err(e) => (
638            ProbeOutcome::Broken,
639            format!("could not launch '{}': {e}", argv[0]),
640            empty(),
641        ),
642        Ok(outcome) if outcome.timed_out => {
643            let label = timeout.map(crate::pulse::limit_label).unwrap_or_default();
644            (
645                ProbeOutcome::Broken,
646                format!("timed out after {label}; probe killed"),
647                outcome,
648            )
649        }
650        Ok(outcome) => {
651            let code = outcome.status.and_then(|s| s.code());
652            let (result, reason) = classify(adapter, code, &outcome.stdout, &outcome.stderr);
653            (result, reason, outcome)
654        }
655    }
656}
657
658// ----- Outcome classification -------------------------------------------------------
659
660/// A probe's classified outcome (before lane mapping).
661#[derive(Debug, Clone, PartialEq, Eq)]
662pub enum ProbeOutcome {
663    /// Zero violations: the rule holds.
664    Holds,
665    /// Violations found.
666    Violated,
667    /// The probe itself is defective (could not conclude).
668    Broken,
669}
670
671/// Classify a finished probe through its adapter. `code` is the exit code
672/// (`None` for a signal death — broken). Returns the outcome and a one-line
673/// reason. Timeouts are handled by the caller (always broken).
674pub fn classify(
675    adapter: &Adapter,
676    code: Option<i32>,
677    stdout: &str,
678    stderr: &str,
679) -> (ProbeOutcome, String) {
680    let by_exit = |code: Option<i32>| -> (ProbeOutcome, String) {
681        match code {
682            Some(0) => (ProbeOutcome::Holds, "probe exited 0".to_string()),
683            Some(1) => (ProbeOutcome::Violated, "probe exited 1".to_string()),
684            Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
685            None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
686        }
687    };
688    match adapter {
689        Adapter::Exit => by_exit(code),
690        Adapter::Empty => match code {
691            Some(0) if stdout.trim().is_empty() => {
692                (ProbeOutcome::Holds, "probe printed nothing".to_string())
693            }
694            Some(0) => (
695                ProbeOutcome::Violated,
696                "expect empty: probe printed output".to_string(),
697            ),
698            Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
699            None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
700        },
701        Adapter::Match { ok, err } => {
702            let hit = |pat: &str| -> Result<bool, String> {
703                Ok(pattern::compile(pat)
704                    .map_err(|e| format!("invalid expect pattern '{pat}': {e}"))?
705                    .is_match(stdout)
706                    || pattern::compile(pat).unwrap().is_match(stderr))
707            };
708            if let Some(p) = err {
709                match hit(p) {
710                    Ok(true) => {
711                        return (ProbeOutcome::Violated, format!("err-match '{p}' matched"));
712                    }
713                    Ok(false) => {}
714                    Err(e) => return (ProbeOutcome::Broken, e),
715                }
716            }
717            if let Some(p) = ok {
718                return match hit(p) {
719                    Ok(true) => (ProbeOutcome::Holds, format!("ok-match '{p}' matched")),
720                    Ok(false) => (ProbeOutcome::Violated, format!("ok-match '{p}' not found")),
721                    Err(e) => (ProbeOutcome::Broken, e),
722                };
723            }
724            by_exit(code)
725        }
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    const SAMPLE: &str = r#"{
734  // a comment, because the store is JSONC
735  "defs": {
736    "layer": "src/domain",
737    "types": ["A", "B"]
738  },
739  "rules": [
740    {
741      "id": "one",
742      "question": "Q1?",
743      "probe": ["ct-search", "--base", "{def:layer}", "--expect", "none"],
744      "why": "because",
745      "tags": ["t1"]
746    },
747    {
748      "id": "two",
749      "question": "Q2?",
750      "probe": ["cargo", "tree", "-d"],
751      "expect": "empty",
752      "severity": "warn",
753      "pending": true,
754      "timeout": 5
755    }
756  ]
757}"#;
758
759    #[test]
760    fn parses_defs_rules_and_optional_fields() {
761        let store = parse_store(SAMPLE).unwrap();
762        assert_eq!(store.defs.len(), 2);
763        assert_eq!(store.rules.len(), 2);
764        let two = &store.rules[1];
765        assert_eq!(two.severity, Severity::Warn);
766        assert!(two.pending);
767        assert_eq!(two.expect, Adapter::Empty);
768        assert_eq!(two.timeout, Some(5.0));
769        assert_eq!(store.rules[0].severity, Severity::Fail);
770        assert_eq!(store.rules[0].expect, Adapter::Exit);
771    }
772
773    #[test]
774    fn rejects_duplicates_and_malformed_entries() {
775        let dup = r#"{"rules":[
776          {"id":"x","question":"q","probe":["ls"]},
777          {"id":"x","question":"q","probe":["ls"]}]}"#;
778        assert!(parse_store(dup).unwrap_err().contains("duplicate rule id"));
779        let bad = r#"{"rules":[{"id":"x","question":"q","probe":[]}]}"#;
780        assert!(parse_store(bad).unwrap_err().contains("must not be empty"));
781        let unknown = r#"{"stuff": 1}"#;
782        assert!(
783            parse_store(unknown)
784                .unwrap_err()
785                .contains("unknown store key")
786        );
787        let badsev = r#"{"rules":[{"id":"x","question":"q","probe":["ls"],"severity":"high"}]}"#;
788        assert!(
789            parse_store(badsev)
790                .unwrap_err()
791                .contains("invalid severity")
792        );
793        // A built-in check carries its own outcome; an expect adapter on one is
794        // rejected at load, not silently ignored (mirrors the ct-rules guard).
795        let builtin_adapter = r#"{"rules":[{"id":"x","question":"q","probe":["deps","--acyclic"],"expect":"empty"}]}"#;
796        assert!(
797            parse_store(builtin_adapter)
798                .unwrap_err()
799                .contains("takes no expect adapter")
800        );
801    }
802
803    #[test]
804    fn adapter_parsing_accepts_strings_and_matcher_objects() {
805        assert_eq!(
806            Adapter::from_value(&serde_json::json!("exit")).unwrap(),
807            Adapter::Exit
808        );
809        assert_eq!(
810            Adapter::from_value(&serde_json::json!("empty")).unwrap(),
811            Adapter::Empty
812        );
813        let m = Adapter::from_value(&serde_json::json!({"ok-match": "fine"})).unwrap();
814        assert_eq!(
815            m,
816            Adapter::Match {
817                ok: Some("fine".into()),
818                err: None
819            }
820        );
821        assert!(Adapter::from_value(&serde_json::json!("sometimes")).is_err());
822        assert!(Adapter::from_value(&serde_json::json!({})).is_err());
823        assert!(Adapter::from_value(&serde_json::json!({"oops": "x"})).is_err());
824    }
825
826    #[test]
827    fn gate_admits_observers_and_bridge_only() {
828        let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
829        assert!(matches!(
830            gate_probe(&argv(&["ct-outline", "--base", "."])),
831            Ok(Gated::Observer)
832        ));
833        assert!(matches!(
834            gate_probe(&argv(&["ct-test", "--cmd", "cat"])),
835            Ok(Gated::Observer)
836        ));
837        assert!(matches!(
838            gate_probe(&argv(&["cargo", "deny", "check", "bans"])),
839            Ok(Gated::Bridge(_))
840        ));
841        assert!(matches!(
842            gate_probe(&argv(&["rust-analyzer", "symbols"])),
843            Ok(Gated::Bridge(_))
844        ));
845        // Refusals: mutating tools, self-recursion, unlisted prefixes.
846        assert!(gate_probe(&argv(&["ct-edit", "--find", "a", "--replace", "b"])).is_err());
847        assert!(gate_probe(&argv(&["cargo", "build"])).is_err());
848        assert!(gate_probe(&argv(&["cargo"])).is_err());
849        assert!(gate_probe(&argv(&["sh", "-c", "true"])).is_err());
850    }
851
852    #[test]
853    fn gate_classifies_builtin_checks() {
854        let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
855        // `deps`/`mods` are reserved heads → built-in checks run in-process.
856        assert!(matches!(
857            gate_probe(&argv(&["deps", "--acyclic"])),
858            Ok(Gated::Builtin(Builtin::Deps))
859        ));
860        assert!(matches!(
861            gate_probe(&argv(&["mods", "--forbid", "a=>b"])),
862            Ok(Gated::Builtin(Builtin::Mods))
863        ));
864        // The retired binary names are not probes (no longer allowlisted).
865        assert!(gate_probe(&argv(&["ct-deps", "--acyclic"])).is_err());
866        assert!(gate_probe(&argv(&["ct-mods", "--acyclic"])).is_err());
867    }
868
869    #[test]
870    fn classify_exit_empty_and_matchers() {
871        use ProbeOutcome::*;
872        assert_eq!(classify(&Adapter::Exit, Some(0), "", "").0, Holds);
873        assert_eq!(classify(&Adapter::Exit, Some(1), "", "").0, Violated);
874        assert_eq!(classify(&Adapter::Exit, Some(101), "", "").0, Broken);
875
876        assert_eq!(classify(&Adapter::Empty, Some(0), " \n", "").0, Holds);
877        assert_eq!(
878            classify(&Adapter::Empty, Some(0), "dupe v1\n", "").0,
879            Violated
880        );
881        assert_eq!(classify(&Adapter::Empty, Some(2), "", "").0, Broken);
882
883        let m = Adapter::Match {
884            ok: Some("did not match any packages".into()),
885            err: None,
886        };
887        // cargo tree -i on an absent crate: error exit, but the ok proof appears.
888        assert_eq!(
889            classify(&m, Some(101), "", "error: ... did not match any packages").0,
890            Holds
891        );
892        let m = Adapter::Match {
893            ok: None,
894            err: Some("^openssl".into()),
895        };
896        assert_eq!(classify(&m, Some(0), "openssl v1.0\n", "").0, Violated);
897        // No hit, only err supplied: fall back to exit.
898        assert_eq!(classify(&m, Some(0), "clean", "").0, Holds);
899        // Required ok absent: violated even on exit 0 (fail-closed).
900        let m = Adapter::Match {
901            ok: Some("proof".into()),
902            err: None,
903        };
904        assert_eq!(classify(&m, Some(0), "no luck", "").0, Violated);
905    }
906}