use std::borrow::Cow;
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use crate::contract::Contract;
use crate::finding::{Finding, FindingLevel};
use crate::validate::validate;
pub struct CorpusRule {
pub include: Vec<String>,
pub exclude: Vec<String>,
pub contract: Contract,
pub name: Option<String>,
}
#[derive(Default)]
pub struct CorpusConfig {
pub rules: Vec<CorpusRule>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunStats {
pub files_scanned: usize,
pub files_matched: usize,
pub files_unmatched: usize,
pub matched_by_rule: Vec<usize>,
}
#[derive(Debug)]
pub struct RunOutcome {
pub findings: Vec<Finding>,
pub exit_code: i32,
pub stats: RunStats,
}
#[derive(Default)]
pub struct RunOptions {
pub include: Vec<String>,
pub exclude: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
#[error("invalid glob '{glob}': {reason}")]
pub struct GlobError {
pub glob: String,
pub reason: String,
}
pub(crate) fn compile_matcher(globs: &[String]) -> Result<GlobSet, GlobError> {
let mut builder = GlobSetBuilder::new();
for glob in globs {
let compiled = GlobBuilder::new(glob)
.literal_separator(true)
.build()
.map_err(|e| GlobError {
glob: glob.clone(),
reason: e.to_string(),
})?;
builder.add(compiled);
}
builder.build().map_err(|e| GlobError {
glob: globs.join(", "),
reason: e.to_string(),
})
}
struct CompiledRule {
include: GlobSet,
exclude: Option<GlobSet>,
}
fn compile(config: &CorpusConfig) -> Result<Vec<CompiledRule>, GlobError> {
config
.rules
.iter()
.map(|r| {
Ok(CompiledRule {
include: compile_matcher(&r.include)?,
exclude: if r.exclude.is_empty() {
None
} else {
Some(compile_matcher(&r.exclude)?)
},
})
})
.collect()
}
fn first_matching_rule(rules: &[CompiledRule], path: &str) -> Option<usize> {
rules.iter().position(|r| {
r.include.is_match(path) && !r.exclude.as_ref().is_some_and(|e| e.is_match(path))
})
}
pub fn run_corpus<'a>(
config: &CorpusConfig,
files: impl IntoIterator<Item = (&'a str, &'a str)>,
opts: &RunOptions,
) -> Result<RunOutcome, GlobError> {
run_lazy(
config,
files
.into_iter()
.map(|(path, source)| (path, move || Ok(Cow::Borrowed(source)))),
opts,
)
}
pub(crate) fn run_lazy<'a, E, F>(
config: &CorpusConfig,
files: impl IntoIterator<Item = (&'a str, F)>,
opts: &RunOptions,
) -> Result<RunOutcome, E>
where
E: From<GlobError>,
F: FnOnce() -> Result<Cow<'a, str>, E>,
{
let rules = compile(config)?;
let include = if opts.include.is_empty() {
None
} else {
Some(compile_matcher(&opts.include)?)
};
let exclude = if opts.exclude.is_empty() {
None
} else {
Some(compile_matcher(&opts.exclude)?)
};
let mut findings = Vec::new();
let mut matched_by_rule = vec![0usize; config.rules.len()];
let mut files_scanned = 0usize;
let mut files_matched = 0usize;
for (path, source) in files {
files_scanned += 1;
if exclude.as_ref().is_some_and(|e| e.is_match(path)) {
continue;
}
if include.as_ref().is_some_and(|i| !i.is_match(path)) {
continue;
}
let Some(idx) = first_matching_rule(&rules, path) else {
continue;
};
matched_by_rule[idx] += 1;
files_matched += 1;
findings.extend(validate(&source()?, &config.rules[idx].contract, path));
}
let has_error = findings.iter().any(|f| f.level == FindingLevel::Error);
Ok(RunOutcome {
findings,
exit_code: i32::from(has_error),
stats: RunStats {
files_scanned,
files_matched,
files_unmatched: files_scanned - files_matched,
matched_by_rule,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contract::{Contract, LevelOpts, section, sections};
fn overview_contract() -> Contract {
Contract::new().body(sections(LevelOpts::default(), vec![section("Overview")]))
}
fn rule(include: &[&str], exclude: &[&str]) -> CorpusRule {
CorpusRule {
include: include.iter().map(|s| s.to_string()).collect(),
exclude: exclude.iter().map(|s| s.to_string()).collect(),
contract: overview_contract(),
name: None,
}
}
#[test]
fn routes_by_glob_and_returns_a_ci_exit_code() {
let config = CorpusConfig {
rules: vec![rule(&["**/*.md"], &[])],
};
let files = [
("docs/bad.md", "## Wrong\n\nprose\n"),
("notes.txt", "not markdown"),
];
let out = run_corpus(&config, files, &RunOptions::default()).unwrap();
assert_eq!(out.exit_code, 1);
assert_eq!(out.findings.len(), 1);
assert_eq!(out.findings[0].path, "docs/bad.md");
assert_eq!(
out.stats,
RunStats {
files_scanned: 2,
files_matched: 1,
files_unmatched: 1,
matched_by_rule: vec![1],
}
);
}
#[test]
fn clean_corpus_exits_zero() {
let config = CorpusConfig {
rules: vec![rule(&["**/*.md"], &[])],
};
let out = run_corpus(
&config,
[("a.md", "## Overview\n\nfine\n")],
&RunOptions::default(),
)
.unwrap();
assert_eq!(out.exit_code, 0);
assert_eq!(out.findings, vec![]);
}
#[test]
fn first_match_routing_is_by_rule_order() {
let config = CorpusConfig {
rules: vec![rule(&["**/A-*.md"], &[]), rule(&["**/*.md"], &[])],
};
let files = [
("A-1.md", "## Overview\n\nok\n"),
("b.md", "## Overview\n\nok\n"),
];
let out = run_corpus(&config, files, &RunOptions::default()).unwrap();
assert_eq!(out.stats.matched_by_rule, vec![1, 1]);
}
#[test]
fn per_rule_exclude_and_global_pre_filter_narrow() {
let config = CorpusConfig {
rules: vec![rule(&["**/*.md"], &["**/skip-*.md"])],
};
let out = run_corpus(
&config,
[("skip-1.md", "x"), ("keep.md", "## Overview\n\nok\n")],
&RunOptions::default(),
)
.unwrap();
assert_eq!(out.stats.files_matched, 1);
let out = run_corpus(
&config,
[
("keep.md", "## Overview\n\nok\n"),
("docs/keep.md", "## Overview\n\nok\n"),
],
&RunOptions {
include: vec!["docs/**".into()],
..Default::default()
},
)
.unwrap();
assert_eq!(out.stats.files_matched, 1);
}
#[test]
fn glob_semantics_match_picomatch() {
let m = compile_matcher(&["**/*.md".into()]).unwrap();
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();
assert!(single.is_match("docs/a.md"));
assert!(!single.is_match("docs/deep/a.md")); let brace = compile_matcher(&["**/{a,b}.md".into()]).unwrap();
assert!(brace.is_match("x/a.md"));
assert!(brace.is_match("b.md"));
assert!(!brace.is_match("c.md"));
}
#[test]
fn an_invalid_glob_is_a_config_error() {
assert!(compile_matcher(&["a[".into()]).is_err());
}
#[test]
fn the_global_exclude_pre_filters_before_rules() {
let config = CorpusConfig {
rules: vec![rule(&["**/*.md"], &[])],
};
let out = run_corpus(
&config,
[
("drafts/broken.md", "## Wrong\n\nx\n"),
("keep.md", "## Overview\n\nok\n"),
],
&RunOptions {
exclude: vec!["drafts/**".into()],
..Default::default()
},
)
.unwrap();
assert_eq!(out.stats.files_matched, 1);
assert_eq!(out.stats.files_unmatched, 1);
assert_eq!(out.exit_code, 0);
}
#[test]
fn an_invalid_run_option_glob_is_a_glob_error() {
let config = CorpusConfig {
rules: vec![rule(&["**/*.md"], &[])],
};
for opts in [
RunOptions {
include: vec!["a[".into()],
..Default::default()
},
RunOptions {
exclude: vec!["a[".into()],
..Default::default()
},
] {
let Err(e) = run_corpus(&config, [("a.md", "x")], &opts) else {
panic!("expected a GlobError")
};
assert_eq!(e.glob, "a[");
}
}
}