testing_conventions/
lib.rs1pub mod config;
2pub mod coverage;
3pub mod location;
4
5use std::path::{Path, PathBuf};
6
7use anyhow::Context;
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}
32
33#[derive(Subcommand, Debug)]
35enum UnitRule {
36 Location {
38 path: PathBuf,
40 #[arg(long, value_enum)]
42 language: location::Language,
43 },
44 Coverage {
46 path: PathBuf,
48 #[arg(long, value_enum)]
50 language: location::Language,
51 #[arg(long, default_value = "testing-conventions.toml")]
53 config: PathBuf,
54 },
55}
56
57pub fn run<I, T>(args: I) -> anyhow::Result<i32>
58where
59 I: IntoIterator<Item = T>,
60 T: Into<std::ffi::OsString> + Clone,
61{
62 let cli = Cli::try_parse_from(args)?;
63 match cli.command {
64 Some(Command::Check) | None => Ok(0),
68 Some(Command::Unit { rule }) => match rule {
69 UnitRule::Location { path, language } => run_unit_location(&path, language),
70 UnitRule::Coverage {
71 path,
72 language,
73 config,
74 } => run_unit_coverage(&path, language, &config),
75 },
76 }
77}
78
79fn run_unit_location(root: &Path, language: location::Language) -> anyhow::Result<i32> {
84 let orphans = location::missing_unit_tests(root, language)?;
85 if orphans.is_empty() {
86 return Ok(0);
87 }
88 for orphan in &orphans {
89 eprintln!("missing colocated unit test: {}", orphan.display());
90 }
91 eprintln!(
92 "error: {} source file(s) missing a colocated unit test",
93 orphans.len()
94 );
95 Ok(1)
96}
97
98fn run_unit_coverage(
102 root: &Path,
103 language: location::Language,
104 config_path: &Path,
105) -> anyhow::Result<i32> {
106 let config = config::load_config(config_path)?;
107 let thresholds = match language {
108 location::Language::Python => {
109 let python = config
110 .python
111 .context("config has no [python] table to read coverage thresholds from")?;
112 coverage::Thresholds {
113 fail_under: python.coverage.fail_under,
114 branch: python.coverage.branch,
115 }
116 }
117 location::Language::TypeScript => anyhow::bail!(
118 "`unit coverage` supports `--language python` only for now; \
119 TypeScript coverage is a separate item"
120 ),
121 };
122 match coverage::measure(root, thresholds)? {
123 coverage::Outcome::Pass => Ok(0),
124 coverage::Outcome::Fail(reason) => {
125 eprintln!("error: coverage check failed — {reason}");
126 Ok(1)
127 }
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn no_args_returns_ok_zero() {
137 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
138 }
139
140 #[test]
141 fn check_returns_ok_zero() {
142 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
143 }
144
145 #[test]
146 fn unknown_flag_errors() {
147 assert!(run(["testing-conventions", "--bogus"]).is_err());
148 }
149
150 #[test]
151 fn help_flag_returns_clap_display_help() {
152 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
153 let clap_err = err
154 .downcast_ref::<clap::Error>()
155 .expect("error should be a clap::Error");
156 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
157 }
158
159 #[test]
160 fn version_flag_returns_clap_display_version() {
161 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
162 let clap_err = err
163 .downcast_ref::<clap::Error>()
164 .expect("error should be a clap::Error");
165 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
166 }
167}