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