markdown_contract/runner/
corpus.rs1use std::borrow::Cow;
17
18use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
19
20use crate::contract::Contract;
21use crate::finding::{Finding, FindingLevel};
22use crate::validate::validate;
23
24pub struct CorpusRule {
27 pub include: Vec<String>,
29 pub exclude: Vec<String>,
31 pub contract: Contract,
33 pub name: Option<String>,
36}
37
38#[derive(Default)]
41pub struct CorpusConfig {
42 pub rules: Vec<CorpusRule>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct RunStats {
51 pub files_scanned: usize,
53 pub files_matched: usize,
55 pub files_unmatched: usize,
57 pub matched_by_rule: Vec<usize>,
59}
60
61#[derive(Debug)]
64pub struct RunOutcome {
65 pub findings: Vec<Finding>,
67 pub exit_code: i32,
69 pub stats: RunStats,
71}
72
73#[derive(Default)]
77pub struct RunOptions {
78 pub include: Vec<String>,
80 pub exclude: Vec<String>,
82}
83
84#[derive(Debug, thiserror::Error)]
87#[error("invalid glob '{glob}': {reason}")]
88pub struct GlobError {
89 pub glob: String,
91 pub reason: String,
93}
94
95pub(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
136fn 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
143pub 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
163pub(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 #[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 #[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 #[test]
323 fn glob_semantics_match_picomatch() {
324 let m = compile_matcher(&["**/*.md".into()]).unwrap();
325 assert!(m.is_match("a.md")); assert!(m.is_match("docs/deep/a.md")); assert!(m.is_match(".hidden.md")); 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")); 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 #[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}