Skip to main content

testing_conventions/
lib.rs

1pub mod colocated_test;
2pub mod config;
3pub mod coverage;
4pub mod e2e;
5pub mod isolation;
6pub mod lint;
7pub mod packaging;
8pub mod ts;
9pub mod violation;
10pub mod workflow;
11
12use std::path::{Path, PathBuf};
13
14use clap::{CommandFactory, Parser, Subcommand};
15
16#[derive(Parser, Debug)]
17#[command(
18    name = "testing-conventions",
19    version,
20    about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
21    long_about = None,
22)]
23pub struct Cli {
24    #[command(subcommand)]
25    command: Option<Command>,
26}
27
28#[derive(Subcommand, Debug)]
29enum Command {
30    /// Check the repository against its testing-conventions config.
31    Check,
32    /// Unit-test conventions.
33    Unit {
34        #[command(subcommand)]
35        rule: UnitRule,
36    },
37    /// Integration-test conventions.
38    Integration {
39        #[command(subcommand)]
40        rule: IntegrationRule,
41    },
42    /// Packaging conventions: test files must not ship in the built artifact.
43    Packaging {
44        /// Root of the built artifact to inspect (e.g. an unpacked wheel or `dist/`).
45        path: PathBuf,
46        /// Language convention to enforce (required).
47        #[arg(long, value_enum)]
48        language: colocated_test::Language,
49    },
50    /// Workflow guard: every `testing-conventions` invocation in a CI workflow must
51    /// name a subcommand this binary still exposes (guards the `@v0` path, #92).
52    Workflow {
53        /// Workflow file (or a directory of them) to scan.
54        path: PathBuf,
55    },
56    /// End-to-end-test conventions.
57    E2e {
58        #[command(subcommand)]
59        command: E2eCommand,
60    },
61}
62
63/// Rules enforced on the unit-test suite (the README's "Unit" taxonomy).
64#[derive(Subcommand, Debug)]
65enum UnitRule {
66    /// Check that every source file has a colocated, matching-named unit test.
67    ColocatedTest {
68        /// Directory to scan recursively.
69        path: PathBuf,
70        /// Language convention to enforce (required).
71        #[arg(long, value_enum)]
72        language: colocated_test::Language,
73        /// testing-conventions config file providing the `exempt` list. Optional:
74        /// if the file is absent, no files are exempt.
75        #[arg(long, default_value = "testing-conventions.toml")]
76        config: PathBuf,
77    },
78    /// Check that the unit suite meets the configured coverage floor.
79    Coverage {
80        /// Directory whose unit suite is run and measured.
81        path: PathBuf,
82        /// Language convention to enforce (required).
83        #[arg(long, value_enum)]
84        language: colocated_test::Language,
85        /// testing-conventions config file with the coverage thresholds and
86        /// `exempt` list. Optional: if the file — or its `[<language>].coverage`
87        /// table — is absent, the language's sane default floor is used and
88        /// nothing is exempt.
89        #[arg(long, default_value = "testing-conventions.toml")]
90        config: PathBuf,
91    },
92    /// Check that unit tests isolate the unit under test (Rust, TypeScript).
93    Isolation {
94        /// Crate root / source dir to scan recursively.
95        path: PathBuf,
96        /// Language convention to enforce (required).
97        #[arg(long, value_enum)]
98        language: isolation::Language,
99        /// testing-conventions config file providing the `exempt` list (waivers).
100        /// Optional: if the file is absent, nothing is waived.
101        #[arg(long, default_value = "testing-conventions.toml")]
102        config: PathBuf,
103    },
104}
105
106/// Languages the integration-test lints support — its own set (Python,
107/// TypeScript, Rust), distinct from the file-pairing `colocated_test::Language`,
108/// so adding Rust here doesn't touch the colocated-test/coverage rules.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
110pub enum IntegrationLintLanguage {
111    /// Python test files (`*_test.py`, `test_*.py`, `conftest.py`).
112    #[value(name = "python")]
113    Python,
114    /// TypeScript test files (`*.test.{ts,tsx,mts,cts}`).
115    #[value(name = "typescript")]
116    TypeScript,
117    /// Rust integration crates under `tests/`.
118    #[value(name = "rust")]
119    Rust,
120}
121
122/// Lints enforced on integration tests (mocking mechanism & style, and more to
123/// come). The README's "Integration" taxonomy.
124#[derive(Subcommand, Debug)]
125enum IntegrationRule {
126    /// Lint integration test files for mocking mechanism & style (Python, TypeScript, Rust).
127    Lint {
128        /// Directory to scan recursively for test files.
129        path: PathBuf,
130        /// Language convention to enforce (required).
131        #[arg(long, value_enum)]
132        language: IntegrationLintLanguage,
133        /// testing-conventions config file providing the `exempt` list (waivers).
134        /// Optional: if the file is absent, nothing is waived.
135        #[arg(long, default_value = "testing-conventions.toml")]
136        config: PathBuf,
137    },
138}
139
140/// E2E attestation commands (#17): record a local e2e run and (later, #68)
141/// verify in CI that the latest code commit is attested.
142#[derive(Subcommand, Debug)]
143enum E2eCommand {
144    /// Run the e2e suite and write a committed attestation naming the current commit.
145    Attest {
146        /// The e2e command to run (e.g. `pnpm run e2e`), executed via the shell.
147        command: String,
148    },
149}
150
151pub fn run<I, T>(args: I) -> anyhow::Result<i32>
152where
153    I: IntoIterator<Item = T>,
154    T: Into<std::ffi::OsString> + Clone,
155{
156    let cli = Cli::try_parse_from(args)?;
157    match cli.command {
158        // The config-driven `check` umbrella isn't wired yet; the scaffold
159        // proves the wiring while individual rules land under their test-kind
160        // group (e.g. `unit colocated-test`).
161        Some(Command::Check) | None => Ok(0),
162        Some(Command::Unit { rule }) => match rule {
163            UnitRule::ColocatedTest {
164                path,
165                language,
166                config,
167            } => run_unit_colocated_test(&path, language, &config),
168            UnitRule::Coverage {
169                path,
170                language,
171                config,
172            } => run_unit_coverage(&path, language, &config),
173            UnitRule::Isolation {
174                path,
175                language,
176                config,
177            } => run_unit_isolation(&path, language, &config),
178        },
179        Some(Command::Integration { rule }) => match rule {
180            IntegrationRule::Lint {
181                path,
182                language,
183                config,
184            } => run_integration_lint(&path, language, &config),
185        },
186        Some(Command::Packaging { path, language }) => run_packaging(&path, language),
187        Some(Command::Workflow { path }) => run_workflow(&path),
188        Some(Command::E2e { command }) => match command {
189            E2eCommand::Attest { command } => run_e2e_attest(&command),
190        },
191    }
192}
193
194/// The binary's own clap command tree — the source of truth for which subcommands
195/// it exposes. The `workflow` guard (#92) checks a workflow's invocations against
196/// it, so a renamed or removed subcommand is caught the moment they diverge.
197pub fn command() -> clap::Command {
198    Cli::command()
199}
200
201/// Run the unit-test colocated-test check over `root` for `language`, reporting orphans.
202///
203/// Loads the `colocated-test`-rule exemptions from the config at `config_path` (no
204/// config file → no exemptions). Returns `0` when every source file has its
205/// colocated unit test; otherwise prints each orphan to stderr and returns `1`.
206fn run_unit_colocated_test(
207    root: &Path,
208    language: colocated_test::Language,
209    config_path: &Path,
210) -> anyhow::Result<i32> {
211    if language == colocated_test::Language::Rust {
212        anyhow::bail!(
213            "`unit colocated-test` checks file-based colocation (Python/TypeScript); \
214             Rust units are inline `#[cfg(test)]` modules — see `unit isolation`"
215        );
216    }
217    let exempt = colocated_test_exemptions(root, language, config_path)?;
218    let orphans = colocated_test::missing_unit_tests(root, language, &exempt)?;
219    if orphans.is_empty() {
220        return Ok(0);
221    }
222    for orphan in &orphans {
223        eprintln!("missing colocated unit test: {}", orphan.display());
224    }
225    eprintln!(
226        "error: {} source file(s) missing a colocated unit test \
227         (add a colocated test, or an `exempt` entry with a reason)",
228        orphans.len()
229    );
230    Ok(1)
231}
232
233/// The `colocated-test`-rule exempt paths for `language`, resolved (and validated)
234/// from the config at `config_path`. A missing config file means no exemptions —
235/// the check still runs, just with nothing exempted.
236fn colocated_test_exemptions(
237    root: &Path,
238    language: colocated_test::Language,
239    config_path: &Path,
240) -> anyhow::Result<std::collections::BTreeSet<String>> {
241    if !config_path.exists() {
242        return Ok(std::collections::BTreeSet::new());
243    }
244    let config = config::load_config(config_path)?;
245    config::resolve_exempt(
246        root,
247        config.exemptions(language),
248        config::Rule::ColocatedTest,
249    )
250}
251
252/// Run the unit-test coverage check over `root` for `language`, enforcing the
253/// floor from the config at `config_path`. Returns `0` when the floor is met,
254/// `1` otherwise.
255///
256/// Coverage is zero-config by default (#80): a missing config file — or a config
257/// with no `[<language>].coverage` table — falls back to the language's sane
258/// default floor ([`config::PythonCoverage::default`] /
259/// [`config::TypeScriptCoverage::default`]), the same way `unit colocated-test`
260/// and `integration lint` treat an absent config as "nothing exempt". A present
261/// `coverage` table overrides the default; `coverage`-rule exemptions still apply.
262fn run_unit_coverage(
263    root: &Path,
264    language: colocated_test::Language,
265    config_path: &Path,
266) -> anyhow::Result<i32> {
267    let config = if config_path.exists() {
268        config::load_config(config_path)?
269    } else {
270        config::Config::default()
271    };
272    let outcome = match language {
273        colocated_test::Language::Python => {
274            let python = config.python.unwrap_or_default();
275            let coverage = python.coverage.unwrap_or_default();
276            let thresholds = coverage::Thresholds {
277                fail_under: coverage.fail_under,
278                branch: coverage.branch,
279            };
280            let omit: Vec<String> =
281                config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
282                    .into_iter()
283                    .collect();
284            coverage::measure(root, thresholds, &omit)?
285        }
286        colocated_test::Language::TypeScript => {
287            let typescript = config.typescript.unwrap_or_default();
288            let coverage = typescript.coverage.unwrap_or_default();
289            let thresholds = coverage::TypeScriptThresholds {
290                lines: coverage.lines,
291                branches: coverage.branches,
292                functions: coverage.functions,
293                statements: coverage.statements,
294            };
295            let exclude: Vec<String> =
296                config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
297                    .into_iter()
298                    .collect();
299            coverage::measure_typescript(root, thresholds, &exclude)?
300        }
301        colocated_test::Language::Rust => anyhow::bail!(
302            "`unit coverage` supports `--language python` / `typescript`; \
303             Rust coverage (`cargo llvm-cov`) is a separate item"
304        ),
305    };
306    match outcome {
307        coverage::Outcome::Pass => Ok(0),
308        coverage::Outcome::Fail(reason) => {
309            eprintln!("error: coverage check failed — {reason}");
310            Ok(1)
311        }
312    }
313}
314
315/// Run the unit-isolation check over `root` for `language`, printing each
316/// violation to stderr as `path:line: rule — message` and returning `1` when any
317/// are found, `0` otherwise.
318fn run_unit_isolation(
319    root: &Path,
320    language: isolation::Language,
321    config_path: &Path,
322) -> anyhow::Result<i32> {
323    let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
324        isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
325        isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
326            c.exemptions(colocated_test::Language::TypeScript)
327        }),
328    };
329    let violations = apply_waivers(raw, root, config_path, select)?;
330    if violations.is_empty() {
331        return Ok(0);
332    }
333    for v in &violations {
334        eprintln!(
335            "{}:{}: {} — {}",
336            v.file.display(),
337            v.line,
338            v.rule,
339            v.message
340        );
341    }
342    eprintln!("error: {} isolation violation(s)", violations.len());
343    Ok(1)
344}
345
346/// Run the integration-test lints over `root` for `language`, printing each
347/// violation to stderr as `path:line: rule — message` and returning `1` when any
348/// are found, `0` otherwise.
349fn run_integration_lint(
350    root: &Path,
351    language: IntegrationLintLanguage,
352    config_path: &Path,
353) -> anyhow::Result<i32> {
354    let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
355        IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
356            c.exemptions(colocated_test::Language::Python)
357        }),
358        IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
359            c.exemptions(colocated_test::Language::TypeScript)
360        }),
361        IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
362            c.rust_exemptions()
363        }),
364    };
365    let violations = apply_waivers(raw, root, config_path, select)?;
366    if violations.is_empty() {
367        return Ok(0);
368    }
369    for v in &violations {
370        eprintln!(
371            "{}:{}: {} — {}",
372            v.file.display(),
373            v.line,
374            v.rule,
375            v.message
376        );
377    }
378    eprintln!("error: {} lint violation(s)", violations.len());
379    Ok(1)
380}
381
382/// Selects a language's `[[<lang>.exempt]]` table from a loaded config — the one
383/// varying piece between the `unit isolation` and `integration lint` waiver paths.
384type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
385
386/// Drop the violations waived by the config's `exempt` list (#32/#102). A
387/// violation is waived when its `rule` is a known [`config::Rule`] and its
388/// `root`-relative path is exempt for that rule. `exemptions` selects the
389/// language's `[[<lang>.exempt]]` table from the loaded config. A missing config
390/// file waives nothing; a reason-less or stale entry errors (via `load_config` /
391/// `resolve_exempt`), so the escape hatch can't silently rot.
392fn apply_waivers(
393    violations: Vec<lint::Violation>,
394    root: &Path,
395    config_path: &Path,
396    exemptions: ExemptSelect,
397) -> anyhow::Result<Vec<lint::Violation>> {
398    use std::collections::hash_map::Entry;
399
400    if !config_path.exists() {
401        return Ok(violations);
402    }
403    let config = config::load_config(config_path)?;
404    let exempt = exemptions(&config);
405    // Resolve each rule's exempt set once (and surface a stale entry as an error).
406    let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
407        std::collections::HashMap::new();
408    let mut kept = Vec::new();
409    for violation in violations {
410        let waived = match config::Rule::from_id(violation.rule) {
411            Some(rule) => {
412                let exempt_paths = match resolved.entry(rule) {
413                    Entry::Occupied(entry) => entry.into_mut(),
414                    Entry::Vacant(entry) => {
415                        entry.insert(config::resolve_exempt(root, exempt, rule)?)
416                    }
417                };
418                violation
419                    .file
420                    .strip_prefix(root)
421                    .ok()
422                    .map(|rel| rel.to_string_lossy().replace('\\', "/"))
423                    .is_some_and(|rel| exempt_paths.contains(&rel))
424            }
425            None => false,
426        };
427        if !waived {
428            kept.push(violation);
429        }
430    }
431    Ok(kept)
432}
433
434/// Run the packaging check: inspect the built artifact at `artifact` for test
435/// files that must not ship (README "Packaging"), per `language`'s test-file
436/// globs.
437///
438/// `artifact` is either an already-unpacked directory or a packed artifact the
439/// rule unpacks itself — a Python wheel (`.whl`) today; the TypeScript (#73) and
440/// Rust (#74) archives follow. Returns `0` when no test file is present, `1`
441/// otherwise (after printing each offending path, relative to the artifact root).
442fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
443    let globs = match language {
444        colocated_test::Language::Python => vec!["*_test.py".to_string()],
445        colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
446        // `#[cfg(test)]` units compile out for free; the only thing to keep out of
447        // the `.crate` source tarball is the crate-root integration `tests/` dir.
448        colocated_test::Language::Rust => vec!["tests/".to_string()],
449    };
450    let offenders = packaging::inspect(artifact, &globs)?;
451    if offenders.is_empty() {
452        return Ok(0);
453    }
454    for offender in &offenders {
455        eprintln!("test file in built artifact: {}", offender.display());
456    }
457    eprintln!(
458        "error: {} test file(s) present in the built artifact \
459         (they must be excluded from packaging)",
460        offenders.len()
461    );
462    Ok(1)
463}
464
465/// Run the workflow guard over `path` (a workflow file or directory): flag every
466/// `testing-conventions` invocation that names a subcommand this binary no longer
467/// exposes, printing each as `path:line: rule — message` and returning `1` when any
468/// are found, `0` otherwise.
469fn run_workflow(path: &Path) -> anyhow::Result<i32> {
470    let violations = workflow::check(path, &command())?;
471    if violations.is_empty() {
472        return Ok(0);
473    }
474    for v in &violations {
475        eprintln!(
476            "{}:{}: {} — {}",
477            v.file.display(),
478            v.line,
479            v.rule,
480            v.message
481        );
482    }
483    eprintln!(
484        "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
485        violations.len()
486    );
487    Ok(1)
488}
489
490/// Run `command` as an e2e suite and write a committed attestation naming the
491/// current commit (#67). Force-runs: the attestation is written regardless of
492/// the command's exit code, so this exits `0` once the attestation is recorded.
493fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
494    let repo = std::env::current_dir()?;
495    let attestation = e2e::attest(&repo, command)?;
496    println!(
497        "e2e attestation recorded for commit {} (command exited {})",
498        attestation.commit, attestation.exit_code
499    );
500    Ok(0)
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn no_args_returns_ok_zero() {
509        assert_eq!(run(["testing-conventions"]).unwrap(), 0);
510    }
511
512    #[test]
513    fn check_returns_ok_zero() {
514        assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
515    }
516
517    #[test]
518    fn unknown_flag_errors() {
519        assert!(run(["testing-conventions", "--bogus"]).is_err());
520    }
521
522    #[test]
523    fn help_flag_returns_clap_display_help() {
524        let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
525        let clap_err = err
526            .downcast_ref::<clap::Error>()
527            .expect("error should be a clap::Error");
528        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
529    }
530
531    #[test]
532    fn version_flag_returns_clap_display_version() {
533        let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
534        let clap_err = err
535            .downcast_ref::<clap::Error>()
536            .expect("error should be a clap::Error");
537        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
538    }
539
540    #[test]
541    fn unit_colocated_test_rejects_rust() {
542        let err = run([
543            "testing-conventions",
544            "unit",
545            "colocated-test",
546            "pkg",
547            "--language",
548            "rust",
549        ])
550        .unwrap_err();
551        assert!(err.to_string().contains("inline"), "got: {err}");
552    }
553
554    #[test]
555    fn unit_coverage_rejects_rust() {
556        // Zero-config: with no config file the default config is used, so this
557        // reaches the language arm (which bails for Rust) without any fixture.
558        let err = run([
559            "testing-conventions",
560            "unit",
561            "coverage",
562            "pkg",
563            "--language",
564            "rust",
565        ])
566        .unwrap_err();
567        assert!(err.to_string().contains("separate item"), "got: {err}");
568    }
569}