Skip to main content

testing_conventions/
lib.rs

1pub mod agents;
2pub mod co_change;
3pub mod colocated_test;
4pub mod config;
5pub mod coverage;
6pub mod e2e;
7pub mod isolation;
8pub mod lint;
9pub mod mutation;
10pub mod one_function;
11pub mod packaging;
12pub mod patch_coverage;
13pub mod tiers;
14pub mod ts;
15pub mod violation;
16pub mod workflow;
17
18use std::path::{Path, PathBuf};
19
20use clap::{CommandFactory, Parser, Subcommand};
21
22#[derive(Parser, Debug)]
23#[command(
24    name = "testing-conventions",
25    version,
26    about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
27    long_about = None,
28)]
29pub struct Cli {
30    #[command(subcommand)]
31    command: Option<Command>,
32}
33
34#[derive(Subcommand, Debug)]
35enum Command {
36    /// Write the testing contract into the repository's agent context file:
37    /// a marker-delimited, hash-versioned block in `AGENTS.md` that a
38    /// coding agent reads before writing code. Idempotent — re-running
39    /// refreshes the owned region and touches nothing outside it.
40    Install {
41        /// The agent context file to manage.
42        #[arg(default_value = "AGENTS.md")]
43        path: PathBuf,
44    },
45    /// Unit-test conventions.
46    Unit {
47        #[command(subcommand)]
48        rule: UnitRule,
49    },
50    /// Integration-test conventions.
51    Integration {
52        #[command(subcommand)]
53        rule: IntegrationRule,
54    },
55    /// Packaging conventions: test files must not ship in the built artifact.
56    Packaging {
57        /// Root of the built artifact to inspect (e.g. an unpacked wheel or `dist/`).
58        path: PathBuf,
59        /// Language convention to enforce (required).
60        #[arg(long, value_enum)]
61        language: colocated_test::Language,
62    },
63    /// Workflow guard (private — hidden from `--help`): every `testing-conventions`
64    /// invocation in a CI workflow must name a subcommand this binary still exposes
65    /// (guards the `@v0` path). Run from our own CI, not a documented consumer command;
66    /// it stays in the binary because the guard needs the in-process command tree.
67    #[command(hide = true)]
68    Workflow {
69        /// Workflow file (or a directory of them) to scan.
70        path: PathBuf,
71    },
72    /// End-to-end-test conventions.
73    E2e {
74        #[command(subcommand)]
75        command: E2eCommand,
76    },
77}
78
79#[derive(Subcommand, Debug)]
80enum UnitRule {
81    /// Check that every source file has a colocated, matching-named unit test
82    /// (tree-wide presence). With `--base`, additionally run the commit-scoped
83    /// `co-change` check over `<base>...HEAD`: a modified or deleted source
84    /// whose colocated test is not in the diff fails. Presence always runs;
85    /// `--base` *adds* the diff-scoped check.
86    ColocatedTest {
87        /// Directory to scan recursively.
88        path: PathBuf,
89        /// Language convention to enforce (required).
90        #[arg(long, value_enum)]
91        language: colocated_test::Language,
92        /// Opt-in commit-scoped co-change check: diff `<base>...HEAD` and
93        /// also flag a modified or deleted source whose colocated test didn't
94        /// co-change. Absent means presence-only — there is no default. Python /
95        /// TypeScript only: `--base --language rust` is rejected (inline
96        /// `#[cfg(test)]` units have no sibling test to go stale).
97        #[arg(long)]
98        base: Option<String>,
99        /// testing-conventions config file providing the `exempt` list. Optional:
100        /// if the file is absent, no files are exempt.
101        #[arg(long, default_value = "testing-conventions.toml")]
102        config: PathBuf,
103    },
104    /// Check that the unit suite meets the configured coverage floor. With
105    /// `--base`, the same configured floor is measured over the `<base>...HEAD`
106    /// diff (the changed lines) instead of the whole tree — a changed line
107    /// below the floor fails, no matter how small the diff.
108    Coverage {
109        /// Directory whose unit suite is run and measured.
110        path: PathBuf,
111        /// Language convention to enforce (required).
112        #[arg(long, value_enum)]
113        language: colocated_test::Language,
114        /// Opt-in diff-scoped coverage: diff `<base>...HEAD` and measure the
115        /// configured floor over only the changed lines, instead of the whole tree.
116        /// Absent means whole-tree — there is no default. This is the patch-scoped
117        /// check the old `unit patch-coverage` command did, re-homed onto the floor
118        /// it shares.
119        #[arg(long)]
120        base: Option<String>,
121        /// testing-conventions config file with the coverage thresholds and
122        /// `exempt` list. Optional: if the file — or its `[<language>].coverage`
123        /// table — is absent, the language's sane default floor is used and
124        /// nothing is exempt.
125        #[arg(long, default_value = "testing-conventions.toml")]
126        config: PathBuf,
127    },
128    /// Check that no source file holds more than one module-scope function whose body
129    /// runs longer than the configured threshold. Trivial functions — at or under the
130    /// threshold — share a file freely.
131    OneFunctionPerFile {
132        /// Directory to scan recursively.
133        path: PathBuf,
134        /// Language convention to enforce (required).
135        #[arg(long, value_enum)]
136        language: colocated_test::Language,
137        /// testing-conventions config file providing the `max_lines` threshold and the
138        /// `exempt` list. Optional: if the file — or its
139        /// `[<language>].one_function_per_file` table — is absent, the default threshold
140        /// of one line applies and nothing is exempt.
141        #[arg(long, default_value = "testing-conventions.toml")]
142        config: PathBuf,
143    },
144    /// Lint unit test files for isolation: mock every collaborator (Python, TypeScript, Rust).
145    Lint {
146        /// Crate root / source dir to scan recursively.
147        path: PathBuf,
148        /// Language convention to enforce (required).
149        #[arg(long, value_enum)]
150        language: isolation::Language,
151        /// testing-conventions config file providing the `exempt` list (waivers).
152        /// Optional: if the file is absent, nothing is waived.
153        #[arg(long, default_value = "testing-conventions.toml")]
154        config: PathBuf,
155    },
156    /// Run mutation testing over the unit suite and fail on any surviving mutant not
157    /// lifted by a `mutation` exemption — the rung above coverage. The gate is
158    /// on by default (no report-only mode). All three languages (Python, TypeScript,
159    /// Rust) are at parity and wired into the reusable workflow as a diff-scoped,
160    /// PR-only job.
161    Mutation {
162        /// Crate whose unit suite is mutated.
163        path: PathBuf,
164        /// Language convention to enforce (required): `python`, `typescript`, or `rust`.
165        #[arg(long, value_enum)]
166        language: colocated_test::Language,
167        /// Opt-in diff-scoping: restrict to mutants on lines a `<base>...HEAD`
168        /// diff added or modified, via cargo-mutants' `--in-diff`. Absent means the
169        /// whole crate (slower).
170        #[arg(long)]
171        base: Option<String>,
172        /// testing-conventions config file providing the `exempt` list. Optional:
173        /// absent means nothing is exempt (every survivor must be killed).
174        #[arg(long, default_value = "testing-conventions.toml")]
175        config: PathBuf,
176        /// Path to the bundled TypeScript mutation adapter (`dist/mutation/main.js`), used
177        /// only by `--language typescript`. The npm launcher appends it; hidden because a
178        /// consumer never sets it by hand.
179        #[arg(long = "ts-mutation-adapter", hide = true)]
180        ts_adapter: Option<PathBuf>,
181    },
182}
183
184/// Languages the integration-test lints support.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
186pub enum IntegrationLintLanguage {
187    /// Python test files (`*_test.py`, `test_*.py`, `conftest.py`).
188    #[value(name = "python")]
189    Python,
190    /// TypeScript test files (`*.test.{ts,tsx,mts,cts}`).
191    #[value(name = "typescript")]
192    TypeScript,
193    /// Rust integration crates under `tests/`.
194    #[value(name = "rust")]
195    Rust,
196}
197
198#[derive(Subcommand, Debug)]
199enum IntegrationRule {
200    /// Lint integration test files for mocking mechanism & style (Python, TypeScript, Rust).
201    Lint {
202        /// Directory to scan recursively for test files.
203        path: PathBuf,
204        /// Language convention to enforce (required).
205        #[arg(long, value_enum)]
206        language: IntegrationLintLanguage,
207        /// testing-conventions config file providing the `exempt` list (waivers).
208        /// Optional: if the file is absent, nothing is waived.
209        #[arg(long, default_value = "testing-conventions.toml")]
210        config: PathBuf,
211    },
212}
213
214#[derive(Subcommand, Debug)]
215enum E2eCommand {
216    /// Run the e2e command of your choosing and, when it passes, commit the
217    /// branch's receipt — the command (full suite, targeted subset, or a no-op)
218    /// is the judgment the receipt records. Exits with the command's own code.
219    Attest {
220        /// The e2e command to run (e.g. `pnpm run e2e`), executed via the shell.
221        command: String,
222    },
223    /// Verify a receipt answers this branch's e2e nudge (the CI gate).
224    Verify {
225        /// Directory whose committed receipts (`e2e-attestations/`) are read
226        /// (default: current directory).
227        #[arg(default_value = ".")]
228        path: PathBuf,
229        /// Directory defining what counts as scoped source, if narrower than
230        /// `path` (default: `path` itself). Must be `path` or a descendant of it.
231        #[arg(long)]
232        scope: Option<PathBuf>,
233        /// Base ref for the branch's content diff (`<base>...HEAD`): a branch
234        /// whose diff leaves the scoped source untouched owes no decision, and
235        /// one that changed it passes when its diff adds or updates a receipt —
236        /// the way the changed-line coverage/mutation gates read the diff, and
237        /// indifferent to rebases and squash merges. Absent, presence of a
238        /// committed receipt is the whole check.
239        #[arg(long)]
240        base: Option<String>,
241        /// Extra scopes: repo-root-relative directories outside `path` that
242        /// join the scoped diff — a shared source tree beside the package (a
243        /// native core bound into several bindings) that no `--scope`
244        /// at-or-below `path` can reach. Repeatable.
245        #[arg(long = "extra-scope")]
246        extra_scope: Vec<PathBuf>,
247        /// Feature-gated subtrees carved back out of the `--extra-scope` union:
248        /// repo-root-relative directories (a core `cli/` compiled out of the
249        /// bindings) whose changes owe no decision. Repeatable.
250        #[arg(long = "exclude")]
251        exclude: Vec<PathBuf>,
252    },
253    /// Print the standardized receipt slug for a branch name — the receipt
254    /// lives at `e2e-attestations/<slug>.json`.
255    Slug {
256        /// Branch name to standardize (default: the checked-out branch).
257        branch: Option<String>,
258    },
259}
260
261pub fn run<I, T>(args: I) -> anyhow::Result<i32>
262where
263    I: IntoIterator<Item = T>,
264    T: Into<std::ffi::OsString> + Clone,
265{
266    // Printed before parsing so a run that dies on an unrecognized flag still names its
267    // version, and on stderr because `e2e slug`'s stdout is read by command substitution.
268    eprintln!("testing-conventions {}", env!("CARGO_PKG_VERSION"));
269    let cli = Cli::try_parse_from(args)?;
270    match cli.command {
271        None => Ok(0),
272        Some(Command::Unit { rule }) => match rule {
273            UnitRule::ColocatedTest {
274                path,
275                language,
276                base,
277                config,
278            } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
279            UnitRule::Coverage {
280                path,
281                language,
282                base,
283                config,
284            } => run_unit_coverage(&path, language, base.as_deref(), &config),
285            UnitRule::OneFunctionPerFile {
286                path,
287                language,
288                config,
289            } => run_unit_one_function(&path, language, &config),
290            UnitRule::Lint {
291                path,
292                language,
293                config,
294            } => run_unit_lint(&path, language, &config),
295            UnitRule::Mutation {
296                path,
297                language,
298                base,
299                config,
300                ts_adapter,
301            } => run_unit_mutation(
302                &path,
303                language,
304                base.as_deref(),
305                &config,
306                ts_adapter.as_deref(),
307            ),
308        },
309        Some(Command::Integration { rule }) => match rule {
310            IntegrationRule::Lint {
311                path,
312                language,
313                config,
314            } => run_integration_lint(&path, language, &config),
315        },
316        Some(Command::Packaging { path, language }) => run_packaging(&path, language),
317        Some(Command::Workflow { path }) => run_workflow(&path),
318        Some(Command::E2e { command }) => match command {
319            E2eCommand::Attest { command } => run_e2e_attest(&command),
320            E2eCommand::Verify {
321                path,
322                scope,
323                base,
324                extra_scope,
325                exclude,
326            } => run_e2e_verify(
327                &path,
328                scope.as_deref(),
329                base.as_deref(),
330                &extra_scope,
331                &exclude,
332            ),
333            E2eCommand::Slug { branch } => run_e2e_slug(branch.as_deref()),
334        },
335        Some(Command::Install { path }) => {
336            agents::install(&path)?;
337            Ok(0)
338        }
339    }
340}
341
342/// The binary's own clap command tree, which the `workflow` guard checks invocations against.
343pub fn command() -> clap::Command {
344    Cli::command()
345}
346
347/// Run the colocated-test presence check over `root`, plus the diff-scoped co-change
348/// check when `base` is set. Returns `0` only when both pass.
349fn run_unit_colocated_test(
350    root: &Path,
351    language: colocated_test::Language,
352    base: Option<&str>,
353    config_path: &Path,
354) -> anyhow::Result<i32> {
355    if base.is_some() && language == colocated_test::Language::Rust {
356        anyhow::bail!(
357            "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
358             units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
359        );
360    }
361    let presence_clean = report_colocated_presence(root, language, config_path)?;
362    let co_change_clean = match base {
363        Some(base) => report_co_change(root, base, language, config_path)?,
364        None => true,
365    };
366    Ok(if presence_clean && co_change_clean {
367        0
368    } else {
369        1
370    })
371}
372
373/// Print every source file under `root` missing its colocated unit test; `Ok(false)`
374/// when any were found.
375fn report_colocated_presence(
376    root: &Path,
377    language: colocated_test::Language,
378    config_path: &Path,
379) -> anyhow::Result<bool> {
380    let exempt = colocated_test_exemptions(root, language, config_path)?;
381    let orphans = match language {
382        colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
383        _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
384    };
385    if orphans.is_empty() {
386        return Ok(true);
387    }
388    let (label, summary) = match language {
389        colocated_test::Language::Rust => (
390            "missing inline `#[cfg(test)]` tests",
391            "source file(s) with testable code but no inline `#[cfg(test)]` module \
392             (add an inline test module, or an `exempt` entry with a reason)",
393        ),
394        _ => (
395            "missing colocated unit test",
396            "source file(s) missing a colocated unit test \
397             (add a colocated test, or an `exempt` entry with a reason)",
398        ),
399    };
400    for orphan in &orphans {
401        eprintln!("{label}: {}", orphan.display());
402    }
403    eprintln!("error: {} {summary}", orphans.len());
404    Ok(false)
405}
406
407/// The `colocated-test`-rule exempt paths for `language`; empty when the config is absent.
408fn colocated_test_exemptions(
409    root: &Path,
410    language: colocated_test::Language,
411    config_path: &Path,
412) -> anyhow::Result<std::collections::BTreeSet<String>> {
413    if !config_path.exists() {
414        return Ok(std::collections::BTreeSet::new());
415    }
416    let config = config::load_config(config_path)?;
417    config::resolve_exempt(
418        root,
419        config.exemptions(language),
420        config::Rule::ColocatedTest,
421    )
422}
423
424/// Print every source under `root` that `<base>...HEAD` changed without its colocated
425/// test; `Ok(false)` when any were found.
426fn report_co_change(
427    root: &Path,
428    base: &str,
429    language: colocated_test::Language,
430    config_path: &Path,
431) -> anyhow::Result<bool> {
432    let exempt = co_change_exemptions(root, language, config_path)?;
433    let stale = co_change::stale_sources(root, base, language, &exempt)?;
434    if stale.is_empty() {
435        return Ok(true);
436    }
437    for source in &stale {
438        eprintln!(
439            "source changed without its colocated test: {}",
440            source.display()
441        );
442    }
443    eprintln!(
444        "error: {} source file(s) changed without their colocated test co-changing \
445         (update the test, or add an `exempt` entry with a reason)",
446        stale.len()
447    );
448    Ok(false)
449}
450
451/// The `co-change`-rule exempt paths for `language`; empty when the config is absent.
452fn co_change_exemptions(
453    root: &Path,
454    language: colocated_test::Language,
455    config_path: &Path,
456) -> anyhow::Result<std::collections::BTreeSet<String>> {
457    if !config_path.exists() {
458        return Ok(std::collections::BTreeSet::new());
459    }
460    let config = config::load_config(config_path)?;
461    config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
462}
463
464/// Split a resolved exempt-scope map into whole-file paths and line-scoped sets.
465fn split_scopes(
466    scopes: std::collections::BTreeMap<String, config::LineScope>,
467) -> (
468    Vec<String>,
469    std::collections::BTreeMap<String, std::collections::BTreeSet<u32>>,
470) {
471    let mut whole_file = Vec::new();
472    let mut line_scoped = std::collections::BTreeMap::new();
473    for (path, scope) in scopes {
474        match scope {
475            config::LineScope::WholeFile => whole_file.push(path),
476            config::LineScope::Lines(lines) => {
477                line_scoped.insert(path, lines);
478            }
479        }
480    }
481    (whole_file, line_scoped)
482}
483
484/// Run the unit coverage check over `root`, measuring the configured floor over the
485/// whole tree or, with `base` set, over the `<base>...HEAD` diff. `0` when the floor is met.
486fn run_unit_coverage(
487    root: &Path,
488    language: colocated_test::Language,
489    base: Option<&str>,
490    config_path: &Path,
491) -> anyhow::Result<i32> {
492    let config = if config_path.exists() {
493        config::load_config(config_path)?
494    } else {
495        config::Config::default()
496    };
497    let outcome = match language {
498        colocated_test::Language::Python => {
499            let python = config.python.unwrap_or_default();
500            let coverage = python.coverage.unwrap_or_default();
501            let thresholds = coverage::Thresholds {
502                fail_under: coverage.fail_under,
503                branch: coverage.branch,
504            };
505            let (omit, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
506                root,
507                &python.exempt,
508                config::Rule::Coverage,
509            )?);
510            match base {
511                Some(base) => {
512                    patch_coverage::measure(root, base, thresholds, &omit, &exempt_lines)?
513                }
514                None if exempt_lines.is_empty() => coverage::measure(root, thresholds, &omit)?,
515                None => {
516                    patch_coverage::measure_line_exempt(root, thresholds, &omit, &exempt_lines)?
517                }
518            }
519        }
520        colocated_test::Language::TypeScript => {
521            let typescript = config.typescript.unwrap_or_default();
522            let coverage = typescript.coverage.unwrap_or_default();
523            let thresholds = coverage::TypeScriptThresholds {
524                lines: coverage.lines,
525                branches: coverage.branches,
526                functions: coverage.functions,
527                statements: coverage.statements,
528            };
529            let (exclude, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
530                root,
531                &typescript.exempt,
532                config::Rule::Coverage,
533            )?);
534            match base {
535                Some(base) => patch_coverage::measure_typescript(
536                    root,
537                    base,
538                    thresholds,
539                    &exclude,
540                    &exempt_lines,
541                )?,
542                None if exempt_lines.is_empty() => {
543                    coverage::measure_typescript(root, thresholds, &exclude)?
544                }
545                None => patch_coverage::measure_line_exempt_typescript(
546                    root,
547                    thresholds,
548                    &exclude,
549                    &exempt_lines,
550                )?,
551            }
552        }
553        colocated_test::Language::Rust => {
554            let rust = config.rust.unwrap_or_default();
555            let coverage = rust.coverage.unwrap_or_default();
556            let thresholds = coverage::RustThresholds {
557                regions: coverage.regions,
558                lines: coverage.lines,
559                functions: coverage.functions,
560                branch: coverage.branch,
561            };
562            let (ignore, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
563                root,
564                &rust.exempt,
565                config::Rule::Coverage,
566            )?);
567            match base {
568                Some(base) => patch_coverage::measure_rust(
569                    root,
570                    base,
571                    thresholds,
572                    &ignore,
573                    &exempt_lines,
574                    &rust.features,
575                )?,
576                None if exempt_lines.is_empty() => {
577                    coverage::measure_rust(root, thresholds, &ignore, &rust.features)?
578                }
579                None => patch_coverage::measure_line_exempt_rust(
580                    root,
581                    thresholds,
582                    &ignore,
583                    &exempt_lines,
584                    &rust.features,
585                )?,
586            }
587        }
588    };
589    match outcome {
590        coverage::Outcome::Pass => Ok(0),
591        coverage::Outcome::Fail(reason) => {
592            eprintln!("error: coverage check failed — {reason}");
593            Ok(1)
594        }
595    }
596}
597
598/// Run the per-language mutation engine over `root` and fail on any surviving mutant
599/// not lifted by a `mutation` exemption. `base` scopes the run to the diff.
600fn run_unit_mutation(
601    root: &Path,
602    language: colocated_test::Language,
603    base: Option<&str>,
604    config_path: &Path,
605    ts_adapter: Option<&Path>,
606) -> anyhow::Result<i32> {
607    let config = if config_path.exists() {
608        config::load_config(config_path)?
609    } else {
610        config::Config::default()
611    };
612    let measurement = match language {
613        colocated_test::Language::Rust => {
614            let rust = config.rust.unwrap_or_default();
615            let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
616                root,
617                &rust.exempt,
618                config::Rule::Mutation,
619            )?);
620            mutation::measure_rust(root, &exempt, &exempt_lines, base, &rust.features)?
621        }
622        colocated_test::Language::TypeScript => {
623            let typescript = config.typescript.unwrap_or_default();
624            let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
625                root,
626                &typescript.exempt,
627                config::Rule::Mutation,
628            )?);
629            let adapter = ts_adapter.ok_or_else(|| {
630                anyhow::anyhow!(
631                    "the TypeScript mutation adapter path is required: pass \
632                     `--ts-mutation-adapter <path>`. The npm `testing-conventions` CLI appends it \
633                     automatically — run the rule through that CLI, not the raw binary."
634                )
635            })?;
636            mutation::measure_typescript(root, &exempt, &exempt_lines, base, adapter)?
637        }
638        colocated_test::Language::Python => {
639            let python = config.python.unwrap_or_default();
640            let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
641                root,
642                &python.exempt,
643                config::Rule::Mutation,
644            )?);
645            mutation::measure_python(root, &exempt, &exempt_lines, base)?
646        }
647    };
648    let (count, survivors) = match measurement {
649        mutation::Measurement::EngineNotRun => {
650            println!("unit mutation: no mutatable changed lines — engine not run");
651            return Ok(0);
652        }
653        mutation::Measurement::Tested { count, survivors } => (count, survivors),
654    };
655    if survivors.is_empty() {
656        if count == 0 {
657            println!("unit mutation: the engine found no mutants to test");
658        } else {
659            println!(
660                "unit mutation: no surviving mutants — every mutation was caught \
661                 ({count} mutant(s) tested)"
662            );
663        }
664        return Ok(0);
665    }
666
667    eprintln!(
668        "error: {} unexplained surviving mutant(s) — kill each with an assertion, or lift an \
669         equivalent/defensive one with a reason-required `[[<language>.exempt]] rules = [\"mutation\"]`:",
670        survivors.len()
671    );
672    for survivor in &survivors {
673        eprintln!(
674            "  {}:{}: {}",
675            survivor.file, survivor.line, survivor.description
676        );
677    }
678    Ok(1)
679}
680
681/// Run the one-function-per-file rule over `root`, printing each violation and returning
682/// `1` when any are found. A language with no configured threshold reports that and exits `0`.
683fn run_unit_one_function(
684    root: &Path,
685    language: colocated_test::Language,
686    config_path: &Path,
687) -> anyhow::Result<i32> {
688    let threshold = if config_path.exists() {
689        config::load_config(config_path)?.one_function_threshold(language)
690    } else {
691        config::Config::default().one_function_threshold(language)
692    };
693    let Some(max_lines) = threshold else {
694        let key = match language {
695            colocated_test::Language::Python => "python",
696            colocated_test::Language::TypeScript => "typescript",
697            colocated_test::Language::Rust => "rust",
698        };
699        println!(
700            "unit one-function-per-file: not enabled for {key} — \
701             set `[{key}].one_function_per_file` to opt in"
702        );
703        return Ok(0);
704    };
705    let raw = one_function::find_violations(root, language, max_lines)?;
706    let select: ExemptSelect = match language {
707        colocated_test::Language::Python => |c| c.exemptions(colocated_test::Language::Python),
708        colocated_test::Language::TypeScript => {
709            |c| c.exemptions(colocated_test::Language::TypeScript)
710        }
711        colocated_test::Language::Rust => |c| c.rust_exemptions(),
712    };
713    let violations = apply_waivers(raw, root, config_path, select)?;
714    if violations.is_empty() {
715        return Ok(0);
716    }
717    for v in &violations {
718        eprintln!(
719            "{}:{}: {} — {}",
720            v.file.display(),
721            v.line,
722            v.rule,
723            v.message
724        );
725    }
726    eprintln!(
727        "error: {} function(s) sharing a file with another function over the \
728         {max_lines}-line threshold (move each to its own module, or add an \
729         `exempt` entry with a reason)",
730        violations.len()
731    );
732    Ok(1)
733}
734
735/// Run the unit-suite isolation lints over `root`, printing each violation and returning
736/// `1` when any are found.
737fn run_unit_lint(
738    root: &Path,
739    language: isolation::Language,
740    config_path: &Path,
741) -> anyhow::Result<i32> {
742    let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
743        isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
744        isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
745            c.exemptions(colocated_test::Language::TypeScript)
746        }),
747        isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
748            c.exemptions(colocated_test::Language::Python)
749        }),
750    };
751    let violations = apply_waivers(raw, root, config_path, select)?;
752    if violations.is_empty() {
753        return Ok(0);
754    }
755    for v in &violations {
756        eprintln!(
757            "{}:{}: {} — {}",
758            v.file.display(),
759            v.line,
760            v.rule,
761            v.message
762        );
763    }
764    eprintln!("error: {} isolation violation(s)", violations.len());
765    Ok(1)
766}
767
768/// Run the integration-test lints over the package root above `root`, printing each
769/// violation and returning `1` when any are found. A tree with no manifest is scanned at `root`.
770fn run_integration_lint(
771    root: &Path,
772    language: IntegrationLintLanguage,
773    config_path: &Path,
774) -> anyhow::Result<i32> {
775    let manifest = match language {
776        IntegrationLintLanguage::Python => "pyproject.toml",
777        IntegrationLintLanguage::TypeScript => "package.json",
778        IntegrationLintLanguage::Rust => "Cargo.toml",
779    };
780    let package_root = tiers::package_root(root, manifest);
781    let scan_root = package_root.as_deref().unwrap_or(root);
782    let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
783        IntegrationLintLanguage::Python => (
784            match &package_root {
785                Some(package_root) => lint::find_suite_violations(package_root)?,
786                None => lint::find_violations(root)?,
787            },
788            |c| c.exemptions(colocated_test::Language::Python),
789        ),
790        IntegrationLintLanguage::TypeScript => (
791            match &package_root {
792                Some(package_root) => ts::find_suite_violations(package_root)?,
793                None => ts::find_integration_violations(root)?,
794            },
795            |c| c.exemptions(colocated_test::Language::TypeScript),
796        ),
797        IntegrationLintLanguage::Rust => {
798            (isolation::find_integration_violations(scan_root)?, |c| {
799                c.rust_exemptions()
800            })
801        }
802    };
803    let violations = apply_waivers(raw, scan_root, config_path, select)?;
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!("error: {} lint violation(s)", violations.len());
817    Ok(1)
818}
819
820/// Selects a language's `[[<lang>.exempt]]` table from a loaded config.
821type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
822
823/// Drop the violations whose `root`-relative path is exempt for their rule.
824fn apply_waivers(
825    violations: Vec<lint::Violation>,
826    root: &Path,
827    config_path: &Path,
828    exemptions: ExemptSelect,
829) -> anyhow::Result<Vec<lint::Violation>> {
830    use std::collections::hash_map::Entry;
831
832    if !config_path.exists() {
833        return Ok(violations);
834    }
835    let config = config::load_config(config_path)?;
836    let exempt = exemptions(&config);
837    let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
838        std::collections::HashMap::new();
839    let mut kept = Vec::new();
840    for violation in violations {
841        let waived = match config::Rule::from_id(violation.rule) {
842            Some(rule) => {
843                let exempt_paths = match resolved.entry(rule) {
844                    Entry::Occupied(entry) => entry.into_mut(),
845                    Entry::Vacant(entry) => {
846                        entry.insert(config::resolve_exempt(root, exempt, rule)?)
847                    }
848                };
849                violation
850                    .file
851                    .strip_prefix(root)
852                    .ok()
853                    .map(|rel| rel.to_string_lossy().replace('\\', "/"))
854                    .is_some_and(|rel| exempt_paths.contains(&rel))
855            }
856            None => false,
857        };
858        if !waived {
859            kept.push(violation);
860        }
861    }
862    Ok(kept)
863}
864
865/// Inspect the built artifact at `artifact` — an unpacked directory or a packed archive —
866/// for test files matching `language`'s globs. `1` when any are present.
867fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
868    let globs = match language {
869        colocated_test::Language::Python => vec!["*_test.py".to_string()],
870        colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
871        // `#[cfg(test)]` units compile out, so only the crate-root `tests/` dir can ship.
872        colocated_test::Language::Rust => vec!["tests/".to_string()],
873    };
874    let offenders = packaging::inspect(artifact, &globs)?;
875    if offenders.is_empty() {
876        return Ok(0);
877    }
878    for offender in &offenders {
879        eprintln!("test file in built artifact: {}", offender.display());
880    }
881    eprintln!(
882        "error: {} test file(s) present in the built artifact \
883         (they must be excluded from packaging)",
884        offenders.len()
885    );
886    Ok(1)
887}
888
889/// Flag every `testing-conventions` invocation under `path` naming a subcommand this
890/// binary no longer exposes. `1` when any are found.
891fn run_workflow(path: &Path) -> anyhow::Result<i32> {
892    let violations = workflow::check(path, &command())?;
893    if violations.is_empty() {
894        return Ok(0);
895    }
896    for v in &violations {
897        eprintln!(
898            "{}:{}: {} — {}",
899            v.file.display(),
900            v.line,
901            v.rule,
902            v.message
903        );
904    }
905    eprintln!(
906        "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
907        violations.len()
908    );
909    Ok(1)
910}
911
912/// Run `command` as the branch's e2e decision and, when it passes, commit the receipt.
913/// Returns `command`'s own exit code.
914fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
915    let repo = std::env::current_dir()?;
916    let attestation = e2e::attest(&repo, command)?;
917    if attestation.exit_code != 0 {
918        eprintln!(
919            "e2e command `{command}` exited {}; a receipt records a run that passed — \
920             fix the failure and attest again",
921            attestation.exit_code
922        );
923        return Ok(attestation.exit_code);
924    }
925    println!(
926        "e2e receipt recorded for branch {} at {}/{}.json",
927        attestation.branch,
928        e2e::RECEIPTS_DIR,
929        e2e::branch_slug(&attestation.branch),
930    );
931    Ok(0)
932}
933
934/// Verify a receipt under `path` answers this branch's e2e nudge. `0` when it does;
935/// otherwise prints the hint and returns `1`. `scope` defaults to `path`; `base`, when set,
936/// makes the check a `<base>...HEAD` content diff.
937fn run_e2e_verify(
938    path: &Path,
939    scope: Option<&Path>,
940    base: Option<&str>,
941    extra_scopes: &[PathBuf],
942    excludes: &[PathBuf],
943) -> anyhow::Result<i32> {
944    match e2e::verify_extra_scoped(path, scope.unwrap_or(path), base, extra_scopes, excludes)? {
945        e2e::Verification::Fresh => Ok(0),
946        e2e::Verification::Missing => {
947            eprintln!(
948                "no e2e receipt answers this change — run \
949                 `testing-conventions e2e attest '<your e2e command>'`; the command is \
950                 your judgment: the full suite, a targeted subset, or a no-op"
951            );
952            Ok(1)
953        }
954    }
955}
956
957/// Print the receipt slug for `branch`, defaulting to the checked-out branch.
958fn run_e2e_slug(branch: Option<&str>) -> anyhow::Result<i32> {
959    let slug = match branch {
960        Some(name) => e2e::branch_slug(name),
961        None => {
962            let repo = std::env::current_dir()?;
963            e2e::branch_slug(&e2e::current_branch(&repo)?)
964        }
965    };
966    println!("{slug}");
967    Ok(0)
968}
969
970#[cfg(test)]
971mod tests {
972    use super::*;
973
974    #[test]
975    fn no_args_returns_ok_zero() {
976        assert_eq!(run(["testing-conventions"]).unwrap(), 0);
977    }
978
979    #[test]
980    fn unknown_flag_errors() {
981        assert!(run(["testing-conventions", "--bogus"]).is_err());
982    }
983
984    #[test]
985    fn help_flag_returns_clap_display_help() {
986        let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
987        let clap_err = err
988            .downcast_ref::<clap::Error>()
989            .expect("error should be a clap::Error");
990        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
991    }
992
993    #[test]
994    fn version_flag_returns_clap_display_version() {
995        let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
996        let clap_err = err
997            .downcast_ref::<clap::Error>()
998            .expect("error should be a clap::Error");
999        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
1000    }
1001}