1pub mod colocated_test;
2pub mod config;
3pub mod coverage;
4pub mod lint;
5
6use std::path::{Path, PathBuf};
7
8use clap::{Parser, Subcommand};
9
10#[derive(Parser, Debug)]
11#[command(
12 name = "testing-conventions",
13 version,
14 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
15 long_about = None,
16)]
17pub struct Cli {
18 #[command(subcommand)]
19 command: Option<Command>,
20}
21
22#[derive(Subcommand, Debug)]
23enum Command {
24 Check,
26 Unit {
28 #[command(subcommand)]
29 rule: UnitRule,
30 },
31 Integration {
33 #[command(subcommand)]
34 rule: IntegrationRule,
35 },
36}
37
38#[derive(Subcommand, Debug)]
40enum UnitRule {
41 ColocatedTest {
43 path: PathBuf,
45 #[arg(long, value_enum)]
47 language: colocated_test::Language,
48 #[arg(long, default_value = "testing-conventions.toml")]
51 config: PathBuf,
52 },
53 Coverage {
55 path: PathBuf,
57 #[arg(long, value_enum)]
59 language: colocated_test::Language,
60 #[arg(long, default_value = "testing-conventions.toml")]
65 config: PathBuf,
66 },
67}
68
69#[derive(Subcommand, Debug)]
72enum IntegrationRule {
73 Lint {
75 path: PathBuf,
77 #[arg(long, value_enum)]
79 language: colocated_test::Language,
80 #[arg(long, default_value = "testing-conventions.toml")]
83 config: PathBuf,
84 },
85}
86
87pub fn run<I, T>(args: I) -> anyhow::Result<i32>
88where
89 I: IntoIterator<Item = T>,
90 T: Into<std::ffi::OsString> + Clone,
91{
92 let cli = Cli::try_parse_from(args)?;
93 match cli.command {
94 Some(Command::Check) | None => Ok(0),
98 Some(Command::Unit { rule }) => match rule {
99 UnitRule::ColocatedTest {
100 path,
101 language,
102 config,
103 } => run_unit_colocated_test(&path, language, &config),
104 UnitRule::Coverage {
105 path,
106 language,
107 config,
108 } => run_unit_coverage(&path, language, &config),
109 },
110 Some(Command::Integration { rule }) => match rule {
111 IntegrationRule::Lint {
112 path,
113 language,
114 config,
115 } => run_integration_lint(&path, language, &config),
116 },
117 }
118}
119
120fn run_unit_colocated_test(
126 root: &Path,
127 language: colocated_test::Language,
128 config_path: &Path,
129) -> anyhow::Result<i32> {
130 let exempt = colocated_test_exemptions(root, language, config_path)?;
131 let orphans = colocated_test::missing_unit_tests(root, language, &exempt)?;
132 if orphans.is_empty() {
133 return Ok(0);
134 }
135 for orphan in &orphans {
136 eprintln!("missing colocated unit test: {}", orphan.display());
137 }
138 eprintln!(
139 "error: {} source file(s) missing a colocated unit test \
140 (add a colocated test, or an `exempt` entry with a reason)",
141 orphans.len()
142 );
143 Ok(1)
144}
145
146fn colocated_test_exemptions(
150 root: &Path,
151 language: colocated_test::Language,
152 config_path: &Path,
153) -> anyhow::Result<std::collections::BTreeSet<String>> {
154 if !config_path.exists() {
155 return Ok(std::collections::BTreeSet::new());
156 }
157 let config = config::load_config(config_path)?;
158 config::resolve_exempt(
159 root,
160 config.exemptions(language),
161 config::Rule::ColocatedTest,
162 )
163}
164
165fn run_unit_coverage(
176 root: &Path,
177 language: colocated_test::Language,
178 config_path: &Path,
179) -> anyhow::Result<i32> {
180 let config = if config_path.exists() {
181 config::load_config(config_path)?
182 } else {
183 config::Config::default()
184 };
185 let outcome = match language {
186 colocated_test::Language::Python => {
187 let python = config.python.unwrap_or_default();
188 let coverage = python.coverage.unwrap_or_default();
189 let thresholds = coverage::Thresholds {
190 fail_under: coverage.fail_under,
191 branch: coverage.branch,
192 };
193 let omit: Vec<String> =
194 config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
195 .into_iter()
196 .collect();
197 coverage::measure(root, thresholds, &omit)?
198 }
199 colocated_test::Language::TypeScript => {
200 let typescript = config.typescript.unwrap_or_default();
201 let coverage = typescript.coverage.unwrap_or_default();
202 let thresholds = coverage::TypeScriptThresholds {
203 lines: coverage.lines,
204 branches: coverage.branches,
205 functions: coverage.functions,
206 statements: coverage.statements,
207 };
208 let exclude: Vec<String> =
209 config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
210 .into_iter()
211 .collect();
212 coverage::measure_typescript(root, thresholds, &exclude)?
213 }
214 };
215 match outcome {
216 coverage::Outcome::Pass => Ok(0),
217 coverage::Outcome::Fail(reason) => {
218 eprintln!("error: coverage check failed — {reason}");
219 Ok(1)
220 }
221 }
222}
223
224fn run_integration_lint(
228 root: &Path,
229 language: colocated_test::Language,
230 config_path: &Path,
231) -> anyhow::Result<i32> {
232 match language {
233 colocated_test::Language::Python => {}
234 colocated_test::Language::TypeScript => {
235 anyhow::bail!("`integration lint` supports `--language python` only for now")
236 }
237 }
238 let waived = lint_waivers(root, language, config_path)?;
239 let violations: Vec<lint::Violation> = lint::find_violations(root)?
240 .into_iter()
241 .filter(|v| !is_waived(v, root, &waived))
242 .collect();
243 if violations.is_empty() {
244 return Ok(0);
245 }
246 for v in &violations {
247 eprintln!(
248 "{}:{}: {} — {}",
249 v.file.display(),
250 v.line,
251 v.rule,
252 v.message
253 );
254 }
255 eprintln!("error: {} lint violation(s)", violations.len());
256 Ok(1)
257}
258
259fn lint_waivers(
263 root: &Path,
264 language: colocated_test::Language,
265 config_path: &Path,
266) -> anyhow::Result<std::collections::BTreeSet<String>> {
267 if !config_path.exists() {
268 return Ok(std::collections::BTreeSet::new());
269 }
270 let config = config::load_config(config_path)?;
271 config::resolve_exempt(
272 root,
273 config.exemptions(language),
274 config::Rule::NoConstantPatch,
275 )
276}
277
278fn is_waived(
280 violation: &lint::Violation,
281 root: &Path,
282 waived: &std::collections::BTreeSet<String>,
283) -> bool {
284 violation.rule == "no-constant-patch"
285 && violation
286 .file
287 .strip_prefix(root)
288 .ok()
289 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
290 .is_some_and(|rel| waived.contains(&rel))
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn no_args_returns_ok_zero() {
299 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
300 }
301
302 #[test]
303 fn check_returns_ok_zero() {
304 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
305 }
306
307 #[test]
308 fn unknown_flag_errors() {
309 assert!(run(["testing-conventions", "--bogus"]).is_err());
310 }
311
312 #[test]
313 fn help_flag_returns_clap_display_help() {
314 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
315 let clap_err = err
316 .downcast_ref::<clap::Error>()
317 .expect("error should be a clap::Error");
318 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
319 }
320
321 #[test]
322 fn version_flag_returns_clap_display_version() {
323 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
324 let clap_err = err
325 .downcast_ref::<clap::Error>()
326 .expect("error should be a clap::Error");
327 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
328 }
329}