1use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22use crate::allowlist;
23use crate::pattern;
24
25pub const STORE_FILE: &str = "rules.jsonc";
27
28pub 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
50pub fn store_path(root: &Path) -> PathBuf {
52 root.join(".ct").join(STORE_FILE)
53}
54
55pub 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#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum Def {
76 One(String),
78 Many(Vec<String>),
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub enum Severity {
86 #[default]
88 Fail,
89 Warn,
91}
92
93impl Severity {
94 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
107pub enum Adapter {
108 #[default]
111 Exit,
112 Empty,
116 Match {
121 ok: Option<String>,
123 err: Option<String>,
125 },
126}
127
128impl Adapter {
129 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#[derive(Debug, Clone)]
167pub struct Rule {
168 pub id: String,
170 pub question: String,
172 pub probe: Vec<String>,
174 pub why: Option<String>,
176 pub prompt: Option<String>,
180 pub tags: Vec<String>,
182 pub added: Option<String>,
184 pub timeout: Option<f64>,
186 pub pending: bool,
188 pub severity: Severity,
190 pub expect: Adapter,
192 pub network: bool,
194}
195
196#[derive(Debug, Default)]
198pub struct Store {
199 pub defs: BTreeMap<String, Def>,
201 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
219pub 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 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
332pub 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 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 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; };
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
404pub struct BridgeEntry {
409 pub prefix: &'static [&'static str],
411 pub enforced: &'static [&'static str],
413 pub offline_flag: Option<&'static str>,
415 pub network_meaningful: bool,
417}
418
419pub const BRIDGE: &[BridgeEntry] = &[
422 BridgeEntry {
423 prefix: &["cargo", "metadata"],
424 enforced: &["--locked", "--offline", "--format-version", "1"],
425 offline_flag: None, 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum Builtin {
457 Deps,
459 Mods,
461 Okf,
463}
464
465pub enum Gated<'a> {
467 Observer,
469 Bridge(&'a BridgeEntry),
471 Builtin(Builtin),
473}
474
475pub 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
545pub 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 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
595pub 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#[derive(Debug, Clone, PartialEq, Eq)]
668pub enum ProbeOutcome {
669 Holds,
671 Violated,
673 Broken,
675}
676
677pub 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 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 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 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 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 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 assert_eq!(classify(&m, Some(0), "clean", "").0, Holds);
905 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}