Skip to main content

testing_conventions/
lib.rs

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 the repository against its testing-conventions config.
25    Check,
26    /// Unit-test conventions.
27    Unit {
28        #[command(subcommand)]
29        rule: UnitRule,
30    },
31    /// Integration-test conventions.
32    Integration {
33        #[command(subcommand)]
34        rule: IntegrationRule,
35    },
36}
37
38/// Rules enforced on the unit-test suite (the README's "Unit" taxonomy).
39#[derive(Subcommand, Debug)]
40enum UnitRule {
41    /// Check that every source file has a colocated, matching-named unit test.
42    ColocatedTest {
43        /// Directory to scan recursively.
44        path: PathBuf,
45        /// Language convention to enforce (required).
46        #[arg(long, value_enum)]
47        language: colocated_test::Language,
48        /// testing-conventions config file providing the `exempt` list. Optional:
49        /// if the file is absent, no files are exempt.
50        #[arg(long, default_value = "testing-conventions.toml")]
51        config: PathBuf,
52    },
53    /// Check that the unit suite meets the configured coverage floor.
54    Coverage {
55        /// Directory whose unit suite is run and measured.
56        path: PathBuf,
57        /// Language convention to enforce (required).
58        #[arg(long, value_enum)]
59        language: colocated_test::Language,
60        /// testing-conventions config file with the coverage thresholds and
61        /// `exempt` list. Optional: if the file — or its `[<language>].coverage`
62        /// table — is absent, the language's sane default floor is used and
63        /// nothing is exempt.
64        #[arg(long, default_value = "testing-conventions.toml")]
65        config: PathBuf,
66    },
67}
68
69/// Lints enforced on integration tests (mocking mechanism & style, and more to
70/// come). The README's "Integration" taxonomy.
71#[derive(Subcommand, Debug)]
72enum IntegrationRule {
73    /// Lint integration test files for mocking mechanism & style (Python).
74    Lint {
75        /// Directory to scan recursively for Python test files.
76        path: PathBuf,
77        /// Language convention to enforce (required).
78        #[arg(long, value_enum)]
79        language: colocated_test::Language,
80        /// testing-conventions config file providing the `exempt` list (waivers).
81        /// Optional: if the file is absent, nothing is waived.
82        #[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        // The config-driven `check` umbrella isn't wired yet; the scaffold
95        // proves the wiring while individual rules land under their test-kind
96        // group (e.g. `unit colocated-test`).
97        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
120/// Run the unit-test colocated-test check over `root` for `language`, reporting orphans.
121///
122/// Loads the `colocated-test`-rule exemptions from the config at `config_path` (no
123/// config file → no exemptions). Returns `0` when every source file has its
124/// colocated unit test; otherwise prints each orphan to stderr and returns `1`.
125fn 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
146/// The `colocated-test`-rule exempt paths for `language`, resolved (and validated)
147/// from the config at `config_path`. A missing config file means no exemptions —
148/// the check still runs, just with nothing exempted.
149fn 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
165/// Run the unit-test coverage check over `root` for `language`, enforcing the
166/// floor from the config at `config_path`. Returns `0` when the floor is met,
167/// `1` otherwise.
168///
169/// Coverage is zero-config by default (#80): a missing config file — or a config
170/// with no `[<language>].coverage` table — falls back to the language's sane
171/// default floor ([`config::PythonCoverage::default`] /
172/// [`config::TypeScriptCoverage::default`]), the same way `unit colocated-test`
173/// and `integration lint` treat an absent config as "nothing exempt". A present
174/// `coverage` table overrides the default; `coverage`-rule exemptions still apply.
175fn 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
224/// Run the integration-test lints over `root` for `language`, printing each
225/// violation to stderr as `path:line: rule — message` and returning `1` when any
226/// are found, `0` otherwise.
227fn 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
259/// The `no-constant-patch` waivers (root-relative paths) from the config at
260/// `config_path` — the only waivable lint (#52). A missing config file means
261/// nothing is waived.
262fn 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
278/// `true` when `violation` is a `no-constant-patch` finding in a waived file.
279fn 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}