use std::collections::BTreeSet;
use std::path::Path;
use anyhow::{bail, Context, Result};
use serde::Deserialize;
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub python: Option<PythonConfig>,
pub typescript: Option<TypeScriptConfig>,
pub rust: Option<RustConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PythonConfig {
pub coverage: Option<PythonCoverage>,
#[serde(default)]
pub exempt: Vec<Exemption>,
pub build_command: Option<String>,
#[serde(default)]
pub reason: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TypeScriptConfig {
pub coverage: Option<TypeScriptCoverage>,
#[serde(default)]
pub exempt: Vec<Exemption>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RustConfig {
pub coverage: Option<RustCoverage>,
#[serde(default)]
pub features: Vec<String>,
#[serde(default)]
pub exempt: Vec<Exemption>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PythonCoverage {
pub branch: bool,
pub fail_under: u8,
}
impl Default for PythonCoverage {
fn default() -> Self {
Self {
branch: true,
fail_under: 100,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TypeScriptCoverage {
pub lines: u8,
pub branches: u8,
pub functions: u8,
pub statements: u8,
}
impl Default for TypeScriptCoverage {
fn default() -> Self {
Self {
lines: 100,
branches: 100,
functions: 100,
statements: 100,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RustCoverage {
pub regions: Option<u8>,
pub lines: u8,
pub functions: Option<u8>,
pub branch: Option<u8>,
}
impl Default for RustCoverage {
fn default() -> Self {
Self {
regions: None,
lines: 100,
functions: None,
branch: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Rule {
ColocatedTest,
Coverage,
CoChange,
NoMonkeypatch,
NoInlinePatch,
NoEnvironMutation,
NoConstantPatch,
NoFirstPartyPatch,
NoOutOfModuleCall,
NoOutOfModuleImport,
NoFirstPartyDouble,
UnmockedCollaborator,
UntypedMock,
NoFirstPartyMock,
Mutation,
}
impl Rule {
pub fn is_line_scopable(self) -> bool {
matches!(self, Rule::Coverage | Rule::Mutation)
}
pub fn id(self) -> &'static str {
match self {
Rule::ColocatedTest => "colocated-test",
Rule::Coverage => "coverage",
Rule::CoChange => "co-change",
Rule::NoMonkeypatch => "no-monkeypatch",
Rule::NoInlinePatch => "no-inline-patch",
Rule::NoEnvironMutation => "no-environ-mutation",
Rule::NoConstantPatch => "no-constant-patch",
Rule::NoFirstPartyPatch => "no-first-party-patch",
Rule::NoOutOfModuleCall => "no-out-of-module-call",
Rule::NoOutOfModuleImport => "no-out-of-module-import",
Rule::NoFirstPartyDouble => "no-first-party-double",
Rule::UnmockedCollaborator => "unmocked-collaborator",
Rule::UntypedMock => "untyped-mock",
Rule::NoFirstPartyMock => "no-first-party-mock",
Rule::Mutation => "mutation",
}
}
pub fn from_id(id: &str) -> Option<Rule> {
[
Rule::ColocatedTest,
Rule::Coverage,
Rule::CoChange,
Rule::NoMonkeypatch,
Rule::NoInlinePatch,
Rule::NoEnvironMutation,
Rule::NoConstantPatch,
Rule::NoFirstPartyPatch,
Rule::NoOutOfModuleCall,
Rule::NoOutOfModuleImport,
Rule::NoFirstPartyDouble,
Rule::UnmockedCollaborator,
Rule::UntypedMock,
Rule::NoFirstPartyMock,
Rule::Mutation,
]
.into_iter()
.find(|rule| rule.id() == id)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineSpec {
Single(u32),
Range(u32, u32),
}
impl LineSpec {
fn parse_str(s: &str) -> Result<LineSpec, String> {
let parse = |part: &str| {
part.trim()
.parse::<u32>()
.map_err(|_| format!("`{s}` is not a line number or \"start-end\" range"))
};
match s.split_once('-') {
Some((start, end)) => Ok(LineSpec::Range(parse(start)?, parse(end)?)),
None => Ok(LineSpec::Single(parse(s)?)),
}
}
fn extend_into(self, set: &mut BTreeSet<u32>) {
match self {
LineSpec::Single(n) => {
set.insert(n);
}
LineSpec::Range(start, end) => {
for n in start..=end {
set.insert(n);
}
}
}
}
}
impl<'de> Deserialize<'de> for LineSpec {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct SpecVisitor;
impl serde::de::Visitor<'_> for SpecVisitor {
type Value = LineSpec;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a line number or a \"start-end\" range string")
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> std::result::Result<LineSpec, E> {
u32::try_from(v)
.map(LineSpec::Single)
.map_err(|_| E::custom(format!("line number {v} is out of range")))
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> std::result::Result<LineSpec, E> {
u64::try_from(v)
.map_err(|_| E::custom(format!("line number {v} must be positive")))
.and_then(|v| self.visit_u64(v))
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<LineSpec, E> {
LineSpec::parse_str(v).map_err(E::custom)
}
}
deserializer.deserialize_any(SpecVisitor)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Exemption {
pub path: String,
pub rules: Vec<Rule>,
#[serde(default)]
pub lines: Vec<LineSpec>,
pub reason: String,
}
impl Exemption {
pub fn line_set(&self) -> BTreeSet<u32> {
let mut set = BTreeSet::new();
for spec in &self.lines {
spec.extend_into(&mut set);
}
set
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LineScope {
WholeFile,
Lines(BTreeSet<u32>),
}
impl LineScope {
fn merged_with(self, other: LineScope) -> LineScope {
match (self, other) {
(LineScope::WholeFile, _) | (_, LineScope::WholeFile) => LineScope::WholeFile,
(LineScope::Lines(mut a), LineScope::Lines(b)) => {
a.extend(b);
LineScope::Lines(a)
}
}
}
}
pub fn load_config(path: impl AsRef<Path>) -> Result<Config> {
let path = path.as_ref();
let contents = std::fs::read_to_string(path)
.with_context(|| format!("reading config file `{}`", path.display()))?;
let config: Config = toml::from_str(&contents)
.with_context(|| format!("parsing config file `{}`", path.display()))?;
config
.validate()
.with_context(|| format!("validating config file `{}`", path.display()))?;
Ok(config)
}
impl Config {
pub fn exemptions(&self, language: crate::colocated_test::Language) -> &[Exemption] {
match language {
crate::colocated_test::Language::Python => {
self.python.as_ref().map_or(&[], |c| &c.exempt)
}
crate::colocated_test::Language::TypeScript => {
self.typescript.as_ref().map_or(&[], |c| &c.exempt)
}
crate::colocated_test::Language::Rust => self.rust_exemptions(),
}
}
pub fn rust_exemptions(&self) -> &[Exemption] {
self.rust.as_ref().map_or(&[], |c| &c.exempt)
}
fn validate(&self) -> Result<()> {
if let Some(python) = &self.python {
if python.build_command.is_some() && python.reason.trim().is_empty() {
bail!(
"[python].build_command has an empty reason — say why the manifest can't \
express this build (maturin/PyO3's PEP 517 backend has no pre-build shell \
hook; npm's prepare/postinstall and Cargo's build.rs already do)"
);
}
}
let tables = [
("python", self.python.as_ref().map(|c| &c.exempt)),
("typescript", self.typescript.as_ref().map(|c| &c.exempt)),
("rust", self.rust.as_ref().map(|c| &c.exempt)),
];
for (table, exempt) in tables.into_iter().filter_map(|(t, e)| e.map(|e| (t, e))) {
for entry in exempt {
if entry.rules.is_empty() {
bail!(
"[{table}].exempt entry for `{}` names no rules — set \
`rules = [\"colocated-test\"]` and/or `\"coverage\"`",
entry.path
);
}
if entry.reason.trim().is_empty() {
bail!(
"[{table}].exempt entry for `{}` has an empty reason — \
every exemption must say why the file is exempt",
entry.path
);
}
let has_scopable = entry.rules.iter().any(|rule| rule.is_line_scopable());
let has_whole_file = entry.rules.iter().any(|rule| !rule.is_line_scopable());
if entry.lines.is_empty() {
if has_scopable {
let rule = entry.rules.iter().find(|r| r.is_line_scopable()).unwrap();
bail!(
"[{table}].exempt entry for `{}` names `{}` but lists no `lines` — \
a `coverage` / `mutation` exemption must name the exact lines it \
covers (whole-file exemptions are for presence / lint rules only)",
entry.path,
rule.id()
);
}
} else {
if has_whole_file {
let rule = entry.rules.iter().find(|r| !r.is_line_scopable()).unwrap();
bail!(
"[{table}].exempt entry for `{}` has `lines` alongside rule \
`{}` — line-scoped exemptions apply only to `coverage` and \
`mutation`; move the whole-file rules to a separate entry",
entry.path,
rule.id()
);
}
for spec in &entry.lines {
let invalid = match spec {
LineSpec::Single(n) => *n == 0,
LineSpec::Range(start, end) => *start == 0 || start > end,
};
if invalid {
bail!(
"[{table}].exempt entry for `{}` has an invalid line spec — \
line numbers are 1-based and a range's start must not exceed \
its end",
entry.path
);
}
}
}
}
}
Ok(())
}
}
pub fn resolve_exempt(
root: &Path,
exemptions: &[Exemption],
rule: Rule,
) -> Result<BTreeSet<String>> {
Ok(resolve_exempt_scoped(root, exemptions, rule)?
.into_keys()
.collect())
}
pub fn resolve_exempt_scoped(
root: &Path,
exemptions: &[Exemption],
rule: Rule,
) -> Result<std::collections::BTreeMap<String, LineScope>> {
let mut scopes: std::collections::BTreeMap<String, LineScope> =
std::collections::BTreeMap::new();
for entry in exemptions {
if !entry.rules.contains(&rule) {
continue;
}
if !root.join(&entry.path).is_file() {
bail!(
"exempt entry `{}` matches no file under `{}` — remove the stale \
entry or fix the path",
entry.path,
root.display()
);
}
let key = entry.path.replace('\\', "/");
let scope = if entry.lines.is_empty() {
LineScope::WholeFile
} else {
LineScope::Lines(entry.line_set())
};
let merged = match scopes.remove(&key) {
Some(existing) => existing.merged_with(scope),
None => scope,
};
scopes.insert(key, merged);
}
Ok(scopes)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
fn parse(toml_src: &str) -> Result<Config> {
let config: Config = toml::from_str(toml_src)?;
config.validate()?;
Ok(config)
}
#[test]
fn an_exemption_with_no_rules_is_rejected() {
let err = parse(
"[python]\ncoverage = { branch = true, fail_under = 100 }\n\
[[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
)
.unwrap_err();
assert!(err.to_string().contains("names no rules"), "got: {err}");
}
#[test]
fn an_exemption_with_an_empty_reason_is_rejected() {
let err = parse(
"[python]\ncoverage = { branch = true, fail_under = 100 }\n\
[[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\nreason = \" \"\n",
)
.unwrap_err();
assert!(err.to_string().contains("empty reason"), "got: {err}");
}
#[test]
fn an_unknown_rule_is_rejected() {
assert!(parse(
"[python]\ncoverage = { branch = true, fail_under = 100 }\n\
[[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
)
.is_err());
}
#[test]
fn default_python_coverage_is_the_strict_floor() {
assert_eq!(
PythonCoverage::default(),
PythonCoverage {
branch: true,
fail_under: 100,
}
);
}
#[test]
fn default_typescript_coverage_is_the_strict_floor() {
assert_eq!(
TypeScriptCoverage::default(),
TypeScriptCoverage {
lines: 100,
branches: 100,
functions: 100,
statements: 100,
}
);
}
#[test]
fn default_rust_coverage_is_the_strict_line_floor() {
assert_eq!(
RustCoverage::default(),
RustCoverage {
regions: None,
lines: 100,
functions: None,
branch: None,
}
);
}
#[test]
fn rust_coverage_table_parses_with_regions_omitted() {
let config = parse("[rust]\ncoverage = { lines = 90 }\n").unwrap();
let coverage = config.rust.unwrap().coverage.unwrap();
assert_eq!(coverage.regions, None);
assert_eq!(coverage.lines, 90);
}
#[test]
fn a_python_build_command_with_a_reason_parses() {
let config = parse(
"[python]\nbuild_command = \"uv run maturin develop\"\n\
reason = \"maturin's PEP 517 backend has no pre-build shell hook\"\n",
)
.unwrap();
let python = config.python.unwrap();
assert_eq!(
python.build_command.as_deref(),
Some("uv run maturin develop")
);
assert_eq!(
python.reason,
"maturin's PEP 517 backend has no pre-build shell hook"
);
}
#[test]
fn a_python_build_command_with_an_empty_reason_is_rejected() {
let err = parse("[python]\nbuild_command = \"uv run maturin develop\"\nreason = \" \"\n")
.unwrap_err();
assert!(err.to_string().contains("empty reason"), "got: {err}");
}
#[test]
fn a_python_build_command_with_no_reason_is_rejected() {
let err = parse("[python]\nbuild_command = \"uv run maturin develop\"\n").unwrap_err();
assert!(err.to_string().contains("empty reason"), "got: {err}");
}
#[test]
fn a_python_table_without_a_build_command_needs_no_reason() {
let config = parse("[python]\ncoverage = { branch = true, fail_under = 90 }\n").unwrap();
let python = config.python.unwrap();
assert!(python.build_command.is_none());
assert!(python.reason.is_empty());
}
#[test]
fn a_valid_exemption_parses() {
let config = parse(
"[python]\ncoverage = { branch = true, fail_under = 100 }\n\
[[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\n\
reason = \"thin launcher\"\n",
)
.unwrap();
let exempt = &config.python.unwrap().exempt;
assert_eq!(exempt.len(), 1);
assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest]);
assert!(exempt[0].lines.is_empty());
}
#[test]
fn exemptions_reads_the_rust_table() {
let config = parse(
"[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
reason = \"generated\"\n",
)
.unwrap();
let rust = config.exemptions(crate::colocated_test::Language::Rust);
assert_eq!(rust.len(), 1);
assert_eq!(rust[0].path, "build.rs");
}
struct TempTree(std::path::PathBuf);
impl TempTree {
fn new(files: &[&str]) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"tc-exempt-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
));
for rel in files {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "x = 1\n").unwrap();
}
TempTree(root)
}
}
impl Drop for TempTree {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn exemption(path: &str, rules: &[Rule]) -> Exemption {
Exemption {
path: path.to_string(),
rules: rules.to_vec(),
lines: vec![],
reason: "deliberate".to_string(),
}
}
#[test]
fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
let exemptions = [
exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
exemption("pkg/gen.py", &[Rule::Coverage]),
exemption("loc_only.py", &[Rule::ColocatedTest]),
];
let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
assert_eq!(
coverage.into_iter().collect::<Vec<_>>(),
vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
);
let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
assert_eq!(
colocated_test.into_iter().collect::<Vec<_>>(),
vec!["cli.py".to_string(), "loc_only.py".to_string()],
);
}
#[test]
fn a_stale_exempt_path_is_an_error() {
let tree = TempTree::new(&["cli.py"]);
let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
assert!(err.to_string().contains("matches no file"), "got: {err}");
}
#[test]
fn line_specs_parse_from_ints_and_range_strings() {
let config = parse(
"[[python.exempt]]\npath = \"shim.py\"\nrules = [\"coverage\"]\n\
lines = [9, 10, \"12-13\"]\nreason = \"dead branch\"\n",
)
.unwrap();
let exempt = &config.python.unwrap().exempt[0];
assert_eq!(
exempt.lines,
vec![
LineSpec::Single(9),
LineSpec::Single(10),
LineSpec::Range(12, 13),
]
);
assert_eq!(
exempt.line_set().into_iter().collect::<Vec<_>>(),
vec![9, 10, 12, 13]
);
}
#[test]
fn a_coverage_exemption_without_lines_is_rejected() {
let err = parse(
"[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\nreason = \"gen\"\n",
)
.unwrap_err();
assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
}
#[test]
fn a_mutation_exemption_without_lines_is_rejected() {
let err = parse(
"[[rust.exempt]]\npath = \"src/lib.rs\"\nrules = [\"mutation\"]\nreason = \"eq\"\n",
)
.unwrap_err();
assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
}
#[test]
fn lines_on_a_whole_file_rule_is_rejected() {
let err = parse(
"[[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\", \"coverage\"]\n\
lines = [3]\nreason = \"shim\"\n",
)
.unwrap_err();
assert!(
err.to_string()
.contains("line-scoped exemptions apply only"),
"got: {err}"
);
}
#[test]
fn a_zero_line_is_rejected() {
let err = parse(
"[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
lines = [0]\nreason = \"x\"\n",
)
.unwrap_err();
assert!(err.to_string().contains("invalid line spec"), "got: {err}");
}
#[test]
fn a_reversed_range_is_rejected() {
let err = parse(
"[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
lines = [\"13-12\"]\nreason = \"x\"\n",
)
.unwrap_err();
assert!(err.to_string().contains("invalid line spec"), "got: {err}");
}
#[test]
fn a_non_numeric_line_spec_is_a_parse_error() {
assert!(parse(
"[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
lines = [\"oops\"]\nreason = \"x\"\n",
)
.is_err());
}
#[test]
fn resolve_scoped_distinguishes_whole_file_from_lines() {
let tree = TempTree::new(&["barrel.py", "scoped.py"]);
let exemptions = [
exemption("barrel.py", &[Rule::ColocatedTest]),
Exemption {
path: "scoped.py".to_string(),
rules: vec![Rule::Coverage],
lines: vec![LineSpec::Single(2), LineSpec::Range(4, 5)],
reason: "dead branch".to_string(),
},
];
let coverage = resolve_exempt_scoped(&tree.0, &exemptions, Rule::Coverage).unwrap();
assert_eq!(
coverage["scoped.py"],
LineScope::Lines([2, 4, 5].into_iter().collect())
);
let presence = resolve_exempt_scoped(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
assert_eq!(presence["barrel.py"], LineScope::WholeFile);
}
#[test]
fn resolve_scoped_merges_two_entries_for_one_file() {
let tree = TempTree::new(&["a.py", "b.py"]);
let line = |n: u32| Exemption {
path: "a.py".to_string(),
rules: vec![Rule::Mutation],
lines: vec![LineSpec::Single(n)],
reason: "equivalent mutant".to_string(),
};
let mutation = [line(3), line(7)];
let scopes = resolve_exempt_scoped(&tree.0, &mutation, Rule::Mutation).unwrap();
assert_eq!(
scopes["a.py"],
LineScope::Lines([3, 7].into_iter().collect())
);
let presence = [
exemption("b.py", &[Rule::ColocatedTest]),
exemption("b.py", &[Rule::ColocatedTest]),
];
let scopes = resolve_exempt_scoped(&tree.0, &presence, Rule::ColocatedTest).unwrap();
assert_eq!(scopes["b.py"], LineScope::WholeFile);
}
}