Skip to main content

testing_conventions/
lib.rs

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