Skip to main content

markdown_contract/runner/
corpus.rs

1//! The corpus runner: route a set of documents to contracts by glob, and return the
2//! findings and an exit code, mirroring the TS `runner/corpus.ts`.
3//!
4//! The core does no I/O: [`run_corpus`] takes `(relative posix path, source)` pairs in
5//! walk order, and validates each file against the first rule whose `include` matches
6//! and whose `exclude` does not. The native layer walks a directory and reads only the
7//! files a rule routes.
8//!
9//! Globs cover the common subset of picomatch with `{ dot: true }`: `*` never crosses
10//! `/`, `**/` spans zero or more directories (so `**/*.md` covers the root and nested
11//! files), dotfiles match, and `{a,b}` alternation works.
12//!
13//! The exit code is `0` when no finding has level `error`, else `1`. A host reports a
14//! config error (a [`GlobError`] or a `DeclarativeError`) on its own, such as with `2`.
15
16use std::borrow::Cow;
17
18use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
19
20use crate::contract::Contract;
21use crate::finding::{Finding, FindingLevel};
22use crate::validate::validate;
23
24/// One routing rule: the files that match `include` and not `exclude` are checked
25/// against `contract`.
26pub struct CorpusRule {
27    /// Globs relative to the run root. A file must match at least one.
28    pub include: Vec<String>,
29    /// Globs that take a file out of the rule.
30    pub exclude: Vec<String>,
31    /// The contract that checks the matched files.
32    pub contract: Contract,
33    /// The contract's name when the config refers to it by a string, for a host to
34    /// print. The runner does not route on it.
35    pub name: Option<String>,
36}
37
38/// The directory → contract config: a flat ORDERED list of rules. Rule order is
39/// significant — a file is validated against the FIRST rule it matches.
40#[derive(Default)]
41pub struct CorpusConfig {
42    /// the rules, in order; the first match wins
43    pub rules: Vec<CorpusRule>,
44}
45
46/// Per-run counts returned alongside the findings. Always satisfies
47/// `files_matched == matched_by_rule.iter().sum()` and
48/// `files_unmatched == files_scanned - files_matched`.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct RunStats {
51    /// every file offered to the run
52    pub files_scanned: usize,
53    /// files routed to a rule (validated)
54    pub files_matched: usize,
55    /// offered but not routed (pre-filtered out or matching no rule)
56    pub files_unmatched: usize,
57    /// matched count per rule, parallel to `config.rules` by index
58    pub matched_by_rule: Vec<usize>,
59}
60
61/// The run outcome: aggregated findings (each already deterministically sorted within
62/// its file, stamped with its file path), the exit code, and the run counts.
63#[derive(Debug)]
64pub struct RunOutcome {
65    /// every finding, grouped by file in the order the files were offered
66    pub findings: Vec<Finding>,
67    /// `0` = no error-level finding; `1` = at least one
68    pub exit_code: i32,
69    /// how many files were scanned and matched
70    pub stats: RunStats,
71}
72
73/// An optional global pre-filter, applied before rule matching (AND-narrowing): a file
74/// is considered only if it matches at least one `include` glob (when any are given)
75/// and no `exclude` glob.
76#[derive(Default)]
77pub struct RunOptions {
78    /// globs a file must match to be considered, when any are given
79    pub include: Vec<String>,
80    /// globs that take a file out of the run
81    pub exclude: Vec<String>,
82}
83
84/// A glob set that failed to compile — a config error (exit-code-2 territory for the
85/// hosting layer).
86#[derive(Debug, thiserror::Error)]
87#[error("invalid glob '{glob}': {reason}")]
88pub struct GlobError {
89    /// the glob that did not compile
90    pub glob: String,
91    /// why it did not compile
92    pub reason: String,
93}
94
95/// Compile a glob list into a matcher: `*` never crosses `/`, and dotfiles match.
96pub(crate) fn compile_matcher(globs: &[String]) -> Result<GlobSet, GlobError> {
97    let mut builder = GlobSetBuilder::new();
98    for glob in globs {
99        let compiled = GlobBuilder::new(glob)
100            .literal_separator(true)
101            .build()
102            .map_err(|e| GlobError {
103                glob: glob.clone(),
104                reason: e.to_string(),
105            })?;
106        builder.add(compiled);
107    }
108    builder.build().map_err(|e| GlobError {
109        glob: globs.join(", "),
110        reason: e.to_string(),
111    })
112}
113
114struct CompiledRule {
115    include: GlobSet,
116    exclude: Option<GlobSet>,
117}
118
119fn compile(config: &CorpusConfig) -> Result<Vec<CompiledRule>, GlobError> {
120    config
121        .rules
122        .iter()
123        .map(|r| {
124            Ok(CompiledRule {
125                include: compile_matcher(&r.include)?,
126                exclude: if r.exclude.is_empty() {
127                    None
128                } else {
129                    Some(compile_matcher(&r.exclude)?)
130                },
131            })
132        })
133        .collect()
134}
135
136/// Index of the FIRST rule whose include matches and exclude does not, or `None`.
137fn first_matching_rule(rules: &[CompiledRule], path: &str) -> Option<usize> {
138    rules.iter().position(|r| {
139        r.include.is_match(path) && !r.exclude.as_ref().is_some_and(|e| e.is_match(path))
140    })
141}
142
143/// Run a config across a file set and aggregate findings.
144///
145/// `files` supplies `(relative posix path, source)` pairs in the caller's walk order.
146/// Each file is validated by the first rule whose `include` matches and whose `exclude`
147/// does not, with its path stamped on every finding. A file that matches no rule is
148/// skipped.
149pub fn run_corpus<'a>(
150    config: &CorpusConfig,
151    files: impl IntoIterator<Item = (&'a str, &'a str)>,
152    opts: &RunOptions,
153) -> Result<RunOutcome, GlobError> {
154    run_lazy(
155        config,
156        files
157            .into_iter()
158            .map(|(path, source)| (path, move || Ok(Cow::Borrowed(source)))),
159        opts,
160    )
161}
162
163/// [`run_corpus`] with each source loaded only when a rule routes its file.
164pub(crate) fn run_lazy<'a, E, F>(
165    config: &CorpusConfig,
166    files: impl IntoIterator<Item = (&'a str, F)>,
167    opts: &RunOptions,
168) -> Result<RunOutcome, E>
169where
170    E: From<GlobError>,
171    F: FnOnce() -> Result<Cow<'a, str>, E>,
172{
173    let rules = compile(config)?;
174    let include = if opts.include.is_empty() {
175        None
176    } else {
177        Some(compile_matcher(&opts.include)?)
178    };
179    let exclude = if opts.exclude.is_empty() {
180        None
181    } else {
182        Some(compile_matcher(&opts.exclude)?)
183    };
184
185    let mut findings = Vec::new();
186    let mut matched_by_rule = vec![0usize; config.rules.len()];
187    let mut files_scanned = 0usize;
188    let mut files_matched = 0usize;
189
190    for (path, source) in files {
191        files_scanned += 1;
192        if exclude.as_ref().is_some_and(|e| e.is_match(path)) {
193            continue;
194        }
195        if include.as_ref().is_some_and(|i| !i.is_match(path)) {
196            continue;
197        }
198        let Some(idx) = first_matching_rule(&rules, path) else {
199            continue;
200        };
201        matched_by_rule[idx] += 1;
202        files_matched += 1;
203        findings.extend(validate(&source()?, &config.rules[idx].contract, path));
204    }
205
206    let has_error = findings.iter().any(|f| f.level == FindingLevel::Error);
207    Ok(RunOutcome {
208        findings,
209        exit_code: i32::from(has_error),
210        stats: RunStats {
211            files_scanned,
212            files_matched,
213            files_unmatched: files_scanned - files_matched,
214            matched_by_rule,
215        },
216    })
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::contract::{Contract, LevelOpts, section, sections};
223
224    fn overview_contract() -> Contract {
225        Contract::new().body(sections(LevelOpts::default(), vec![section("Overview")]))
226    }
227
228    fn rule(include: &[&str], exclude: &[&str]) -> CorpusRule {
229        CorpusRule {
230            include: include.iter().map(|s| s.to_string()).collect(),
231            exclude: exclude.iter().map(|s| s.to_string()).collect(),
232            contract: overview_contract(),
233            name: None,
234        }
235    }
236
237    // Contract first: one rule, two files — the matching file validates, the other is
238    // skipped, and the exit code reflects the error.
239    #[test]
240    fn routes_by_glob_and_returns_a_ci_exit_code() {
241        let config = CorpusConfig {
242            rules: vec![rule(&["**/*.md"], &[])],
243        };
244        let files = [
245            ("docs/bad.md", "## Wrong\n\nprose\n"),
246            ("notes.txt", "not markdown"),
247        ];
248        let out = run_corpus(&config, files, &RunOptions::default()).unwrap();
249        assert_eq!(out.exit_code, 1);
250        assert_eq!(out.findings.len(), 1);
251        assert_eq!(out.findings[0].path, "docs/bad.md");
252        assert_eq!(
253            out.stats,
254            RunStats {
255                files_scanned: 2,
256                files_matched: 1,
257                files_unmatched: 1,
258                matched_by_rule: vec![1],
259            }
260        );
261    }
262
263    #[test]
264    fn clean_corpus_exits_zero() {
265        let config = CorpusConfig {
266            rules: vec![rule(&["**/*.md"], &[])],
267        };
268        let out = run_corpus(
269            &config,
270            [("a.md", "## Overview\n\nfine\n")],
271            &RunOptions::default(),
272        )
273        .unwrap();
274        assert_eq!(out.exit_code, 0);
275        assert_eq!(out.findings, vec![]);
276    }
277
278    // First-match routing: the specific rule ahead of the catch-all wins.
279    #[test]
280    fn first_match_routing_is_by_rule_order() {
281        let config = CorpusConfig {
282            rules: vec![rule(&["**/A-*.md"], &[]), rule(&["**/*.md"], &[])],
283        };
284        let files = [
285            ("A-1.md", "## Overview\n\nok\n"),
286            ("b.md", "## Overview\n\nok\n"),
287        ];
288        let out = run_corpus(&config, files, &RunOptions::default()).unwrap();
289        assert_eq!(out.stats.matched_by_rule, vec![1, 1]);
290    }
291
292    #[test]
293    fn per_rule_exclude_and_global_pre_filter_narrow() {
294        let config = CorpusConfig {
295            rules: vec![rule(&["**/*.md"], &["**/skip-*.md"])],
296        };
297        let out = run_corpus(
298            &config,
299            [("skip-1.md", "x"), ("keep.md", "## Overview\n\nok\n")],
300            &RunOptions::default(),
301        )
302        .unwrap();
303        assert_eq!(out.stats.files_matched, 1);
304
305        let out = run_corpus(
306            &config,
307            [
308                ("keep.md", "## Overview\n\nok\n"),
309                ("docs/keep.md", "## Overview\n\nok\n"),
310            ],
311            &RunOptions {
312                include: vec!["docs/**".into()],
313                ..Default::default()
314            },
315        )
316        .unwrap();
317        assert_eq!(out.stats.files_matched, 1);
318    }
319
320    // The picomatch parity cases the TS runner pins: a single `**/`-glob spans the run
321    // root and nested files; `*` never crosses a separator; dotfiles match.
322    #[test]
323    fn glob_semantics_match_picomatch() {
324        let m = compile_matcher(&["**/*.md".into()]).unwrap();
325        assert!(m.is_match("a.md")); // root level
326        assert!(m.is_match("docs/deep/a.md")); // nested
327        assert!(m.is_match(".hidden.md")); // dot: true
328        let single = compile_matcher(&["docs/*.md".into()]).unwrap();
329        assert!(single.is_match("docs/a.md"));
330        assert!(!single.is_match("docs/deep/a.md")); // * does not cross /
331        let brace = compile_matcher(&["**/{a,b}.md".into()]).unwrap();
332        assert!(brace.is_match("x/a.md"));
333        assert!(brace.is_match("b.md"));
334        assert!(!brace.is_match("c.md"));
335    }
336
337    #[test]
338    fn an_invalid_glob_is_a_config_error() {
339        assert!(compile_matcher(&["a[".into()]).is_err());
340    }
341
342    // The global exclude drops a file before routing: a broken draft is never read.
343    #[test]
344    fn the_global_exclude_pre_filters_before_rules() {
345        let config = CorpusConfig {
346            rules: vec![rule(&["**/*.md"], &[])],
347        };
348        let out = run_corpus(
349            &config,
350            [
351                ("drafts/broken.md", "## Wrong\n\nx\n"),
352                ("keep.md", "## Overview\n\nok\n"),
353            ],
354            &RunOptions {
355                exclude: vec!["drafts/**".into()],
356                ..Default::default()
357            },
358        )
359        .unwrap();
360        assert_eq!(out.stats.files_matched, 1);
361        assert_eq!(out.stats.files_unmatched, 1);
362        assert_eq!(out.exit_code, 0);
363    }
364
365    #[test]
366    fn an_invalid_run_option_glob_is_a_glob_error() {
367        let config = CorpusConfig {
368            rules: vec![rule(&["**/*.md"], &[])],
369        };
370        for opts in [
371            RunOptions {
372                include: vec!["a[".into()],
373                ..Default::default()
374            },
375            RunOptions {
376                exclude: vec!["a[".into()],
377                ..Default::default()
378            },
379        ] {
380            let Err(e) = run_corpus(&config, [("a.md", "x")], &opts) else {
381                panic!("expected a GlobError")
382            };
383            assert_eq!(e.glob, "a[");
384        }
385    }
386}