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