testing_conventions/
lib.rs1pub 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 },
50 Coverage {
52 path: PathBuf,
54 #[arg(long, value_enum)]
56 language: location::Language,
57 #[arg(long, default_value = "testing-conventions.toml")]
59 config: PathBuf,
60 },
61}
62
63#[derive(Subcommand, Debug)]
66enum IntegrationRule {
67 Lint {
69 path: PathBuf,
71 #[arg(long, value_enum)]
73 language: location::Language,
74 },
75}
76
77pub fn run<I, T>(args: I) -> anyhow::Result<i32>
78where
79 I: IntoIterator<Item = T>,
80 T: Into<std::ffi::OsString> + Clone,
81{
82 let cli = Cli::try_parse_from(args)?;
83 match cli.command {
84 Some(Command::Check) | None => Ok(0),
88 Some(Command::Unit { rule }) => match rule {
89 UnitRule::Location { path, language } => run_unit_location(&path, language),
90 UnitRule::Coverage {
91 path,
92 language,
93 config,
94 } => run_unit_coverage(&path, language, &config),
95 },
96 Some(Command::Integration { rule }) => match rule {
97 IntegrationRule::Lint { path, language } => run_integration_lint(&path, language),
98 },
99 }
100}
101
102fn run_unit_location(root: &Path, language: location::Language) -> anyhow::Result<i32> {
107 let orphans = location::missing_unit_tests(root, language)?;
108 if orphans.is_empty() {
109 return Ok(0);
110 }
111 for orphan in &orphans {
112 eprintln!("missing colocated unit test: {}", orphan.display());
113 }
114 eprintln!(
115 "error: {} source file(s) missing a colocated unit test",
116 orphans.len()
117 );
118 Ok(1)
119}
120
121fn run_unit_coverage(
125 root: &Path,
126 language: location::Language,
127 config_path: &Path,
128) -> anyhow::Result<i32> {
129 let config = config::load_config(config_path)?;
130 let thresholds = match language {
131 location::Language::Python => {
132 let python = config
133 .python
134 .context("config has no [python] table to read coverage thresholds from")?;
135 coverage::Thresholds {
136 fail_under: python.coverage.fail_under,
137 branch: python.coverage.branch,
138 }
139 }
140 location::Language::TypeScript => anyhow::bail!(
141 "`unit coverage` supports `--language python` only for now; \
142 TypeScript coverage is a separate item"
143 ),
144 };
145 match coverage::measure(root, thresholds)? {
146 coverage::Outcome::Pass => Ok(0),
147 coverage::Outcome::Fail(reason) => {
148 eprintln!("error: coverage check failed — {reason}");
149 Ok(1)
150 }
151 }
152}
153
154fn run_integration_lint(root: &Path, language: location::Language) -> anyhow::Result<i32> {
158 match language {
159 location::Language::Python => {}
160 location::Language::TypeScript => {
161 anyhow::bail!("`integration lint` supports `--language python` only for now")
162 }
163 }
164 let violations = lint::find_violations(root)?;
165 if violations.is_empty() {
166 return Ok(0);
167 }
168 for v in &violations {
169 eprintln!(
170 "{}:{}: {} — {}",
171 v.file.display(),
172 v.line,
173 v.rule,
174 v.message
175 );
176 }
177 eprintln!("error: {} lint violation(s)", violations.len());
178 Ok(1)
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn no_args_returns_ok_zero() {
187 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
188 }
189
190 #[test]
191 fn check_returns_ok_zero() {
192 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
193 }
194
195 #[test]
196 fn unknown_flag_errors() {
197 assert!(run(["testing-conventions", "--bogus"]).is_err());
198 }
199
200 #[test]
201 fn help_flag_returns_clap_display_help() {
202 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
203 let clap_err = err
204 .downcast_ref::<clap::Error>()
205 .expect("error should be a clap::Error");
206 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
207 }
208
209 #[test]
210 fn version_flag_returns_clap_display_version() {
211 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
212 let clap_err = err
213 .downcast_ref::<clap::Error>()
214 .expect("error should be a clap::Error");
215 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
216 }
217}