1pub mod colocated_test;
2pub mod config;
3pub mod coverage;
4pub mod lint;
5pub mod packaging;
6pub mod ts;
7
8use std::path::{Path, PathBuf};
9
10use clap::{Parser, Subcommand};
11
12#[derive(Parser, Debug)]
13#[command(
14 name = "testing-conventions",
15 version,
16 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
17 long_about = None,
18)]
19pub struct Cli {
20 #[command(subcommand)]
21 command: Option<Command>,
22}
23
24#[derive(Subcommand, Debug)]
25enum Command {
26 Check,
28 Unit {
30 #[command(subcommand)]
31 rule: UnitRule,
32 },
33 Integration {
35 #[command(subcommand)]
36 rule: IntegrationRule,
37 },
38 Packaging {
40 path: PathBuf,
42 #[arg(long, value_enum)]
44 language: colocated_test::Language,
45 },
46}
47
48#[derive(Subcommand, Debug)]
50enum UnitRule {
51 ColocatedTest {
53 path: PathBuf,
55 #[arg(long, value_enum)]
57 language: colocated_test::Language,
58 #[arg(long, default_value = "testing-conventions.toml")]
61 config: PathBuf,
62 },
63 Coverage {
65 path: PathBuf,
67 #[arg(long, value_enum)]
69 language: colocated_test::Language,
70 #[arg(long, default_value = "testing-conventions.toml")]
75 config: PathBuf,
76 },
77}
78
79#[derive(Subcommand, Debug)]
82enum IntegrationRule {
83 Lint {
85 path: PathBuf,
87 #[arg(long, value_enum)]
89 language: colocated_test::Language,
90 #[arg(long, default_value = "testing-conventions.toml")]
93 config: PathBuf,
94 },
95}
96
97pub fn run<I, T>(args: I) -> anyhow::Result<i32>
98where
99 I: IntoIterator<Item = T>,
100 T: Into<std::ffi::OsString> + Clone,
101{
102 let cli = Cli::try_parse_from(args)?;
103 match cli.command {
104 Some(Command::Check) | None => Ok(0),
108 Some(Command::Unit { rule }) => match rule {
109 UnitRule::ColocatedTest {
110 path,
111 language,
112 config,
113 } => run_unit_colocated_test(&path, language, &config),
114 UnitRule::Coverage {
115 path,
116 language,
117 config,
118 } => run_unit_coverage(&path, language, &config),
119 },
120 Some(Command::Integration { rule }) => match rule {
121 IntegrationRule::Lint {
122 path,
123 language,
124 config,
125 } => run_integration_lint(&path, language, &config),
126 },
127 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
128 }
129}
130
131fn run_unit_colocated_test(
137 root: &Path,
138 language: colocated_test::Language,
139 config_path: &Path,
140) -> anyhow::Result<i32> {
141 let exempt = colocated_test_exemptions(root, language, config_path)?;
142 let orphans = colocated_test::missing_unit_tests(root, language, &exempt)?;
143 if orphans.is_empty() {
144 return Ok(0);
145 }
146 for orphan in &orphans {
147 eprintln!("missing colocated unit test: {}", orphan.display());
148 }
149 eprintln!(
150 "error: {} source file(s) missing a colocated unit test \
151 (add a colocated test, or an `exempt` entry with a reason)",
152 orphans.len()
153 );
154 Ok(1)
155}
156
157fn colocated_test_exemptions(
161 root: &Path,
162 language: colocated_test::Language,
163 config_path: &Path,
164) -> anyhow::Result<std::collections::BTreeSet<String>> {
165 if !config_path.exists() {
166 return Ok(std::collections::BTreeSet::new());
167 }
168 let config = config::load_config(config_path)?;
169 config::resolve_exempt(
170 root,
171 config.exemptions(language),
172 config::Rule::ColocatedTest,
173 )
174}
175
176fn run_unit_coverage(
187 root: &Path,
188 language: colocated_test::Language,
189 config_path: &Path,
190) -> anyhow::Result<i32> {
191 let config = if config_path.exists() {
192 config::load_config(config_path)?
193 } else {
194 config::Config::default()
195 };
196 let outcome = match language {
197 colocated_test::Language::Python => {
198 let python = config.python.unwrap_or_default();
199 let coverage = python.coverage.unwrap_or_default();
200 let thresholds = coverage::Thresholds {
201 fail_under: coverage.fail_under,
202 branch: coverage.branch,
203 };
204 let omit: Vec<String> =
205 config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
206 .into_iter()
207 .collect();
208 coverage::measure(root, thresholds, &omit)?
209 }
210 colocated_test::Language::TypeScript => {
211 let typescript = config.typescript.unwrap_or_default();
212 let coverage = typescript.coverage.unwrap_or_default();
213 let thresholds = coverage::TypeScriptThresholds {
214 lines: coverage.lines,
215 branches: coverage.branches,
216 functions: coverage.functions,
217 statements: coverage.statements,
218 };
219 let exclude: Vec<String> =
220 config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
221 .into_iter()
222 .collect();
223 coverage::measure_typescript(root, thresholds, &exclude)?
224 }
225 };
226 match outcome {
227 coverage::Outcome::Pass => Ok(0),
228 coverage::Outcome::Fail(reason) => {
229 eprintln!("error: coverage check failed — {reason}");
230 Ok(1)
231 }
232 }
233}
234
235fn run_integration_lint(
239 root: &Path,
240 language: colocated_test::Language,
241 config_path: &Path,
242) -> anyhow::Result<i32> {
243 let waived = lint_waivers(root, language, config_path)?;
244 let raw = match language {
245 colocated_test::Language::Python => lint::find_violations(root)?,
246 colocated_test::Language::TypeScript => ts::find_integration_violations(root)?,
247 };
248 let violations: Vec<lint::Violation> = raw
249 .into_iter()
250 .filter(|v| !is_waived(v, root, &waived))
251 .collect();
252 if violations.is_empty() {
253 return Ok(0);
254 }
255 for v in &violations {
256 eprintln!(
257 "{}:{}: {} — {}",
258 v.file.display(),
259 v.line,
260 v.rule,
261 v.message
262 );
263 }
264 eprintln!("error: {} lint violation(s)", violations.len());
265 Ok(1)
266}
267
268fn lint_waivers(
272 root: &Path,
273 language: colocated_test::Language,
274 config_path: &Path,
275) -> anyhow::Result<std::collections::BTreeSet<String>> {
276 if !config_path.exists() {
277 return Ok(std::collections::BTreeSet::new());
278 }
279 let config = config::load_config(config_path)?;
280 config::resolve_exempt(
281 root,
282 config.exemptions(language),
283 config::Rule::NoConstantPatch,
284 )
285}
286
287fn is_waived(
289 violation: &lint::Violation,
290 root: &Path,
291 waived: &std::collections::BTreeSet<String>,
292) -> bool {
293 violation.rule == "no-constant-patch"
294 && violation
295 .file
296 .strip_prefix(root)
297 .ok()
298 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
299 .is_some_and(|rel| waived.contains(&rel))
300}
301
302fn run_packaging(root: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
310 let globs = match language {
311 colocated_test::Language::Python => vec!["*_test.py".to_string()],
312 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
313 };
314 let offenders = packaging::scan(root, &globs)?;
315 if offenders.is_empty() {
316 return Ok(0);
317 }
318 for offender in &offenders {
319 eprintln!("test file in built artifact: {}", offender.display());
320 }
321 eprintln!(
322 "error: {} test file(s) present in the built artifact \
323 (they must be excluded from packaging)",
324 offenders.len()
325 );
326 Ok(1)
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn no_args_returns_ok_zero() {
335 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
336 }
337
338 #[test]
339 fn check_returns_ok_zero() {
340 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
341 }
342
343 #[test]
344 fn unknown_flag_errors() {
345 assert!(run(["testing-conventions", "--bogus"]).is_err());
346 }
347
348 #[test]
349 fn help_flag_returns_clap_display_help() {
350 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
351 let clap_err = err
352 .downcast_ref::<clap::Error>()
353 .expect("error should be a clap::Error");
354 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
355 }
356
357 #[test]
358 fn version_flag_returns_clap_display_version() {
359 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
360 let clap_err = err
361 .downcast_ref::<clap::Error>()
362 .expect("error should be a clap::Error");
363 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
364 }
365}