pub mod colocated_test;
pub mod config;
pub mod coverage;
pub mod isolation;
pub mod lint;
pub mod packaging;
pub mod ts;
pub mod violation;
use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(
name = "testing-conventions",
version,
about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
long_about = None,
)]
pub struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand, Debug)]
enum Command {
Check,
Unit {
#[command(subcommand)]
rule: UnitRule,
},
Integration {
#[command(subcommand)]
rule: IntegrationRule,
},
Packaging {
path: PathBuf,
#[arg(long, value_enum)]
language: colocated_test::Language,
},
}
#[derive(Subcommand, Debug)]
enum UnitRule {
ColocatedTest {
path: PathBuf,
#[arg(long, value_enum)]
language: colocated_test::Language,
#[arg(long, default_value = "testing-conventions.toml")]
config: PathBuf,
},
Coverage {
path: PathBuf,
#[arg(long, value_enum)]
language: colocated_test::Language,
#[arg(long, default_value = "testing-conventions.toml")]
config: PathBuf,
},
Isolation {
path: PathBuf,
#[arg(long, value_enum)]
language: isolation::Language,
},
}
#[derive(Subcommand, Debug)]
enum IntegrationRule {
Lint {
path: PathBuf,
#[arg(long, value_enum)]
language: colocated_test::Language,
#[arg(long, default_value = "testing-conventions.toml")]
config: PathBuf,
},
}
pub fn run<I, T>(args: I) -> anyhow::Result<i32>
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let cli = Cli::try_parse_from(args)?;
match cli.command {
Some(Command::Check) | None => Ok(0),
Some(Command::Unit { rule }) => match rule {
UnitRule::ColocatedTest {
path,
language,
config,
} => run_unit_colocated_test(&path, language, &config),
UnitRule::Coverage {
path,
language,
config,
} => run_unit_coverage(&path, language, &config),
UnitRule::Isolation { path, language } => run_unit_isolation(&path, language),
},
Some(Command::Integration { rule }) => match rule {
IntegrationRule::Lint {
path,
language,
config,
} => run_integration_lint(&path, language, &config),
},
Some(Command::Packaging { path, language }) => run_packaging(&path, language),
}
}
fn run_unit_colocated_test(
root: &Path,
language: colocated_test::Language,
config_path: &Path,
) -> anyhow::Result<i32> {
let exempt = colocated_test_exemptions(root, language, config_path)?;
let orphans = colocated_test::missing_unit_tests(root, language, &exempt)?;
if orphans.is_empty() {
return Ok(0);
}
for orphan in &orphans {
eprintln!("missing colocated unit test: {}", orphan.display());
}
eprintln!(
"error: {} source file(s) missing a colocated unit test \
(add a colocated test, or an `exempt` entry with a reason)",
orphans.len()
);
Ok(1)
}
fn colocated_test_exemptions(
root: &Path,
language: colocated_test::Language,
config_path: &Path,
) -> anyhow::Result<std::collections::BTreeSet<String>> {
if !config_path.exists() {
return Ok(std::collections::BTreeSet::new());
}
let config = config::load_config(config_path)?;
config::resolve_exempt(
root,
config.exemptions(language),
config::Rule::ColocatedTest,
)
}
fn run_unit_coverage(
root: &Path,
language: colocated_test::Language,
config_path: &Path,
) -> anyhow::Result<i32> {
let config = if config_path.exists() {
config::load_config(config_path)?
} else {
config::Config::default()
};
let outcome = match language {
colocated_test::Language::Python => {
let python = config.python.unwrap_or_default();
let coverage = python.coverage.unwrap_or_default();
let thresholds = coverage::Thresholds {
fail_under: coverage.fail_under,
branch: coverage.branch,
};
let omit: Vec<String> =
config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
.into_iter()
.collect();
coverage::measure(root, thresholds, &omit)?
}
colocated_test::Language::TypeScript => {
let typescript = config.typescript.unwrap_or_default();
let coverage = typescript.coverage.unwrap_or_default();
let thresholds = coverage::TypeScriptThresholds {
lines: coverage.lines,
branches: coverage.branches,
functions: coverage.functions,
statements: coverage.statements,
};
let exclude: Vec<String> =
config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
.into_iter()
.collect();
coverage::measure_typescript(root, thresholds, &exclude)?
}
};
match outcome {
coverage::Outcome::Pass => Ok(0),
coverage::Outcome::Fail(reason) => {
eprintln!("error: coverage check failed — {reason}");
Ok(1)
}
}
}
fn run_unit_isolation(root: &Path, language: isolation::Language) -> anyhow::Result<i32> {
match language {
isolation::Language::Rust => {}
}
let violations = isolation::find_violations(root)?;
if violations.is_empty() {
return Ok(0);
}
for v in &violations {
eprintln!(
"{}:{}: {} — {}",
v.file.display(),
v.line,
v.rule,
v.message
);
}
eprintln!("error: {} isolation violation(s)", violations.len());
Ok(1)
}
fn run_integration_lint(
root: &Path,
language: colocated_test::Language,
config_path: &Path,
) -> anyhow::Result<i32> {
let waived = lint_waivers(root, language, config_path)?;
let raw = match language {
colocated_test::Language::Python => lint::find_violations(root)?,
colocated_test::Language::TypeScript => ts::find_integration_violations(root)?,
};
let violations: Vec<lint::Violation> = raw
.into_iter()
.filter(|v| !is_waived(v, root, &waived))
.collect();
if violations.is_empty() {
return Ok(0);
}
for v in &violations {
eprintln!(
"{}:{}: {} — {}",
v.file.display(),
v.line,
v.rule,
v.message
);
}
eprintln!("error: {} lint violation(s)", violations.len());
Ok(1)
}
fn lint_waivers(
root: &Path,
language: colocated_test::Language,
config_path: &Path,
) -> anyhow::Result<std::collections::BTreeSet<String>> {
if !config_path.exists() {
return Ok(std::collections::BTreeSet::new());
}
let config = config::load_config(config_path)?;
config::resolve_exempt(
root,
config.exemptions(language),
config::Rule::NoConstantPatch,
)
}
fn is_waived(
violation: &lint::Violation,
root: &Path,
waived: &std::collections::BTreeSet<String>,
) -> bool {
violation.rule == "no-constant-patch"
&& violation
.file
.strip_prefix(root)
.ok()
.map(|rel| rel.to_string_lossy().replace('\\', "/"))
.is_some_and(|rel| waived.contains(&rel))
}
fn run_packaging(root: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
let globs = match language {
colocated_test::Language::Python => vec!["*_test.py".to_string()],
colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
};
let offenders = packaging::scan(root, &globs)?;
if offenders.is_empty() {
return Ok(0);
}
for offender in &offenders {
eprintln!("test file in built artifact: {}", offender.display());
}
eprintln!(
"error: {} test file(s) present in the built artifact \
(they must be excluded from packaging)",
offenders.len()
);
Ok(1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_args_returns_ok_zero() {
assert_eq!(run(["testing-conventions"]).unwrap(), 0);
}
#[test]
fn check_returns_ok_zero() {
assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
}
#[test]
fn unknown_flag_errors() {
assert!(run(["testing-conventions", "--bogus"]).is_err());
}
#[test]
fn help_flag_returns_clap_display_help() {
let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
let clap_err = err
.downcast_ref::<clap::Error>()
.expect("error should be a clap::Error");
assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
}
#[test]
fn version_flag_returns_clap_display_version() {
let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
let clap_err = err
.downcast_ref::<clap::Error>()
.expect("error should be a clap::Error");
assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
}
}