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 mutation;
9pub mod packaging;
10pub mod patch_coverage;
11pub mod ts;
12pub mod violation;
13pub mod workflow;
14
15use std::path::{Path, PathBuf};
16
17use clap::{CommandFactory, Parser, Subcommand};
18
19#[derive(Parser, Debug)]
20#[command(
21    name = "testing-conventions",
22    version,
23    about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
24    long_about = None,
25)]
26pub struct Cli {
27    #[command(subcommand)]
28    command: Option<Command>,
29}
30
31#[derive(Subcommand, Debug)]
32enum Command {
33    /// Check the repository against its testing-conventions config.
34    Check,
35    /// Unit-test conventions.
36    Unit {
37        #[command(subcommand)]
38        rule: UnitRule,
39    },
40    /// Integration-test conventions.
41    Integration {
42        #[command(subcommand)]
43        rule: IntegrationRule,
44    },
45    /// Packaging conventions: test files must not ship in the built artifact.
46    Packaging {
47        /// Root of the built artifact to inspect (e.g. an unpacked wheel or `dist/`).
48        path: PathBuf,
49        /// Language convention to enforce (required).
50        #[arg(long, value_enum)]
51        language: colocated_test::Language,
52    },
53    /// Workflow guard (private — hidden from `--help`, #191): every `testing-conventions`
54    /// invocation in a CI workflow must name a subcommand this binary still exposes
55    /// (guards the `@v0` path, #92). Run from our own CI, not a documented consumer command;
56    /// it stays in the binary because the guard needs the in-process command tree.
57    #[command(hide = true)]
58    Workflow {
59        /// Workflow file (or a directory of them) to scan.
60        path: PathBuf,
61    },
62    /// End-to-end-test conventions.
63    E2e {
64        #[command(subcommand)]
65        command: E2eCommand,
66    },
67}
68
69/// Rules enforced on the unit-test suite (the README's "Unit" taxonomy).
70#[derive(Subcommand, Debug)]
71enum UnitRule {
72    /// Check that every source file has a colocated, matching-named unit test
73    /// (tree-wide presence). With `--base`, additionally run the commit-scoped
74    /// `co-change` check over `<base>...HEAD` (#33): a modified or deleted source
75    /// whose colocated test is not in the diff fails. Presence always runs;
76    /// `--base` *adds* the diff-scoped check.
77    ColocatedTest {
78        /// Directory to scan recursively.
79        path: PathBuf,
80        /// Language convention to enforce (required).
81        #[arg(long, value_enum)]
82        language: colocated_test::Language,
83        /// Opt-in commit-scoped co-change check (#33): diff `<base>...HEAD` and
84        /// also flag a modified or deleted source whose colocated test didn't
85        /// co-change. Absent means presence-only — there is no default. Python /
86        /// TypeScript only: `--base --language rust` is rejected (inline
87        /// `#[cfg(test)]` units have no sibling test to go stale).
88        #[arg(long)]
89        base: Option<String>,
90        /// testing-conventions config file providing the `exempt` list. Optional:
91        /// if the file is absent, no files are exempt.
92        #[arg(long, default_value = "testing-conventions.toml")]
93        config: PathBuf,
94    },
95    /// Check that the unit suite meets the configured coverage floor. With
96    /// `--base`, the same configured floor is measured over the `<base>...HEAD`
97    /// diff (the changed lines) instead of the whole tree (#162) — a changed line
98    /// below the floor fails, no matter how small the diff.
99    Coverage {
100        /// Directory whose unit suite is run and measured.
101        path: PathBuf,
102        /// Language convention to enforce (required).
103        #[arg(long, value_enum)]
104        language: colocated_test::Language,
105        /// Opt-in diff-scoped coverage (#162): diff `<base>...HEAD` and measure the
106        /// configured floor over only the changed lines, instead of the whole tree.
107        /// Absent means whole-tree — there is no default. This is the patch-scoped
108        /// check the old `unit patch-coverage` command did, re-homed onto the floor
109        /// it shares.
110        #[arg(long)]
111        base: Option<String>,
112        /// testing-conventions config file with the coverage thresholds and
113        /// `exempt` list. Optional: if the file — or its `[<language>].coverage`
114        /// table — is absent, the language's sane default floor is used and
115        /// nothing is exempt.
116        #[arg(long, default_value = "testing-conventions.toml")]
117        config: PathBuf,
118    },
119    /// Lint unit test files for isolation: mock every collaborator (Python, TypeScript, Rust).
120    Lint {
121        /// Crate root / source dir to scan recursively.
122        path: PathBuf,
123        /// Language convention to enforce (required).
124        #[arg(long, value_enum)]
125        language: isolation::Language,
126        /// testing-conventions config file providing the `exempt` list (waivers).
127        /// Optional: if the file is absent, nothing is waived.
128        #[arg(long, default_value = "testing-conventions.toml")]
129        config: PathBuf,
130    },
131    /// Run mutation testing over the unit suite and fail on any surviving mutant not
132    /// lifted by a `mutation` exemption — the rung above coverage (#201). The gate is
133    /// on by default (no report-only mode). All three languages (Python, TypeScript,
134    /// Rust) are at parity and wired into the reusable workflow as a diff-scoped,
135    /// PR-only job (#204).
136    Mutation {
137        /// Crate whose unit suite is mutated.
138        path: PathBuf,
139        /// Language convention to enforce (required): `python`, `typescript`, or `rust`.
140        #[arg(long, value_enum)]
141        language: colocated_test::Language,
142        /// Opt-in diff-scoping (#201): restrict to mutants on lines a `<base>...HEAD`
143        /// diff added or modified, via cargo-mutants' `--in-diff`. Absent means the
144        /// whole crate (slower).
145        #[arg(long)]
146        base: Option<String>,
147        /// testing-conventions config file providing the `exempt` list. Optional:
148        /// absent means nothing is exempt (every survivor must be killed).
149        #[arg(long, default_value = "testing-conventions.toml")]
150        config: PathBuf,
151        /// Path to the bundled TypeScript mutation adapter (`dist/mutation/main.js`), used
152        /// only by `--language typescript`. The npm launcher appends it; hidden because a
153        /// consumer never sets it by hand.
154        #[arg(long = "ts-mutation-adapter", hide = true)]
155        ts_adapter: Option<PathBuf>,
156    },
157}
158
159/// Languages the integration-test lints support — its own set (Python,
160/// TypeScript, Rust), distinct from the file-pairing `colocated_test::Language`,
161/// so adding Rust here doesn't touch the colocated-test/coverage rules.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
163pub enum IntegrationLintLanguage {
164    /// Python test files (`*_test.py`, `test_*.py`, `conftest.py`).
165    #[value(name = "python")]
166    Python,
167    /// TypeScript test files (`*.test.{ts,tsx,mts,cts}`).
168    #[value(name = "typescript")]
169    TypeScript,
170    /// Rust integration crates under `tests/`.
171    #[value(name = "rust")]
172    Rust,
173}
174
175/// Lints enforced on integration tests (mocking mechanism & style, and more to
176/// come). The README's "Integration" taxonomy.
177#[derive(Subcommand, Debug)]
178enum IntegrationRule {
179    /// Lint integration test files for mocking mechanism & style (Python, TypeScript, Rust).
180    Lint {
181        /// Directory to scan recursively for test files.
182        path: PathBuf,
183        /// Language convention to enforce (required).
184        #[arg(long, value_enum)]
185        language: IntegrationLintLanguage,
186        /// testing-conventions config file providing the `exempt` list (waivers).
187        /// Optional: if the file is absent, nothing is waived.
188        #[arg(long, default_value = "testing-conventions.toml")]
189        config: PathBuf,
190    },
191}
192
193/// E2E attestation commands (#17): record a local e2e run and (later, #68)
194/// verify in CI that the latest code commit is attested.
195#[derive(Subcommand, Debug)]
196enum E2eCommand {
197    /// Run the e2e suite and write a committed attestation naming the current commit.
198    Attest {
199        /// The e2e command to run (e.g. `pnpm run e2e`), executed via the shell.
200        command: String,
201    },
202    /// Verify the committed attestation names the latest code commit (the CI gate).
203    Verify,
204}
205
206pub fn run<I, T>(args: I) -> anyhow::Result<i32>
207where
208    I: IntoIterator<Item = T>,
209    T: Into<std::ffi::OsString> + Clone,
210{
211    let cli = Cli::try_parse_from(args)?;
212    match cli.command {
213        // The config-driven `check` umbrella isn't wired yet; the scaffold
214        // proves the wiring while individual rules land under their test-kind
215        // group (e.g. `unit colocated-test`).
216        Some(Command::Check) | None => Ok(0),
217        Some(Command::Unit { rule }) => match rule {
218            UnitRule::ColocatedTest {
219                path,
220                language,
221                base,
222                config,
223            } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
224            UnitRule::Coverage {
225                path,
226                language,
227                base,
228                config,
229            } => run_unit_coverage(&path, language, base.as_deref(), &config),
230            UnitRule::Lint {
231                path,
232                language,
233                config,
234            } => run_unit_lint(&path, language, &config),
235            UnitRule::Mutation {
236                path,
237                language,
238                base,
239                config,
240                ts_adapter,
241            } => run_unit_mutation(
242                &path,
243                language,
244                base.as_deref(),
245                &config,
246                ts_adapter.as_deref(),
247            ),
248        },
249        Some(Command::Integration { rule }) => match rule {
250            IntegrationRule::Lint {
251                path,
252                language,
253                config,
254            } => run_integration_lint(&path, language, &config),
255        },
256        Some(Command::Packaging { path, language }) => run_packaging(&path, language),
257        Some(Command::Workflow { path }) => run_workflow(&path),
258        Some(Command::E2e { command }) => match command {
259            E2eCommand::Attest { command } => run_e2e_attest(&command),
260            E2eCommand::Verify => run_e2e_verify(),
261        },
262    }
263}
264
265/// The binary's own clap command tree — the source of truth for which subcommands
266/// it exposes. The `workflow` guard (#92) checks a workflow's invocations against
267/// it, so a renamed or removed subcommand is caught the moment they diverge.
268pub fn command() -> clap::Command {
269    Cli::command()
270}
271
272/// Run the unit colocated-test check over `root` for `language`. Always runs the
273/// tree-wide **presence** check (every source file has its colocated test; Rust:
274/// an inline `#[cfg(test)]` module). When `base` is `Some`, *additionally* runs the
275/// commit-scoped **co-change** check (#33) over `<base>...HEAD` — a modified or
276/// deleted source whose colocated test didn't co-change — and the run fails if
277/// either check does. Returns `0` only when both pass.
278///
279/// Presence loads the `colocated-test`-rule exemptions and co-change the
280/// `co-change`-rule exemptions from the config at `config_path` (no config file →
281/// no exemptions). `--base` rejects `--language rust`: Rust units are inline
282/// `#[cfg(test)]` in the same file, so a sibling test can't go stale (presence,
283/// without `--base`, still supports Rust).
284fn run_unit_colocated_test(
285    root: &Path,
286    language: colocated_test::Language,
287    base: Option<&str>,
288    config_path: &Path,
289) -> anyhow::Result<i32> {
290    // `--base` carries the co-change check, which rejects Rust the same way the
291    // standalone `unit co-change` did — before any work, so the message matches.
292    if base.is_some() && language == colocated_test::Language::Rust {
293        anyhow::bail!(
294            "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
295             units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
296        );
297    }
298    let presence_clean = report_colocated_presence(root, language, config_path)?;
299    let co_change_clean = match base {
300        Some(base) => report_co_change(root, base, language, config_path)?,
301        None => true,
302    };
303    Ok(if presence_clean && co_change_clean {
304        0
305    } else {
306        1
307    })
308}
309
310/// The tree-wide colocated-test **presence** check: every source file under `root`
311/// has its colocated unit test (Rust: an inline `#[cfg(test)]` module). Prints each
312/// orphan to stderr and returns `Ok(false)` when any are found, `Ok(true)` when the
313/// tree is clean. The `colocated-test`-rule exemptions from the config at
314/// `config_path` lift a file (no config file → nothing exempt).
315fn report_colocated_presence(
316    root: &Path,
317    language: colocated_test::Language,
318    config_path: &Path,
319) -> anyhow::Result<bool> {
320    let exempt = colocated_test_exemptions(root, language, config_path)?;
321    let orphans = match language {
322        // Rust units are inline `#[cfg(test)]` modules, so "colocated" means a test
323        // module in the same file, not a sibling file (#40).
324        colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
325        _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
326    };
327    if orphans.is_empty() {
328        return Ok(true);
329    }
330    let (label, summary) = match language {
331        colocated_test::Language::Rust => (
332            "missing inline `#[cfg(test)]` tests",
333            "source file(s) with testable code but no inline `#[cfg(test)]` module \
334             (add an inline test module, or an `exempt` entry with a reason)",
335        ),
336        _ => (
337            "missing colocated unit test",
338            "source file(s) missing a colocated unit test \
339             (add a colocated test, or an `exempt` entry with a reason)",
340        ),
341    };
342    for orphan in &orphans {
343        eprintln!("{label}: {}", orphan.display());
344    }
345    eprintln!("error: {} {summary}", orphans.len());
346    Ok(false)
347}
348
349/// The `colocated-test`-rule exempt paths for `language`, resolved (and validated)
350/// from the config at `config_path`. A missing config file means no exemptions —
351/// the check still runs, just with nothing exempted.
352fn colocated_test_exemptions(
353    root: &Path,
354    language: colocated_test::Language,
355    config_path: &Path,
356) -> anyhow::Result<std::collections::BTreeSet<String>> {
357    if !config_path.exists() {
358        return Ok(std::collections::BTreeSet::new());
359    }
360    let config = config::load_config(config_path)?;
361    config::resolve_exempt(
362        root,
363        config.exemptions(language),
364        config::Rule::ColocatedTest,
365    )
366}
367
368/// The commit-scoped **co-change** check (#33) over `root`, diffing `<base>...HEAD`:
369/// every modified or deleted source whose colocated test didn't co-change. Prints
370/// each stale source to stderr and returns `Ok(false)` when any are found,
371/// `Ok(true)` when clean.
372///
373/// Loads the `co-change`-rule exemptions from the config at `config_path` (no
374/// config file → no exemptions); an exempt source needn't co-change. The caller
375/// rejects `--language rust` before this runs: Rust units are inline `#[cfg(test)]`
376/// in the same file, so a sibling test can't go stale.
377fn report_co_change(
378    root: &Path,
379    base: &str,
380    language: colocated_test::Language,
381    config_path: &Path,
382) -> anyhow::Result<bool> {
383    let exempt = co_change_exemptions(root, language, config_path)?;
384    let stale = co_change::stale_sources(root, base, language, &exempt)?;
385    if stale.is_empty() {
386        return Ok(true);
387    }
388    for source in &stale {
389        eprintln!(
390            "source changed without its colocated test: {}",
391            source.display()
392        );
393    }
394    eprintln!(
395        "error: {} source file(s) changed without their colocated test co-changing \
396         (update the test, or add an `exempt` entry with a reason)",
397        stale.len()
398    );
399    Ok(false)
400}
401
402/// The `co-change`-rule exempt paths for `language`, resolved (and validated)
403/// from the config at `config_path`. A missing config file means no exemptions —
404/// every changed source must co-change its test.
405fn co_change_exemptions(
406    root: &Path,
407    language: colocated_test::Language,
408    config_path: &Path,
409) -> anyhow::Result<std::collections::BTreeSet<String>> {
410    if !config_path.exists() {
411        return Ok(std::collections::BTreeSet::new());
412    }
413    let config = config::load_config(config_path)?;
414    config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
415}
416
417/// Split a resolved exempt-scope map (#226) into the whole-file paths and the
418/// line-scoped sets — the two shapes the `coverage` / `mutation` measure functions
419/// take. A [`config::LineScope::WholeFile`] becomes a path in the first vec; a
420/// [`config::LineScope::Lines`] a `path → lines` entry in the second map.
421fn split_scopes(
422    scopes: std::collections::BTreeMap<String, config::LineScope>,
423) -> (
424    Vec<String>,
425    std::collections::BTreeMap<String, std::collections::BTreeSet<u32>>,
426) {
427    let mut whole_file = Vec::new();
428    let mut line_scoped = std::collections::BTreeMap::new();
429    for (path, scope) in scopes {
430        match scope {
431            config::LineScope::WholeFile => whole_file.push(path),
432            config::LineScope::Lines(lines) => {
433                line_scoped.insert(path, lines);
434            }
435        }
436    }
437    (whole_file, line_scoped)
438}
439
440/// Run the unit-test coverage check over `root` for `language`, enforcing the
441/// floor from the config at `config_path`. Returns `0` when the floor is met,
442/// `1` otherwise.
443///
444/// With `base` set, the same configured floor is measured over the
445/// `<base>...HEAD` diff (the changed lines) rather than the whole tree (#162),
446/// via the diff-scoped [`patch_coverage::measure`] / `measure_typescript` /
447/// `measure_rust`; without it, the whole-tree [`coverage::measure`] family runs.
448///
449/// Coverage is zero-config by default for Python and TypeScript (#80): a missing
450/// config file — or a config with no `[<language>].coverage` table — falls back to
451/// the language's sane default floor ([`config::PythonCoverage::default`] /
452/// [`config::TypeScriptCoverage::default`]), the same way `unit colocated-test` and
453/// `integration lint` treat an absent config as "nothing exempt". A present
454/// `coverage` table overrides the default; `coverage`-rule exemptions still apply.
455/// Rust (#206) is zero-config too: a missing `[rust].coverage` table falls back to
456/// [`config::RustCoverage::default`] (`lines = 100`, `regions` opt-in, no branch).
457fn run_unit_coverage(
458    root: &Path,
459    language: colocated_test::Language,
460    base: Option<&str>,
461    config_path: &Path,
462) -> anyhow::Result<i32> {
463    let config = if config_path.exists() {
464        config::load_config(config_path)?
465    } else {
466        config::Config::default()
467    };
468    let outcome = match language {
469        colocated_test::Language::Python => {
470            let python = config.python.unwrap_or_default();
471            let coverage = python.coverage.unwrap_or_default();
472            let thresholds = coverage::Thresholds {
473                fail_under: coverage.fail_under,
474                branch: coverage.branch,
475            };
476            let (omit, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
477                root,
478                &python.exempt,
479                config::Rule::Coverage,
480            )?);
481            match base {
482                Some(base) => {
483                    patch_coverage::measure(root, base, thresholds, &omit, &exempt_lines)?
484                }
485                None if exempt_lines.is_empty() => coverage::measure(root, thresholds, &omit)?,
486                None => {
487                    patch_coverage::measure_line_exempt(root, thresholds, &omit, &exempt_lines)?
488                }
489            }
490        }
491        colocated_test::Language::TypeScript => {
492            let typescript = config.typescript.unwrap_or_default();
493            let coverage = typescript.coverage.unwrap_or_default();
494            let thresholds = coverage::TypeScriptThresholds {
495                lines: coverage.lines,
496                branches: coverage.branches,
497                functions: coverage.functions,
498                statements: coverage.statements,
499            };
500            let (exclude, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
501                root,
502                &typescript.exempt,
503                config::Rule::Coverage,
504            )?);
505            match base {
506                Some(base) => patch_coverage::measure_typescript(
507                    root,
508                    base,
509                    thresholds,
510                    &exclude,
511                    &exempt_lines,
512                )?,
513                None if exempt_lines.is_empty() => {
514                    coverage::measure_typescript(root, thresholds, &exclude)?
515                }
516                None => patch_coverage::measure_line_exempt_typescript(
517                    root,
518                    thresholds,
519                    &exclude,
520                    &exempt_lines,
521                )?,
522            }
523        }
524        colocated_test::Language::Rust => {
525            let rust = config.rust.unwrap_or_default();
526            // Zero-config (#206): a missing `[rust].coverage` table falls back to the
527            // default Rust floor (`lines = 100`, with `regions` opt-in and no branch
528            // component) — matching Python/TypeScript — rather than erroring for a
529            // required table. A present table overrides it.
530            let coverage = rust.coverage.unwrap_or_default();
531            let thresholds = coverage::RustThresholds {
532                regions: coverage.regions,
533                lines: coverage.lines,
534            };
535            let (ignore, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
536                root,
537                &rust.exempt,
538                config::Rule::Coverage,
539            )?);
540            match base {
541                Some(base) => {
542                    patch_coverage::measure_rust(root, base, thresholds, &ignore, &exempt_lines)?
543                }
544                None if exempt_lines.is_empty() => {
545                    coverage::measure_rust(root, thresholds, &ignore)?
546                }
547                None => patch_coverage::measure_line_exempt_rust(
548                    root,
549                    thresholds,
550                    &ignore,
551                    &exempt_lines,
552                )?,
553            }
554        }
555    };
556    match outcome {
557        coverage::Outcome::Pass => Ok(0),
558        coverage::Outcome::Fail(reason) => {
559            eprintln!("error: coverage check failed — {reason}");
560            Ok(1)
561        }
562    }
563}
564
565/// Run `unit mutation` over `root` (#201, #202): run the per-language engine and fail
566/// on any surviving mutant not lifted by a `mutation` exemption.
567///
568/// The gate is **on by default and binary** — "no *unexplained* surviving mutant":
569/// every survivor must be killed with an assertion, or lifted by a reason-required
570/// `[[<language>.exempt]] rules = ["mutation"]` for an equivalent / deliberately-defensive
571/// mutation. There is no percentage floor (equivalent mutants make one unreachable)
572/// and no report-only mode — the only loosening is a reasoned, per-file exemption.
573/// All three languages are wired: Rust (cargo-mutants), TypeScript (Stryker), and
574/// Python (cosmic-ray). `--base` scopes the run to the diff.
575fn run_unit_mutation(
576    root: &Path,
577    language: colocated_test::Language,
578    base: Option<&str>,
579    config_path: &Path,
580    ts_adapter: Option<&Path>,
581) -> anyhow::Result<i32> {
582    let config = if config_path.exists() {
583        config::load_config(config_path)?
584    } else {
585        config::Config::default()
586    };
587    let survivors = match language {
588        colocated_test::Language::Rust => {
589            let rust = config.rust.unwrap_or_default();
590            let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
591                root,
592                &rust.exempt,
593                config::Rule::Mutation,
594            )?);
595            mutation::measure_rust(root, &exempt, &exempt_lines, base)?
596        }
597        colocated_test::Language::TypeScript => {
598            let typescript = config.typescript.unwrap_or_default();
599            let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
600                root,
601                &typescript.exempt,
602                config::Rule::Mutation,
603            )?);
604            // The npm launcher appends `--ts-mutation-adapter`; its absence means the binary
605            // was run directly, not through the published CLI.
606            let adapter = ts_adapter.ok_or_else(|| {
607                anyhow::anyhow!(
608                    "the TypeScript mutation adapter path is required: pass \
609                     `--ts-mutation-adapter <path>`. The npm `testing-conventions` CLI appends it \
610                     automatically — run the rule through that CLI, not the raw binary."
611                )
612            })?;
613            mutation::measure_typescript(root, &exempt, &exempt_lines, base, adapter)?
614        }
615        colocated_test::Language::Python => {
616            let python = config.python.unwrap_or_default();
617            let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
618                root,
619                &python.exempt,
620                config::Rule::Mutation,
621            )?);
622            mutation::measure_python(root, &exempt, &exempt_lines, base)?
623        }
624    };
625    if survivors.is_empty() {
626        println!("unit mutation: no surviving mutants — every mutation was caught");
627        return Ok(0);
628    }
629
630    eprintln!(
631        "error: {} unexplained surviving mutant(s) — kill each with an assertion, or lift an \
632         equivalent/defensive one with a reason-required `[[<language>.exempt]] rules = [\"mutation\"]`:",
633        survivors.len()
634    );
635    for survivor in &survivors {
636        eprintln!(
637            "  {}:{}: {}",
638            survivor.file, survivor.line, survivor.description
639        );
640    }
641    Ok(1)
642}
643
644/// Run the `unit lint` check over `root` for `language` — the unit-suite
645/// isolation lints (`unmocked-collaborator`, `untyped-mock`, `no-out-of-module-call`,
646/// `no-out-of-module-import`) — printing each violation to stderr as
647/// `path:line: rule — message` and returning `1` when any are found, `0` otherwise.
648fn run_unit_lint(
649    root: &Path,
650    language: isolation::Language,
651    config_path: &Path,
652) -> anyhow::Result<i32> {
653    let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
654        isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
655        isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
656            c.exemptions(colocated_test::Language::TypeScript)
657        }),
658        isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
659            c.exemptions(colocated_test::Language::Python)
660        }),
661    };
662    let violations = apply_waivers(raw, root, config_path, select)?;
663    if violations.is_empty() {
664        return Ok(0);
665    }
666    for v in &violations {
667        eprintln!(
668            "{}:{}: {} — {}",
669            v.file.display(),
670            v.line,
671            v.rule,
672            v.message
673        );
674    }
675    eprintln!("error: {} isolation violation(s)", violations.len());
676    Ok(1)
677}
678
679/// Run the integration-test lints over `root` for `language`, printing each
680/// violation to stderr as `path:line: rule — message` and returning `1` when any
681/// are found, `0` otherwise.
682fn run_integration_lint(
683    root: &Path,
684    language: IntegrationLintLanguage,
685    config_path: &Path,
686) -> anyhow::Result<i32> {
687    let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
688        IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
689            c.exemptions(colocated_test::Language::Python)
690        }),
691        IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
692            c.exemptions(colocated_test::Language::TypeScript)
693        }),
694        IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
695            c.rust_exemptions()
696        }),
697    };
698    let violations = apply_waivers(raw, root, config_path, select)?;
699    if violations.is_empty() {
700        return Ok(0);
701    }
702    for v in &violations {
703        eprintln!(
704            "{}:{}: {} — {}",
705            v.file.display(),
706            v.line,
707            v.rule,
708            v.message
709        );
710    }
711    eprintln!("error: {} lint violation(s)", violations.len());
712    Ok(1)
713}
714
715/// Selects a language's `[[<lang>.exempt]]` table from a loaded config — the one
716/// varying piece between the `unit lint` and `integration lint` waiver paths.
717type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
718
719/// Drop the violations waived by the config's `exempt` list (#32/#102). A
720/// violation is waived when its `rule` is a known [`config::Rule`] and its
721/// `root`-relative path is exempt for that rule. `exemptions` selects the
722/// language's `[[<lang>.exempt]]` table from the loaded config. A missing config
723/// file waives nothing; a reason-less or stale entry errors (via `load_config` /
724/// `resolve_exempt`), so the escape hatch can't silently rot.
725fn apply_waivers(
726    violations: Vec<lint::Violation>,
727    root: &Path,
728    config_path: &Path,
729    exemptions: ExemptSelect,
730) -> anyhow::Result<Vec<lint::Violation>> {
731    use std::collections::hash_map::Entry;
732
733    if !config_path.exists() {
734        return Ok(violations);
735    }
736    let config = config::load_config(config_path)?;
737    let exempt = exemptions(&config);
738    // Resolve each rule's exempt set once (and surface a stale entry as an error).
739    let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
740        std::collections::HashMap::new();
741    let mut kept = Vec::new();
742    for violation in violations {
743        let waived = match config::Rule::from_id(violation.rule) {
744            Some(rule) => {
745                let exempt_paths = match resolved.entry(rule) {
746                    Entry::Occupied(entry) => entry.into_mut(),
747                    Entry::Vacant(entry) => {
748                        entry.insert(config::resolve_exempt(root, exempt, rule)?)
749                    }
750                };
751                violation
752                    .file
753                    .strip_prefix(root)
754                    .ok()
755                    .map(|rel| rel.to_string_lossy().replace('\\', "/"))
756                    .is_some_and(|rel| exempt_paths.contains(&rel))
757            }
758            None => false,
759        };
760        if !waived {
761            kept.push(violation);
762        }
763    }
764    Ok(kept)
765}
766
767/// Run the packaging check: inspect the built artifact at `artifact` for test
768/// files that must not ship (README "Packaging"), per `language`'s test-file
769/// globs.
770///
771/// `artifact` is either an already-unpacked directory or a packed artifact the
772/// rule unpacks itself — a Python wheel (`.whl`) today; the TypeScript (#73) and
773/// Rust (#74) archives follow. Returns `0` when no test file is present, `1`
774/// otherwise (after printing each offending path, relative to the artifact root).
775fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
776    let globs = match language {
777        colocated_test::Language::Python => vec!["*_test.py".to_string()],
778        colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
779        // `#[cfg(test)]` units compile out for free; the only thing to keep out of
780        // the `.crate` source tarball is the crate-root integration `tests/` dir.
781        colocated_test::Language::Rust => vec!["tests/".to_string()],
782    };
783    let offenders = packaging::inspect(artifact, &globs)?;
784    if offenders.is_empty() {
785        return Ok(0);
786    }
787    for offender in &offenders {
788        eprintln!("test file in built artifact: {}", offender.display());
789    }
790    eprintln!(
791        "error: {} test file(s) present in the built artifact \
792         (they must be excluded from packaging)",
793        offenders.len()
794    );
795    Ok(1)
796}
797
798/// Run the workflow guard over `path` (a workflow file or directory): flag every
799/// `testing-conventions` invocation that names a subcommand this binary no longer
800/// exposes, printing each as `path:line: rule — message` and returning `1` when any
801/// are found, `0` otherwise.
802fn run_workflow(path: &Path) -> anyhow::Result<i32> {
803    let violations = workflow::check(path, &command())?;
804    if violations.is_empty() {
805        return Ok(0);
806    }
807    for v in &violations {
808        eprintln!(
809            "{}:{}: {} — {}",
810            v.file.display(),
811            v.line,
812            v.rule,
813            v.message
814        );
815    }
816    eprintln!(
817        "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
818        violations.len()
819    );
820    Ok(1)
821}
822
823/// Run `command` as an e2e suite and write a committed attestation naming the
824/// current commit (#67). Force-runs: the attestation is written regardless of
825/// the command's exit code, so this exits `0` once the attestation is recorded.
826fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
827    let repo = std::env::current_dir()?;
828    let attestation = e2e::attest(&repo, command)?;
829    println!(
830        "e2e attestation recorded for commit {} (command exited {})",
831        attestation.commit, attestation.exit_code
832    );
833    Ok(0)
834}
835
836/// Verify the committed e2e attestation names the latest code commit (#68) — the
837/// CI side of the nudge. Exits `0` when fresh; otherwise prints the actionable
838/// hint and exits `1`. Never runs e2e, never judges the recorded run.
839fn run_e2e_verify() -> anyhow::Result<i32> {
840    let repo = std::env::current_dir()?;
841    match e2e::verify(&repo)? {
842        e2e::Verification::Fresh => Ok(0),
843        e2e::Verification::Missing => {
844            eprintln!(
845                "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
846            );
847            Ok(1)
848        }
849        e2e::Verification::Stale { attested, latest } => {
850            eprintln!(
851                "e2e attestation out of date: attested {}, latest code commit {} — \
852                 run `testing-conventions e2e attest '<your e2e command>'`",
853                &attested[..attested.len().min(7)],
854                &latest[..latest.len().min(7)]
855            );
856            Ok(1)
857        }
858    }
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864
865    #[test]
866    fn no_args_returns_ok_zero() {
867        assert_eq!(run(["testing-conventions"]).unwrap(), 0);
868    }
869
870    #[test]
871    fn check_returns_ok_zero() {
872        assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
873    }
874
875    #[test]
876    fn unknown_flag_errors() {
877        assert!(run(["testing-conventions", "--bogus"]).is_err());
878    }
879
880    #[test]
881    fn help_flag_returns_clap_display_help() {
882        let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
883        let clap_err = err
884            .downcast_ref::<clap::Error>()
885            .expect("error should be a clap::Error");
886        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
887    }
888
889    #[test]
890    fn version_flag_returns_clap_display_version() {
891        let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
892        let clap_err = err
893            .downcast_ref::<clap::Error>()
894            .expect("error should be a clap::Error");
895        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
896    }
897}