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>,
}
#[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 exempt: Vec<Exemption>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(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: 85,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(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: 80,
branches: 75,
functions: 80,
statements: 80,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RustCoverage {
pub regions: u8,
pub lines: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Rule {
ColocatedTest,
Coverage,
NoConstantPatch,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Exemption {
pub path: String,
pub rules: Vec<Rule>,
pub reason: String,
}
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)
}
}
}
fn validate(&self) -> Result<()> {
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
);
}
}
}
Ok(())
}
}
pub fn resolve_exempt(
root: &Path,
exemptions: &[Exemption],
rule: Rule,
) -> Result<BTreeSet<String>> {
let mut paths = BTreeSet::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()
);
}
paths.insert(entry.path.replace('\\', "/"));
}
Ok(paths)
}
#[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_reasonable_floor() {
assert_eq!(
PythonCoverage::default(),
PythonCoverage {
branch: true,
fail_under: 85,
}
);
}
#[test]
fn default_typescript_coverage_matches_internals() {
assert_eq!(
TypeScriptCoverage::default(),
TypeScriptCoverage {
lines: 80,
branches: 75,
functions: 80,
statements: 80,
}
);
}
#[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\", \"coverage\"]\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, Rule::Coverage]);
}
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(),
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}");
}
}