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") | Some("okf")
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    /// The OKF bundle-conformance check ([`crate::okf::check`]).
462    Okf,
463}
464
465/// What the gate resolved a probe to.
466pub enum Gated<'a> {
467    /// A suite observer (read-only tools, `ct-test`, `ct-each`).
468    Observer,
469    /// A bridge entry; run with [`bridge_argv`]-adjusted arguments.
470    Bridge(&'a BridgeEntry),
471    /// A built-in check (`deps`/`mods`) run in-process from the rule layer.
472    Builtin(Builtin),
473}
474
475/// Gate a (def-expanded) probe argv. Returns how it may run, or a refusal
476/// naming the reason. The gate is fail-closed and compiled in.
477///
478/// # Examples
479///
480/// ```
481/// use coding_tools::rules::{gate_probe, Gated};
482///
483/// let ok = |argv: &[&str]| gate_probe(&argv.iter().map(|s| s.to_string()).collect::<Vec<_>>());
484/// assert!(matches!(ok(&["ct-search", "--base", "src"]), Ok(Gated::Observer)));
485/// assert!(matches!(ok(&["cargo", "tree", "-d"]), Ok(Gated::Bridge(_))));
486/// assert!(ok(&["ct-each", "--mutating", "--", "ct-edit"]).is_err()); // mutating never
487/// assert!(ok(&["ct-check"]).is_err());                               // no self-recursion
488/// assert!(ok(&["cargo", "publish"]).is_err());                       // unlisted prefix
489/// assert!(ok(&["rm", "-rf", "x"]).is_err());
490/// ```
491pub fn gate_probe(argv: &[String]) -> Result<Gated<'static>, String> {
492    let name = allowlist::gated_name(&argv[0]);
493    if name == "deps" {
494        return Ok(Gated::Builtin(Builtin::Deps));
495    }
496    if name == "mods" {
497        return Ok(Gated::Builtin(Builtin::Mods));
498    }
499    if name == "okf" {
500        return Ok(Gated::Builtin(Builtin::Okf));
501    }
502    if name == "ct-check" {
503        return Err(
504            "a probe may not run ct-check (no self-recursion through the store)".to_string(),
505        );
506    }
507    if name == "ct-rules" {
508        return Err("a probe may not run ct-rules (probes observe; they never write)".to_string());
509    }
510    if name == "ct-each" {
511        if argv.iter().any(|a| a == "--mutating") {
512            return Err(
513                "a probe may not pass --mutating (rules observe; they never change anything)"
514                    .to_string(),
515            );
516        }
517        return Ok(Gated::Observer);
518    }
519    if allowlist::is_allowed(&name) || name == "ct-test" {
520        return Ok(Gated::Observer);
521    }
522    for entry in BRIDGE {
523        if name == entry.prefix[0]
524            && argv.len() >= entry.prefix.len()
525            && argv[1..entry.prefix.len()]
526                .iter()
527                .zip(&entry.prefix[1..])
528                .all(|(a, p)| a == p)
529        {
530            return Ok(Gated::Bridge(entry));
531        }
532    }
533    Err(format!(
534        "'{}' is not a permitted probe: probes run the suite's read-only tools \
535         or a compiled-in bridge invocation ({}); the gate is immutable",
536        argv.iter().take(3).cloned().collect::<Vec<_>>().join(" "),
537        BRIDGE
538            .iter()
539            .map(|b| b.prefix.join(" "))
540            .collect::<Vec<_>>()
541            .join(", ")
542    ))
543}
544
545/// The argv actually launched for a bridge probe: the rule's argv plus the
546/// entry's enforced flags and hermetic flag (skipping flags already present).
547/// `network` drops the hermetic flag only where the entry deems it meaningful.
548///
549/// # Examples
550///
551/// ```
552/// use coding_tools::rules::{bridge_argv, BRIDGE};
553///
554/// let deny = &BRIDGE[2]; // cargo deny check
555/// let argv: Vec<String> = ["cargo", "deny", "check", "bans"].iter().map(|s| s.to_string()).collect();
556/// assert!(bridge_argv(deny, &argv, false).contains(&"--offline".to_string()));
557/// assert!(!bridge_argv(deny, &argv, true).contains(&"--offline".to_string()));
558///
559/// let tree = &BRIDGE[1]; // cargo tree: network is not meaningful — offline stays
560/// let argv: Vec<String> = ["cargo", "tree", "-d"].iter().map(|s| s.to_string()).collect();
561/// assert!(bridge_argv(tree, &argv, true).contains(&"--offline".to_string()));
562/// assert!(bridge_argv(tree, &argv, true).contains(&"--locked".to_string()));
563/// ```
564pub fn bridge_argv(entry: &BridgeEntry, argv: &[String], network: bool) -> Vec<String> {
565    fn append(out: &mut Vec<String>, flag: &str) {
566        if !out.iter().any(|a| a == flag) {
567            out.push(flag.to_string());
568        }
569    }
570    let mut out = argv.to_vec();
571    let mut i = 0;
572    while i < entry.enforced.len() {
573        let flag = entry.enforced[i];
574        // A flag taking a value (next entry not starting with '-') is
575        // appended as a pair when absent.
576        if i + 1 < entry.enforced.len() && !entry.enforced[i + 1].starts_with('-') {
577            if !argv.iter().any(|a| a == flag) {
578                out.push(flag.to_string());
579                out.push(entry.enforced[i + 1].to_string());
580            }
581            i += 2;
582        } else {
583            append(&mut out, flag);
584            i += 1;
585        }
586    }
587    if let Some(offline) = entry.offline_flag
588        && !(network && entry.network_meaningful)
589    {
590        append(&mut out, offline);
591    }
592    out
593}
594
595// ----- Probe execution ---------------------------------------------------------------
596
597/// Run one gated, def-expanded probe to completion and classify it. The
598/// probe runs from `root` — store paths are root-relative, so rules behave
599/// identically wherever the tool was invoked. Launch failures (e.g. a bridge
600/// binary not installed) and timeouts are *broken*, never errors: a
601/// defective probe is a maintenance signal the caller reports, not a crash.
602pub fn run_probe(
603    expanded: &[String],
604    gated: &Gated,
605    root: &Path,
606    network: bool,
607    timeout: Option<std::time::Duration>,
608    adapter: &Adapter,
609) -> (ProbeOutcome, String, crate::supervise::Outcome) {
610    if let Gated::Builtin(kind) = gated {
611        let (outcome, reason, report) = match kind {
612            Builtin::Deps => crate::deps::check(&expanded[1..], root, timeout),
613            Builtin::Mods => crate::modgraph::check(&expanded[1..], root, timeout),
614            Builtin::Okf => crate::okf::check(&expanded[1..], root, timeout),
615        };
616        return (
617            outcome,
618            reason,
619            crate::supervise::Outcome {
620                stdout: report,
621                stderr: String::new(),
622                status: None,
623                timed_out: false,
624            },
625        );
626    }
627    let argv = match gated {
628        Gated::Observer => expanded.to_vec(),
629        Gated::Bridge(entry) => bridge_argv(entry, expanded, network),
630        Gated::Builtin(_) => unreachable!("built-in checks handled above"),
631    };
632    let name = allowlist::gated_name(&argv[0]);
633    let mut command =
634        std::process::Command::new(crate::supervise::resolve_program(&argv[0], &name));
635    command.args(&argv[1..]).current_dir(root);
636    let empty = || crate::supervise::Outcome {
637        stdout: String::new(),
638        stderr: String::new(),
639        status: None,
640        timed_out: false,
641    };
642    match crate::supervise::run_captured(command, None, timeout) {
643        Err(e) => (
644            ProbeOutcome::Broken,
645            format!("could not launch '{}': {e}", argv[0]),
646            empty(),
647        ),
648        Ok(outcome) if outcome.timed_out => {
649            let label = timeout.map(crate::pulse::limit_label).unwrap_or_default();
650            (
651                ProbeOutcome::Broken,
652                format!("timed out after {label}; probe killed"),
653                outcome,
654            )
655        }
656        Ok(outcome) => {
657            let code = outcome.status.and_then(|s| s.code());
658            let (result, reason) = classify(adapter, code, &outcome.stdout, &outcome.stderr);
659            (result, reason, outcome)
660        }
661    }
662}
663
664// ----- Outcome classification -------------------------------------------------------
665
666/// A probe's classified outcome (before lane mapping).
667#[derive(Debug, Clone, PartialEq, Eq)]
668pub enum ProbeOutcome {
669    /// Zero violations: the rule holds.
670    Holds,
671    /// Violations found.
672    Violated,
673    /// The probe itself is defective (could not conclude).
674    Broken,
675}
676
677/// Classify a finished probe through its adapter. `code` is the exit code
678/// (`None` for a signal death — broken). Returns the outcome and a one-line
679/// reason. Timeouts are handled by the caller (always broken).
680pub fn classify(
681    adapter: &Adapter,
682    code: Option<i32>,
683    stdout: &str,
684    stderr: &str,
685) -> (ProbeOutcome, String) {
686    let by_exit = |code: Option<i32>| -> (ProbeOutcome, String) {
687        match code {
688            Some(0) => (ProbeOutcome::Holds, "probe exited 0".to_string()),
689            Some(1) => (ProbeOutcome::Violated, "probe exited 1".to_string()),
690            Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
691            None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
692        }
693    };
694    match adapter {
695        Adapter::Exit => by_exit(code),
696        Adapter::Empty => match code {
697            Some(0) if stdout.trim().is_empty() => {
698                (ProbeOutcome::Holds, "probe printed nothing".to_string())
699            }
700            Some(0) => (
701                ProbeOutcome::Violated,
702                "expect empty: probe printed output".to_string(),
703            ),
704            Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
705            None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
706        },
707        Adapter::Match { ok, err } => {
708            let hit = |pat: &str| -> Result<bool, String> {
709                Ok(pattern::compile(pat)
710                    .map_err(|e| format!("invalid expect pattern '{pat}': {e}"))?
711                    .is_match(stdout)
712                    || pattern::compile(pat).unwrap().is_match(stderr))
713            };
714            if let Some(p) = err {
715                match hit(p) {
716                    Ok(true) => {
717                        return (ProbeOutcome::Violated, format!("err-match '{p}' matched"));
718                    }
719                    Ok(false) => {}
720                    Err(e) => return (ProbeOutcome::Broken, e),
721                }
722            }
723            if let Some(p) = ok {
724                return match hit(p) {
725                    Ok(true) => (ProbeOutcome::Holds, format!("ok-match '{p}' matched")),
726                    Ok(false) => (ProbeOutcome::Violated, format!("ok-match '{p}' not found")),
727                    Err(e) => (ProbeOutcome::Broken, e),
728                };
729            }
730            by_exit(code)
731        }
732    }
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738
739    const SAMPLE: &str = r#"{
740  // a comment, because the store is JSONC
741  "defs": {
742    "layer": "src/domain",
743    "types": ["A", "B"]
744  },
745  "rules": [
746    {
747      "id": "one",
748      "question": "Q1?",
749      "probe": ["ct-search", "--base", "{def:layer}", "--expect", "none"],
750      "why": "because",
751      "tags": ["t1"]
752    },
753    {
754      "id": "two",
755      "question": "Q2?",
756      "probe": ["cargo", "tree", "-d"],
757      "expect": "empty",
758      "severity": "warn",
759      "pending": true,
760      "timeout": 5
761    }
762  ]
763}"#;
764
765    #[test]
766    fn parses_defs_rules_and_optional_fields() {
767        let store = parse_store(SAMPLE).unwrap();
768        assert_eq!(store.defs.len(), 2);
769        assert_eq!(store.rules.len(), 2);
770        let two = &store.rules[1];
771        assert_eq!(two.severity, Severity::Warn);
772        assert!(two.pending);
773        assert_eq!(two.expect, Adapter::Empty);
774        assert_eq!(two.timeout, Some(5.0));
775        assert_eq!(store.rules[0].severity, Severity::Fail);
776        assert_eq!(store.rules[0].expect, Adapter::Exit);
777    }
778
779    #[test]
780    fn rejects_duplicates_and_malformed_entries() {
781        let dup = r#"{"rules":[
782          {"id":"x","question":"q","probe":["ls"]},
783          {"id":"x","question":"q","probe":["ls"]}]}"#;
784        assert!(parse_store(dup).unwrap_err().contains("duplicate rule id"));
785        let bad = r#"{"rules":[{"id":"x","question":"q","probe":[]}]}"#;
786        assert!(parse_store(bad).unwrap_err().contains("must not be empty"));
787        let unknown = r#"{"stuff": 1}"#;
788        assert!(
789            parse_store(unknown)
790                .unwrap_err()
791                .contains("unknown store key")
792        );
793        let badsev = r#"{"rules":[{"id":"x","question":"q","probe":["ls"],"severity":"high"}]}"#;
794        assert!(
795            parse_store(badsev)
796                .unwrap_err()
797                .contains("invalid severity")
798        );
799        // A built-in check carries its own outcome; an expect adapter on one is
800        // rejected at load, not silently ignored (mirrors the ct-rules guard).
801        let builtin_adapter = r#"{"rules":[{"id":"x","question":"q","probe":["deps","--acyclic"],"expect":"empty"}]}"#;
802        assert!(
803            parse_store(builtin_adapter)
804                .unwrap_err()
805                .contains("takes no expect adapter")
806        );
807    }
808
809    #[test]
810    fn adapter_parsing_accepts_strings_and_matcher_objects() {
811        assert_eq!(
812            Adapter::from_value(&serde_json::json!("exit")).unwrap(),
813            Adapter::Exit
814        );
815        assert_eq!(
816            Adapter::from_value(&serde_json::json!("empty")).unwrap(),
817            Adapter::Empty
818        );
819        let m = Adapter::from_value(&serde_json::json!({"ok-match": "fine"})).unwrap();
820        assert_eq!(
821            m,
822            Adapter::Match {
823                ok: Some("fine".into()),
824                err: None
825            }
826        );
827        assert!(Adapter::from_value(&serde_json::json!("sometimes")).is_err());
828        assert!(Adapter::from_value(&serde_json::json!({})).is_err());
829        assert!(Adapter::from_value(&serde_json::json!({"oops": "x"})).is_err());
830    }
831
832    #[test]
833    fn gate_admits_observers_and_bridge_only() {
834        let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
835        assert!(matches!(
836            gate_probe(&argv(&["ct-outline", "--base", "."])),
837            Ok(Gated::Observer)
838        ));
839        assert!(matches!(
840            gate_probe(&argv(&["ct-test", "--cmd", "cat"])),
841            Ok(Gated::Observer)
842        ));
843        assert!(matches!(
844            gate_probe(&argv(&["cargo", "deny", "check", "bans"])),
845            Ok(Gated::Bridge(_))
846        ));
847        assert!(matches!(
848            gate_probe(&argv(&["rust-analyzer", "symbols"])),
849            Ok(Gated::Bridge(_))
850        ));
851        // Refusals: mutating tools, self-recursion, unlisted prefixes.
852        assert!(gate_probe(&argv(&["ct-edit", "--find", "a", "--replace", "b"])).is_err());
853        assert!(gate_probe(&argv(&["cargo", "build"])).is_err());
854        assert!(gate_probe(&argv(&["cargo"])).is_err());
855        assert!(gate_probe(&argv(&["sh", "-c", "true"])).is_err());
856    }
857
858    #[test]
859    fn gate_classifies_builtin_checks() {
860        let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
861        // `deps`/`mods` are reserved heads → built-in checks run in-process.
862        assert!(matches!(
863            gate_probe(&argv(&["deps", "--acyclic"])),
864            Ok(Gated::Builtin(Builtin::Deps))
865        ));
866        assert!(matches!(
867            gate_probe(&argv(&["mods", "--forbid", "a=>b"])),
868            Ok(Gated::Builtin(Builtin::Mods))
869        ));
870        // The retired binary names are not probes (no longer allowlisted).
871        assert!(gate_probe(&argv(&["ct-deps", "--acyclic"])).is_err());
872        assert!(gate_probe(&argv(&["ct-mods", "--acyclic"])).is_err());
873    }
874
875    #[test]
876    fn classify_exit_empty_and_matchers() {
877        use ProbeOutcome::*;
878        assert_eq!(classify(&Adapter::Exit, Some(0), "", "").0, Holds);
879        assert_eq!(classify(&Adapter::Exit, Some(1), "", "").0, Violated);
880        assert_eq!(classify(&Adapter::Exit, Some(101), "", "").0, Broken);
881
882        assert_eq!(classify(&Adapter::Empty, Some(0), " \n", "").0, Holds);
883        assert_eq!(
884            classify(&Adapter::Empty, Some(0), "dupe v1\n", "").0,
885            Violated
886        );
887        assert_eq!(classify(&Adapter::Empty, Some(2), "", "").0, Broken);
888
889        let m = Adapter::Match {
890            ok: Some("did not match any packages".into()),
891            err: None,
892        };
893        // cargo tree -i on an absent crate: error exit, but the ok proof appears.
894        assert_eq!(
895            classify(&m, Some(101), "", "error: ... did not match any packages").0,
896            Holds
897        );
898        let m = Adapter::Match {
899            ok: None,
900            err: Some("^openssl".into()),
901        };
902        assert_eq!(classify(&m, Some(0), "openssl v1.0\n", "").0, Violated);
903        // No hit, only err supplied: fall back to exit.
904        assert_eq!(classify(&m, Some(0), "clean", "").0, Holds);
905        // Required ok absent: violated even on exit 0 (fail-closed).
906        let m = Adapter::Match {
907            ok: Some("proof".into()),
908            err: None,
909        };
910        assert_eq!(classify(&m, Some(0), "no luck", "").0, Violated);
911    }
912}