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