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