1pub mod config;
2pub mod coverage;
3pub mod lint;
4pub mod location;
5
6use std::path::{Path, PathBuf};
7
8use anyhow::Context;
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}
38
39#[derive(Subcommand, Debug)]
41enum UnitRule {
42 Location {
44 path: PathBuf,
46 #[arg(long, value_enum)]
48 language: location::Language,
49 #[arg(long, default_value = "testing-conventions.toml")]
52 config: PathBuf,
53 },
54 Coverage {
56 path: PathBuf,
58 #[arg(long, value_enum)]
60 language: location::Language,
61 #[arg(long, default_value = "testing-conventions.toml")]
63 config: PathBuf,
64 },
65}
66
67#[derive(Subcommand, Debug)]
70enum IntegrationRule {
71 Lint {
73 path: PathBuf,
75 #[arg(long, value_enum)]
77 language: location::Language,
78 },
79}
80
81pub fn run<I, T>(args: I) -> anyhow::Result<i32>
82where
83 I: IntoIterator<Item = T>,
84 T: Into<std::ffi::OsString> + Clone,
85{
86 let cli = Cli::try_parse_from(args)?;
87 match cli.command {
88 Some(Command::Check) | None => Ok(0),
92 Some(Command::Unit { rule }) => match rule {
93 UnitRule::Location {
94 path,
95 language,
96 config,
97 } => run_unit_location(&path, language, &config),
98 UnitRule::Coverage {
99 path,
100 language,
101 config,
102 } => run_unit_coverage(&path, language, &config),
103 },
104 Some(Command::Integration { rule }) => match rule {
105 IntegrationRule::Lint { path, language } => run_integration_lint(&path, language),
106 },
107 }
108}
109
110fn run_unit_location(
116 root: &Path,
117 language: location::Language,
118 config_path: &Path,
119) -> anyhow::Result<i32> {
120 let exempt = location_exemptions(root, language, config_path)?;
121 let orphans = location::missing_unit_tests(root, language, &exempt)?;
122 if orphans.is_empty() {
123 return Ok(0);
124 }
125 for orphan in &orphans {
126 eprintln!("missing colocated unit test: {}", orphan.display());
127 }
128 eprintln!(
129 "error: {} source file(s) missing a colocated unit test \
130 (add a colocated test, or an `exempt` entry with a reason)",
131 orphans.len()
132 );
133 Ok(1)
134}
135
136fn location_exemptions(
140 root: &Path,
141 language: location::Language,
142 config_path: &Path,
143) -> anyhow::Result<std::collections::BTreeSet<String>> {
144 if !config_path.exists() {
145 return Ok(std::collections::BTreeSet::new());
146 }
147 let config = config::load_config(config_path)?;
148 config::resolve_exempt(root, config.exemptions(language), config::Rule::Location)
149}
150
151fn run_unit_coverage(
155 root: &Path,
156 language: location::Language,
157 config_path: &Path,
158) -> anyhow::Result<i32> {
159 let config = config::load_config(config_path)?;
160 let (thresholds, exempt) = match language {
161 location::Language::Python => {
162 let python = config
163 .python
164 .as_ref()
165 .context("config has no [python] table to read coverage thresholds from")?;
166 let coverage = python
167 .coverage
168 .as_ref()
169 .context("config [python] table has no `coverage` thresholds")?;
170 let thresholds = coverage::Thresholds {
171 fail_under: coverage.fail_under,
172 branch: coverage.branch,
173 };
174 let exempt = config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?;
175 (thresholds, exempt)
176 }
177 location::Language::TypeScript => anyhow::bail!(
178 "`unit coverage` supports `--language python` only for now; \
179 TypeScript coverage is a separate item"
180 ),
181 };
182 let omit: Vec<String> = exempt.into_iter().collect();
183 match coverage::measure(root, thresholds, &omit)? {
184 coverage::Outcome::Pass => Ok(0),
185 coverage::Outcome::Fail(reason) => {
186 eprintln!("error: coverage check failed — {reason}");
187 Ok(1)
188 }
189 }
190}
191
192fn run_integration_lint(root: &Path, language: location::Language) -> anyhow::Result<i32> {
196 match language {
197 location::Language::Python => {}
198 location::Language::TypeScript => {
199 anyhow::bail!("`integration lint` supports `--language python` only for now")
200 }
201 }
202 let violations = lint::find_violations(root)?;
203 if violations.is_empty() {
204 return Ok(0);
205 }
206 for v in &violations {
207 eprintln!(
208 "{}:{}: {} — {}",
209 v.file.display(),
210 v.line,
211 v.rule,
212 v.message
213 );
214 }
215 eprintln!("error: {} lint violation(s)", violations.len());
216 Ok(1)
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn no_args_returns_ok_zero() {
225 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
226 }
227
228 #[test]
229 fn check_returns_ok_zero() {
230 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
231 }
232
233 #[test]
234 fn unknown_flag_errors() {
235 assert!(run(["testing-conventions", "--bogus"]).is_err());
236 }
237
238 #[test]
239 fn help_flag_returns_clap_display_help() {
240 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
241 let clap_err = err
242 .downcast_ref::<clap::Error>()
243 .expect("error should be a clap::Error");
244 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
245 }
246
247 #[test]
248 fn version_flag_returns_clap_display_version() {
249 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
250 let clap_err = err
251 .downcast_ref::<clap::Error>()
252 .expect("error should be a clap::Error");
253 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
254 }
255}