Skip to main content

testing_conventions/
lib.rs

1pub mod colocated_test;
2pub mod config;
3pub mod coverage;
4pub mod isolation;
5pub mod lint;
6pub mod packaging;
7pub mod ts;
8pub mod violation;
9pub mod workflow;
10
11use std::path::{Path, PathBuf};
12
13use clap::{CommandFactory, Parser, Subcommand};
14
15#[derive(Parser, Debug)]
16#[command(
17    name = "testing-conventions",
18    version,
19    about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
20    long_about = None,
21)]
22pub struct Cli {
23    #[command(subcommand)]
24    command: Option<Command>,
25}
26
27#[derive(Subcommand, Debug)]
28enum Command {
29    /// Check the repository against its testing-conventions config.
30    Check,
31    /// Unit-test conventions.
32    Unit {
33        #[command(subcommand)]
34        rule: UnitRule,
35    },
36    /// Integration-test conventions.
37    Integration {
38        #[command(subcommand)]
39        rule: IntegrationRule,
40    },
41    /// Packaging conventions: test files must not ship in the built artifact.
42    Packaging {
43        /// Root of the built artifact to inspect (e.g. an unpacked wheel or `dist/`).
44        path: PathBuf,
45        /// Language convention to enforce (required).
46        #[arg(long, value_enum)]
47        language: colocated_test::Language,
48    },
49    /// Workflow guard: every `testing-conventions` invocation in a CI workflow must
50    /// name a subcommand this binary still exposes (guards the `@v0` path, #92).
51    Workflow {
52        /// Workflow file (or a directory of them) to scan.
53        path: PathBuf,
54    },
55}
56
57/// Rules enforced on the unit-test suite (the README's "Unit" taxonomy).
58#[derive(Subcommand, Debug)]
59enum UnitRule {
60    /// Check that every source file has a colocated, matching-named unit test.
61    ColocatedTest {
62        /// Directory to scan recursively.
63        path: PathBuf,
64        /// Language convention to enforce (required).
65        #[arg(long, value_enum)]
66        language: colocated_test::Language,
67        /// testing-conventions config file providing the `exempt` list. Optional:
68        /// if the file is absent, no files are exempt.
69        #[arg(long, default_value = "testing-conventions.toml")]
70        config: PathBuf,
71    },
72    /// Check that the unit suite meets the configured coverage floor.
73    Coverage {
74        /// Directory whose unit suite is run and measured.
75        path: PathBuf,
76        /// Language convention to enforce (required).
77        #[arg(long, value_enum)]
78        language: colocated_test::Language,
79        /// testing-conventions config file with the coverage thresholds and
80        /// `exempt` list. Optional: if the file — or its `[<language>].coverage`
81        /// table — is absent, the language's sane default floor is used and
82        /// nothing is exempt.
83        #[arg(long, default_value = "testing-conventions.toml")]
84        config: PathBuf,
85    },
86    /// Check that inline unit tests call nothing out of their own module (Rust).
87    Isolation {
88        /// Crate root to scan recursively (its `Cargo.toml` names external crates).
89        path: PathBuf,
90        /// Language convention to enforce (required).
91        #[arg(long, value_enum)]
92        language: isolation::Language,
93    },
94}
95
96/// Lints enforced on integration tests (mocking mechanism & style, and more to
97/// come). The README's "Integration" taxonomy.
98#[derive(Subcommand, Debug)]
99enum IntegrationRule {
100    /// Lint integration test files for mocking mechanism & style (Python, TypeScript).
101    Lint {
102        /// Directory to scan recursively for test files.
103        path: PathBuf,
104        /// Language convention to enforce (required).
105        #[arg(long, value_enum)]
106        language: colocated_test::Language,
107        /// testing-conventions config file providing the `exempt` list (waivers).
108        /// Optional: if the file is absent, nothing is waived.
109        #[arg(long, default_value = "testing-conventions.toml")]
110        config: PathBuf,
111    },
112}
113
114pub fn run<I, T>(args: I) -> anyhow::Result<i32>
115where
116    I: IntoIterator<Item = T>,
117    T: Into<std::ffi::OsString> + Clone,
118{
119    let cli = Cli::try_parse_from(args)?;
120    match cli.command {
121        // The config-driven `check` umbrella isn't wired yet; the scaffold
122        // proves the wiring while individual rules land under their test-kind
123        // group (e.g. `unit colocated-test`).
124        Some(Command::Check) | None => Ok(0),
125        Some(Command::Unit { rule }) => match rule {
126            UnitRule::ColocatedTest {
127                path,
128                language,
129                config,
130            } => run_unit_colocated_test(&path, language, &config),
131            UnitRule::Coverage {
132                path,
133                language,
134                config,
135            } => run_unit_coverage(&path, language, &config),
136            UnitRule::Isolation { path, language } => run_unit_isolation(&path, language),
137        },
138        Some(Command::Integration { rule }) => match rule {
139            IntegrationRule::Lint {
140                path,
141                language,
142                config,
143            } => run_integration_lint(&path, language, &config),
144        },
145        Some(Command::Packaging { path, language }) => run_packaging(&path, language),
146        Some(Command::Workflow { path }) => run_workflow(&path),
147    }
148}
149
150/// The binary's own clap command tree — the source of truth for which subcommands
151/// it exposes. The `workflow` guard (#92) checks a workflow's invocations against
152/// it, so a renamed or removed subcommand is caught the moment they diverge.
153pub fn command() -> clap::Command {
154    Cli::command()
155}
156
157/// Run the unit-test colocated-test check over `root` for `language`, reporting orphans.
158///
159/// Loads the `colocated-test`-rule exemptions from the config at `config_path` (no
160/// config file → no exemptions). Returns `0` when every source file has its
161/// colocated unit test; otherwise prints each orphan to stderr and returns `1`.
162fn run_unit_colocated_test(
163    root: &Path,
164    language: colocated_test::Language,
165    config_path: &Path,
166) -> anyhow::Result<i32> {
167    let exempt = colocated_test_exemptions(root, language, config_path)?;
168    let orphans = colocated_test::missing_unit_tests(root, language, &exempt)?;
169    if orphans.is_empty() {
170        return Ok(0);
171    }
172    for orphan in &orphans {
173        eprintln!("missing colocated unit test: {}", orphan.display());
174    }
175    eprintln!(
176        "error: {} source file(s) missing a colocated unit test \
177         (add a colocated test, or an `exempt` entry with a reason)",
178        orphans.len()
179    );
180    Ok(1)
181}
182
183/// The `colocated-test`-rule exempt paths for `language`, resolved (and validated)
184/// from the config at `config_path`. A missing config file means no exemptions —
185/// the check still runs, just with nothing exempted.
186fn colocated_test_exemptions(
187    root: &Path,
188    language: colocated_test::Language,
189    config_path: &Path,
190) -> anyhow::Result<std::collections::BTreeSet<String>> {
191    if !config_path.exists() {
192        return Ok(std::collections::BTreeSet::new());
193    }
194    let config = config::load_config(config_path)?;
195    config::resolve_exempt(
196        root,
197        config.exemptions(language),
198        config::Rule::ColocatedTest,
199    )
200}
201
202/// Run the unit-test coverage check over `root` for `language`, enforcing the
203/// floor from the config at `config_path`. Returns `0` when the floor is met,
204/// `1` otherwise.
205///
206/// Coverage is zero-config by default (#80): a missing config file — or a config
207/// with no `[<language>].coverage` table — falls back to the language's sane
208/// default floor ([`config::PythonCoverage::default`] /
209/// [`config::TypeScriptCoverage::default`]), the same way `unit colocated-test`
210/// and `integration lint` treat an absent config as "nothing exempt". A present
211/// `coverage` table overrides the default; `coverage`-rule exemptions still apply.
212fn run_unit_coverage(
213    root: &Path,
214    language: colocated_test::Language,
215    config_path: &Path,
216) -> anyhow::Result<i32> {
217    let config = if config_path.exists() {
218        config::load_config(config_path)?
219    } else {
220        config::Config::default()
221    };
222    let outcome = match language {
223        colocated_test::Language::Python => {
224            let python = config.python.unwrap_or_default();
225            let coverage = python.coverage.unwrap_or_default();
226            let thresholds = coverage::Thresholds {
227                fail_under: coverage.fail_under,
228                branch: coverage.branch,
229            };
230            let omit: Vec<String> =
231                config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
232                    .into_iter()
233                    .collect();
234            coverage::measure(root, thresholds, &omit)?
235        }
236        colocated_test::Language::TypeScript => {
237            let typescript = config.typescript.unwrap_or_default();
238            let coverage = typescript.coverage.unwrap_or_default();
239            let thresholds = coverage::TypeScriptThresholds {
240                lines: coverage.lines,
241                branches: coverage.branches,
242                functions: coverage.functions,
243                statements: coverage.statements,
244            };
245            let exclude: Vec<String> =
246                config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
247                    .into_iter()
248                    .collect();
249            coverage::measure_typescript(root, thresholds, &exclude)?
250        }
251    };
252    match outcome {
253        coverage::Outcome::Pass => Ok(0),
254        coverage::Outcome::Fail(reason) => {
255            eprintln!("error: coverage check failed — {reason}");
256            Ok(1)
257        }
258    }
259}
260
261/// Run the unit-isolation check over `root` for `language`, printing each
262/// violation to stderr as `path:line: rule — message` and returning `1` when any
263/// are found, `0` otherwise.
264fn run_unit_isolation(root: &Path, language: isolation::Language) -> anyhow::Result<i32> {
265    let violations = match language {
266        isolation::Language::Rust => isolation::find_violations(root)?,
267        isolation::Language::TypeScript => ts::find_unit_violations(root)?,
268    };
269    if violations.is_empty() {
270        return Ok(0);
271    }
272    for v in &violations {
273        eprintln!(
274            "{}:{}: {} — {}",
275            v.file.display(),
276            v.line,
277            v.rule,
278            v.message
279        );
280    }
281    eprintln!("error: {} isolation violation(s)", violations.len());
282    Ok(1)
283}
284
285/// Run the integration-test lints over `root` for `language`, printing each
286/// violation to stderr as `path:line: rule — message` and returning `1` when any
287/// are found, `0` otherwise.
288fn run_integration_lint(
289    root: &Path,
290    language: colocated_test::Language,
291    config_path: &Path,
292) -> anyhow::Result<i32> {
293    let waived = lint_waivers(root, language, config_path)?;
294    let raw = match language {
295        colocated_test::Language::Python => lint::find_violations(root)?,
296        colocated_test::Language::TypeScript => ts::find_integration_violations(root)?,
297    };
298    let violations: Vec<lint::Violation> = raw
299        .into_iter()
300        .filter(|v| !is_waived(v, root, &waived))
301        .collect();
302    if violations.is_empty() {
303        return Ok(0);
304    }
305    for v in &violations {
306        eprintln!(
307            "{}:{}: {} — {}",
308            v.file.display(),
309            v.line,
310            v.rule,
311            v.message
312        );
313    }
314    eprintln!("error: {} lint violation(s)", violations.len());
315    Ok(1)
316}
317
318/// The `no-constant-patch` waivers (root-relative paths) from the config at
319/// `config_path` — the only waivable lint (#52). A missing config file means
320/// nothing is waived.
321fn lint_waivers(
322    root: &Path,
323    language: colocated_test::Language,
324    config_path: &Path,
325) -> anyhow::Result<std::collections::BTreeSet<String>> {
326    if !config_path.exists() {
327        return Ok(std::collections::BTreeSet::new());
328    }
329    let config = config::load_config(config_path)?;
330    config::resolve_exempt(
331        root,
332        config.exemptions(language),
333        config::Rule::NoConstantPatch,
334    )
335}
336
337/// `true` when `violation` is a `no-constant-patch` finding in a waived file.
338fn is_waived(
339    violation: &lint::Violation,
340    root: &Path,
341    waived: &std::collections::BTreeSet<String>,
342) -> bool {
343    violation.rule == "no-constant-patch"
344        && violation
345            .file
346            .strip_prefix(root)
347            .ok()
348            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
349            .is_some_and(|rel| waived.contains(&rel))
350}
351
352/// Run the packaging check: inspect the built artifact at `artifact` for test
353/// files that must not ship (README "Packaging"), per `language`'s test-file
354/// globs.
355///
356/// `artifact` is either an already-unpacked directory or a packed artifact the
357/// rule unpacks itself — a Python wheel (`.whl`) today; the TypeScript (#73) and
358/// Rust (#74) archives follow. Returns `0` when no test file is present, `1`
359/// otherwise (after printing each offending path, relative to the artifact root).
360fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
361    let globs = match language {
362        colocated_test::Language::Python => vec!["*_test.py".to_string()],
363        colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
364    };
365    let offenders = packaging::inspect(artifact, &globs)?;
366    if offenders.is_empty() {
367        return Ok(0);
368    }
369    for offender in &offenders {
370        eprintln!("test file in built artifact: {}", offender.display());
371    }
372    eprintln!(
373        "error: {} test file(s) present in the built artifact \
374         (they must be excluded from packaging)",
375        offenders.len()
376    );
377    Ok(1)
378}
379
380/// Run the workflow guard over `path` (a workflow file or directory): flag every
381/// `testing-conventions` invocation that names a subcommand this binary no longer
382/// exposes, printing each as `path:line: rule — message` and returning `1` when any
383/// are found, `0` otherwise.
384fn run_workflow(path: &Path) -> anyhow::Result<i32> {
385    let violations = workflow::check(path, &command())?;
386    if violations.is_empty() {
387        return Ok(0);
388    }
389    for v in &violations {
390        eprintln!(
391            "{}:{}: {} — {}",
392            v.file.display(),
393            v.line,
394            v.rule,
395            v.message
396        );
397    }
398    eprintln!(
399        "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
400        violations.len()
401    );
402    Ok(1)
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn no_args_returns_ok_zero() {
411        assert_eq!(run(["testing-conventions"]).unwrap(), 0);
412    }
413
414    #[test]
415    fn check_returns_ok_zero() {
416        assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
417    }
418
419    #[test]
420    fn unknown_flag_errors() {
421        assert!(run(["testing-conventions", "--bogus"]).is_err());
422    }
423
424    #[test]
425    fn help_flag_returns_clap_display_help() {
426        let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
427        let clap_err = err
428            .downcast_ref::<clap::Error>()
429            .expect("error should be a clap::Error");
430        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
431    }
432
433    #[test]
434    fn version_flag_returns_clap_display_version() {
435        let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
436        let clap_err = err
437            .downcast_ref::<clap::Error>()
438            .expect("error should be a clap::Error");
439        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
440    }
441}