Skip to main content

testing_conventions/
lib.rs

1pub mod agents;
2pub mod changelog;
3pub mod co_change;
4pub mod colocated_test;
5pub mod config;
6pub mod coverage;
7pub mod e2e;
8pub mod entrypoint;
9mod git_diff;
10mod git_path;
11mod integration_lint;
12pub mod isolation;
13pub mod lint;
14pub mod mutation;
15pub mod one_function;
16pub mod packaging;
17pub mod patch_coverage;
18mod rust_patch_coverage;
19mod subcommand_walk;
20mod subprocess_seam;
21pub mod tiers;
22pub mod ts;
23mod unit_coverage;
24mod unit_lint;
25mod unit_mutation;
26mod unit_one_function;
27pub mod violation;
28mod waivers;
29mod walk;
30pub mod workflow;
31pub mod workflow_lint;
32
33use std::path::{Path, PathBuf};
34
35use clap::{CommandFactory, Parser, Subcommand};
36
37#[derive(Parser, Debug)]
38#[command(
39    name = "testing-conventions",
40    version,
41    about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
42    long_about = None,
43)]
44pub struct Cli {
45    #[command(subcommand)]
46    command: Option<Command>,
47}
48
49#[derive(Subcommand, Debug)]
50enum Command {
51    /// Write the testing contract into the repository's agent context file:
52    /// a marker-delimited, hash-versioned block in `AGENTS.md` that a
53    /// coding agent reads before writing code. Idempotent — re-running
54    /// refreshes the owned region and touches nothing outside it.
55    Install {
56        /// The agent context file to manage.
57        #[arg(default_value = "AGENTS.md")]
58        path: PathBuf,
59    },
60    /// Unit-test conventions.
61    Unit {
62        #[command(subcommand)]
63        rule: UnitRule,
64    },
65    /// Integration-test conventions.
66    Integration {
67        #[command(subcommand)]
68        rule: IntegrationRule,
69    },
70    /// Packaging conventions: test files must not ship in the built artifact.
71    Packaging {
72        /// Built distributions to check: a directory holding them (searched recursively), one
73        /// named `.whl` / `.tar.gz` / `.tgz` / `.crate`, or an unpacked artifact root.
74        path: PathBuf,
75        /// Language convention to enforce for `path`. Omitted, each distribution found takes
76        /// the language its file name names.
77        #[arg(long, value_enum)]
78        language: Option<colocated_test::Language>,
79    },
80    /// Workflow guard (private — hidden from `--help`): every `testing-conventions`
81    /// invocation in a CI workflow must name a subcommand this binary still exposes
82    /// (guards the `@v0` path). Run from our own CI, not a documented consumer command;
83    /// it stays in the binary because the guard needs the in-process command tree.
84    #[command(hide = true)]
85    Workflow {
86        /// Workflow file (or a directory of them) to scan.
87        path: PathBuf,
88    },
89    /// End-to-end-test conventions.
90    E2e {
91        #[command(subcommand)]
92        command: E2eCommand,
93    },
94    /// Workflow conventions: a GitHub Actions `run:` or `github-script` body must be wiring, not a
95    /// program. Iteration, multi-branch dispatch, text-munging, and a body past a dozen commands
96    /// belong in a tested package in the repository's own language, invoked as a one-line `run:`.
97    WorkflowLint {
98        /// Workflow or action file, or a directory of them (defaults to `.github`).
99        #[arg(default_value = ".github")]
100        path: PathBuf,
101    },
102    /// Changelog conventions: a pull request that changes a package's public surface adds a
103    /// fragment recording it. Skipped when the repository keeps no fragment directories.
104    Changelog {
105        /// Base commit of the pull request; the range checked is `<base>...HEAD`.
106        #[arg(long)]
107        base: String,
108        /// Repository root to read the fragment layout from.
109        #[arg(default_value = ".")]
110        path: PathBuf,
111    },
112}
113
114#[derive(Subcommand, Debug)]
115enum UnitRule {
116    /// Check that every source file has a colocated, matching-named unit test
117    /// (tree-wide presence). With `--base`, additionally run the commit-scoped
118    /// `co-change` check over `<base>...HEAD`: a modified or deleted source
119    /// whose colocated test is not in the diff fails. Presence always runs;
120    /// `--base` *adds* the diff-scoped check.
121    ColocatedTest {
122        /// Directory to scan recursively.
123        path: PathBuf,
124        /// Language convention to enforce (required).
125        #[arg(long, value_enum)]
126        language: colocated_test::Language,
127        /// Opt-in commit-scoped co-change check: diff `<base>...HEAD` and
128        /// also flag a modified or deleted source whose colocated test didn't
129        /// co-change. Absent means presence-only — there is no default. Python /
130        /// TypeScript only: `--base --language rust` is rejected (inline
131        /// `#[cfg(test)]` units have no sibling test to go stale).
132        #[arg(long)]
133        base: Option<String>,
134        /// testing-conventions config file providing the `exempt` list. Optional:
135        /// if the file is absent, no files are exempt.
136        #[arg(long, default_value = "testing-conventions.toml")]
137        config: PathBuf,
138    },
139    /// Check that the unit suite meets the configured coverage floor. With
140    /// `--base`, the same configured floor is measured over the `<base>...HEAD`
141    /// diff (the changed lines) instead of the whole tree — a changed line
142    /// below the floor fails, no matter how small the diff.
143    Coverage {
144        /// Directory whose unit suite is run and measured.
145        path: PathBuf,
146        /// Language convention to enforce (required).
147        #[arg(long, value_enum)]
148        language: colocated_test::Language,
149        /// Opt-in diff-scoped coverage: diff `<base>...HEAD` and measure the
150        /// configured floor over only the changed lines, instead of the whole tree.
151        /// Absent means whole-tree — there is no default. This is the patch-scoped
152        /// check the old `unit patch-coverage` command did, re-homed onto the floor
153        /// it shares.
154        #[arg(long)]
155        base: Option<String>,
156        /// testing-conventions config file with the coverage thresholds and
157        /// `exempt` list. Optional: if the file — or its `[<language>].coverage`
158        /// table — is absent, the language's sane default floor is used and
159        /// nothing is exempt.
160        #[arg(long, default_value = "testing-conventions.toml")]
161        config: PathBuf,
162    },
163    /// Check that no source file holds more than one module-scope function whose body
164    /// runs longer than the configured threshold. Trivial functions — at or under the
165    /// threshold — share a file freely.
166    OneFunctionPerFile {
167        /// Directory to scan recursively.
168        path: PathBuf,
169        /// Language convention to enforce (required).
170        #[arg(long, value_enum)]
171        language: colocated_test::Language,
172        /// testing-conventions config file providing the `max_lines` threshold and the
173        /// `exempt` list. Optional: if the file — or its
174        /// `[<language>].one_function_per_file` table — is absent, the default threshold
175        /// of one line applies and nothing is exempt.
176        #[arg(long, default_value = "testing-conventions.toml")]
177        config: PathBuf,
178    },
179    /// Lint unit test files for isolation: mock every collaborator (Python, TypeScript, Rust).
180    Lint {
181        /// Crate root / source dir to scan recursively.
182        path: PathBuf,
183        /// Language convention to enforce (required).
184        #[arg(long, value_enum)]
185        language: isolation::Language,
186        /// testing-conventions config file providing the `exempt` list (waivers).
187        /// Optional: if the file is absent, nothing is waived.
188        #[arg(long, default_value = "testing-conventions.toml")]
189        config: PathBuf,
190    },
191    /// Run mutation testing over the unit suite and fail on any surviving mutant not
192    /// lifted by a `mutation` exemption — the rung above coverage. The check is
193    /// on by default (no report-only mode). All three languages (Python, TypeScript,
194    /// Rust) are at parity and wired into the reusable workflow as a diff-scoped,
195    /// PR-only job.
196    Mutation {
197        /// Crate whose unit suite is mutated.
198        path: PathBuf,
199        /// Language convention to enforce (required): `python`, `typescript`, or `rust`.
200        #[arg(long, value_enum)]
201        language: colocated_test::Language,
202        /// Opt-in diff-scoping: restrict to mutants on lines a `<base>...HEAD`
203        /// diff added or modified, via cargo-mutants' `--in-diff`. Absent means the
204        /// whole crate (slower).
205        #[arg(long)]
206        base: Option<String>,
207        /// testing-conventions config file providing the `exempt` list. Optional:
208        /// absent means nothing is exempt (every survivor must be killed).
209        #[arg(long, default_value = "testing-conventions.toml")]
210        config: PathBuf,
211        /// Path to the bundled TypeScript mutation adapter (`dist/mutation/main.js`), used
212        /// only by `--language typescript`. The npm launcher appends it; hidden because a
213        /// consumer never sets it by hand.
214        #[arg(long = "ts-mutation-adapter", hide = true)]
215        ts_adapter: Option<PathBuf>,
216    },
217}
218
219/// Languages the integration-test lints support.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
221pub enum IntegrationLintLanguage {
222    /// Python test files (`*_test.py`, `test_*.py`, `conftest.py`).
223    #[value(name = "python")]
224    Python,
225    /// TypeScript test files (`*.test.{ts,tsx,mts,cts}`).
226    #[value(name = "typescript")]
227    TypeScript,
228    /// Rust integration crates under `tests/`.
229    #[value(name = "rust")]
230    Rust,
231}
232
233#[derive(Subcommand, Debug)]
234enum IntegrationRule {
235    /// Lint integration test files for mocking mechanism & style (Python, TypeScript, Rust).
236    Lint {
237        /// Directory to scan recursively for test files.
238        path: PathBuf,
239        /// Language convention to enforce (required).
240        #[arg(long, value_enum)]
241        language: IntegrationLintLanguage,
242        /// testing-conventions config file providing the `exempt` list (waivers).
243        /// Optional: if the file is absent, nothing is waived.
244        #[arg(long, default_value = "testing-conventions.toml")]
245        config: PathBuf,
246    },
247}
248
249#[derive(Subcommand, Debug)]
250enum E2eCommand {
251    /// Run the e2e command of your choosing and, when it passes, commit the
252    /// branch's receipt — the command (full suite, targeted subset, or a no-op)
253    /// is the judgment the receipt records. Exits with the command's own code.
254    Attest {
255        /// The e2e command to run (e.g. `pnpm run e2e`), executed via the shell.
256        command: String,
257    },
258    /// Verify a receipt answers this branch's e2e nudge (the CI gate).
259    Verify {
260        /// Directory whose committed receipts (`e2e-attestations/`) are read
261        /// (default: current directory).
262        #[arg(default_value = ".")]
263        path: PathBuf,
264        /// Directory defining what counts as scoped source, if narrower than
265        /// `path` (default: `path` itself). Must be `path` or a descendant of it.
266        #[arg(long)]
267        scope: Option<PathBuf>,
268        /// Base ref for the branch's content diff (`<base>...HEAD`): a branch
269        /// whose diff leaves the scoped source untouched owes no decision, and
270        /// one that changed it passes when its diff adds or updates a receipt —
271        /// the way the changed-line coverage/mutation checks read the diff, and
272        /// indifferent to rebases and squash merges. Absent, presence of a
273        /// committed receipt is the whole check.
274        #[arg(long)]
275        base: Option<String>,
276        /// Extra scopes: repo-root-relative directories outside `path` that
277        /// join the scoped diff — a shared source tree beside the package (a
278        /// native core bound into several bindings) that no `--scope`
279        /// at-or-below `path` can reach. Repeatable.
280        #[arg(long = "extra-scope")]
281        extra_scope: Vec<PathBuf>,
282        /// Feature-gated subtrees carved back out of the `--extra-scope` union:
283        /// repo-root-relative directories (a core `cli/` compiled out of the
284        /// bindings) whose changes owe no decision. Repeatable.
285        #[arg(long = "exclude")]
286        exclude: Vec<PathBuf>,
287        /// The acting branch's name, for a checkout with no branch to read (a
288        /// detached HEAD). Absent, `verify` reads the checked-out branch.
289        #[arg(long)]
290        branch: Option<String>,
291    },
292    /// Print the standardized receipt slug for a branch name — the receipt
293    /// lives at `e2e-attestations/<slug>.json`.
294    Slug {
295        /// Branch name to standardize (default: the checked-out branch).
296        branch: Option<String>,
297    },
298}
299
300pub fn run<I, T>(args: I) -> anyhow::Result<i32>
301where
302    I: IntoIterator<Item = T>,
303    T: Into<std::ffi::OsString> + Clone,
304{
305    // Printed before parsing so a run that dies on an unrecognized flag still names its
306    // version, and on stderr because `e2e slug`'s stdout is read by command substitution.
307    eprintln!("testing-conventions {}", env!("CARGO_PKG_VERSION"));
308    let cli = Cli::try_parse_from(args)?;
309    match cli.command {
310        None => Ok(0),
311        Some(Command::Unit { rule }) => match rule {
312            UnitRule::ColocatedTest {
313                path,
314                language,
315                base,
316                config,
317            } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
318            UnitRule::Coverage {
319                path,
320                language,
321                base,
322                config,
323            } => unit_coverage::run(&path, language, base.as_deref(), &config),
324            UnitRule::OneFunctionPerFile {
325                path,
326                language,
327                config,
328            } => unit_one_function::run(&path, language, &config),
329            UnitRule::Lint {
330                path,
331                language,
332                config,
333            } => unit_lint::run(&path, language, &config),
334            UnitRule::Mutation {
335                path,
336                language,
337                base,
338                config,
339                ts_adapter,
340            } => unit_mutation::run(
341                &path,
342                language,
343                base.as_deref(),
344                &config,
345                ts_adapter.as_deref(),
346            ),
347        },
348        Some(Command::Integration { rule }) => match rule {
349            IntegrationRule::Lint {
350                path,
351                language,
352                config,
353            } => integration_lint::run(&path, language, &config),
354        },
355        Some(Command::Packaging { path, language }) => run_packaging(&path, language),
356        Some(Command::Changelog { base, path }) => changelog::run(&path, &base),
357        Some(Command::Workflow { path }) => run_workflow(&path),
358        Some(Command::WorkflowLint { path }) => run_workflow_lint(&path),
359        Some(Command::E2e { command }) => match command {
360            E2eCommand::Attest { command } => run_e2e_attest(&command),
361            E2eCommand::Verify {
362                path,
363                scope,
364                base,
365                extra_scope,
366                exclude,
367                branch,
368            } => run_e2e_verify(
369                &path,
370                scope.as_deref(),
371                base.as_deref(),
372                &extra_scope,
373                &exclude,
374                branch.as_deref(),
375            ),
376            E2eCommand::Slug { branch } => run_e2e_slug(branch.as_deref()),
377        },
378        Some(Command::Install { path }) => {
379            agents::install(&path)?;
380            Ok(0)
381        }
382    }
383}
384
385/// The binary's own clap command tree, which the `workflow` guard checks invocations against.
386pub fn command() -> clap::Command {
387    Cli::command()
388}
389
390/// Run the colocated-test presence check over `root`, plus the diff-scoped co-change
391/// check when `base` is set. Returns `0` only when both pass.
392fn run_unit_colocated_test(
393    root: &Path,
394    language: colocated_test::Language,
395    base: Option<&str>,
396    config_path: &Path,
397) -> anyhow::Result<i32> {
398    if base.is_some() && language == colocated_test::Language::Rust {
399        anyhow::bail!(
400            "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
401             units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
402        );
403    }
404    let presence_clean = report_colocated_presence(root, language, config_path)?;
405    let co_change_clean = match base {
406        Some(base) => report_co_change(root, base, language, config_path)?,
407        None => true,
408    };
409    Ok(if presence_clean && co_change_clean {
410        0
411    } else {
412        1
413    })
414}
415
416/// Print every source file under `root` missing its colocated unit test; `Ok(false)`
417/// when any were found.
418fn report_colocated_presence(
419    root: &Path,
420    language: colocated_test::Language,
421    config_path: &Path,
422) -> anyhow::Result<bool> {
423    let exempt = colocated_test_exemptions(root, language, config_path)?;
424    let orphans = match language {
425        colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
426        _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
427    };
428    if orphans.is_empty() {
429        return Ok(true);
430    }
431    let (label, summary) = orphan_wording(language);
432    for orphan in &orphans {
433        eprintln!("{label}: {}", orphan.display());
434    }
435    eprintln!("error: {} {summary}", orphans.len());
436    Ok(false)
437}
438
439/// How the `colocated-test` rule names what is missing for `language`: the per-file label and
440/// the summary line. Rust wants an inline `#[cfg(test)]` module; the others want a sibling
441/// test file.
442fn orphan_wording(language: colocated_test::Language) -> (&'static str, &'static str) {
443    match language {
444        colocated_test::Language::Rust => (
445            "missing inline `#[cfg(test)]` tests",
446            "source file(s) with testable code but no inline `#[cfg(test)]` module \
447             (add an inline test module, or an `exempt` entry with a reason)",
448        ),
449        _ => (
450            "missing colocated unit test",
451            "source file(s) missing a colocated unit test \
452             (add a colocated test, or an `exempt` entry with a reason)",
453        ),
454    }
455}
456
457/// The `colocated-test`-rule exempt paths for `language`; empty when the config is absent.
458fn colocated_test_exemptions(
459    root: &Path,
460    language: colocated_test::Language,
461    config_path: &Path,
462) -> anyhow::Result<std::collections::BTreeSet<String>> {
463    if !config_path.exists() {
464        return Ok(std::collections::BTreeSet::new());
465    }
466    let config = config::load_config(config_path)?;
467    config::resolve_exempt(
468        root,
469        config.exemptions(language),
470        config::Rule::ColocatedTest,
471    )
472}
473
474/// Print every source under `root` that `<base>...HEAD` changed without its colocated
475/// test; `Ok(false)` when any were found.
476fn report_co_change(
477    root: &Path,
478    base: &str,
479    language: colocated_test::Language,
480    config_path: &Path,
481) -> anyhow::Result<bool> {
482    let exempt = co_change_exemptions(root, language, config_path)?;
483    let stale = co_change::stale_sources(root, base, language, &exempt)?;
484    if stale.is_empty() {
485        return Ok(true);
486    }
487    for source in &stale {
488        eprintln!(
489            "source changed without its colocated test: {}",
490            source.display()
491        );
492    }
493    eprintln!(
494        "error: {} source file(s) changed without their colocated test co-changing \
495         (update the test, or add an `exempt` entry with a reason)",
496        stale.len()
497    );
498    Ok(false)
499}
500
501/// The `co-change`-rule exempt paths for `language`; empty when the config is absent.
502fn co_change_exemptions(
503    root: &Path,
504    language: colocated_test::Language,
505    config_path: &Path,
506) -> anyhow::Result<std::collections::BTreeSet<String>> {
507    if !config_path.exists() {
508        return Ok(std::collections::BTreeSet::new());
509    }
510    let config = config::load_config(config_path)?;
511    config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
512}
513
514/// Split a resolved exempt-scope map into whole-file paths and line-scoped sets.
515fn split_scopes(
516    scopes: std::collections::BTreeMap<String, config::LineScope>,
517) -> (
518    Vec<String>,
519    std::collections::BTreeMap<String, std::collections::BTreeSet<u32>>,
520) {
521    let mut whole_file = Vec::new();
522    let mut line_scoped = std::collections::BTreeMap::new();
523    for (path, scope) in scopes {
524        match scope {
525            config::LineScope::WholeFile => whole_file.push(path),
526            config::LineScope::Lines(lines) => {
527                line_scoped.insert(path, lines);
528            }
529        }
530    }
531    (whole_file, line_scoped)
532}
533
534/// Inspect the built distributions at `path` for test files. With `language`, `path` is that
535/// language's distribution or unpacked artifact root; without it, `path` is searched and every
536/// distribution found takes the language its file name names. `1` when any test file ships.
537fn run_packaging(path: &Path, language: Option<colocated_test::Language>) -> anyhow::Result<i32> {
538    let distributions = distributions_at(path, language)?;
539    let mut shipped = 0;
540    for distribution in &distributions {
541        shipped += report_shipped_test_files(distribution)?;
542    }
543    if shipped > 0 {
544        eprintln!(
545            "error: {shipped} test file(s) present in the built distribution(s) \
546             (they must be excluded from packaging)"
547        );
548        return Ok(1);
549    }
550    println!(
551        "checked {} built distribution(s); no test files shipped",
552        distributions.len()
553    );
554    Ok(0)
555}
556
557/// The distributions `run_packaging` inspects. With `language`, `path` is itself that
558/// language's distribution; without it, `path` is searched. An error when neither yields one,
559/// because an empty run would otherwise pass as a clean one.
560fn distributions_at(
561    path: &Path,
562    language: Option<colocated_test::Language>,
563) -> anyhow::Result<Vec<packaging::Distribution>> {
564    let distributions = match language {
565        Some(language) => vec![packaging::Distribution {
566            path: path.to_path_buf(),
567            language,
568        }],
569        None => packaging::discover(path)?,
570    };
571    if distributions.is_empty() {
572        anyhow::bail!(
573            "no recognized built distribution (`.whl`, `.tar.gz`, `.tgz`, `.crate`) at `{}`",
574            path.display()
575        );
576    }
577    Ok(distributions)
578}
579
580/// Name every test file `distribution` ships, and how many there were.
581fn report_shipped_test_files(distribution: &packaging::Distribution) -> anyhow::Result<usize> {
582    let globs = match distribution.language {
583        colocated_test::Language::Python => vec!["*_test.py".to_string()],
584        colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
585        // `#[cfg(test)]` units compile out, so only the crate-root `tests/` dir can ship.
586        colocated_test::Language::Rust => vec!["tests/".to_string()],
587    };
588    let offenders = packaging::inspect(&distribution.path, &globs)?;
589    for offender in &offenders {
590        eprintln!(
591            "test file in built artifact `{}`: {}",
592            distribution.path.display(),
593            offender.display()
594        );
595    }
596    Ok(offenders.len())
597}
598
599/// Flag every `testing-conventions` invocation under `path` naming a subcommand this
600/// binary no longer exposes. `1` when any are found.
601fn run_workflow(path: &Path) -> anyhow::Result<i32> {
602    let violations = workflow::check(path, &command())?;
603    if violations.is_empty() {
604        return Ok(0);
605    }
606    for v in &violations {
607        eprintln!(
608            "{}:{}: {} — {}",
609            v.file.display(),
610            v.line,
611            v.rule,
612            v.message
613        );
614    }
615    eprintln!(
616        "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
617        violations.len()
618    );
619    Ok(1)
620}
621
622fn run_workflow_lint(path: &Path) -> anyhow::Result<i32> {
623    let findings = workflow_lint::scan(path)?;
624    if findings.is_empty() {
625        return Ok(0);
626    }
627    for f in &findings {
628        eprintln!("{}", inline_logic_finding(f));
629    }
630    eprintln!(
631        "error: {} step(s) encode logic in CI YAML, where nothing tests it",
632        findings.len()
633    );
634    Ok(1)
635}
636
637/// What one inline-logic finding reports: where the step sits, and why its body reads as logic.
638fn inline_logic_finding(finding: &workflow_lint::Finding) -> String {
639    format!(
640        "{}:{}: {} step `{}` encodes logic inline ({}) — move it into a tested package in \
641         this repository's own language, invoked as a one-line `run:`",
642        finding.file.display(),
643        finding.line,
644        finding.kind,
645        finding.step,
646        finding.reasons.join("; ")
647    )
648}
649
650/// Run `command` as the branch's e2e decision and, when it passes, commit the receipt.
651/// Returns `command`'s own exit code.
652fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
653    let repo = std::env::current_dir()?;
654    let attestation = e2e::attest(&repo, command)?;
655    if attestation.exit_code != 0 {
656        eprintln!(
657            "e2e command `{command}` exited {}; a receipt records a run that passed — \
658             fix the failure and attest again",
659            attestation.exit_code
660        );
661        return Ok(attestation.exit_code);
662    }
663    println!(
664        "e2e receipt recorded for branch {} at {}/{}.json",
665        attestation.branch,
666        e2e::RECEIPTS_DIR,
667        e2e::branch_slug(&attestation.branch),
668    );
669    Ok(0)
670}
671
672/// Verify a receipt under `path` answers the acting branch's e2e nudge. `0` when it does;
673/// otherwise prints the hint and returns `1`. `scope` defaults to `path`, `branch` overrides
674/// the checked-out branch, and `base`, when set, makes the check a `<base>...HEAD` diff.
675fn run_e2e_verify(
676    path: &Path,
677    scope: Option<&Path>,
678    base: Option<&str>,
679    extra_scopes: &[PathBuf],
680    excludes: &[PathBuf],
681    branch: Option<&str>,
682) -> anyhow::Result<i32> {
683    match e2e::verify_extra_scoped(
684        path,
685        scope.unwrap_or(path),
686        base,
687        extra_scopes,
688        excludes,
689        branch,
690    )? {
691        e2e::Verification::Fresh => Ok(0),
692        e2e::Verification::Missing => {
693            eprintln!(
694                "no e2e receipt answers this change — run \
695                 `testing-conventions e2e attest '<your e2e command>'`; the command is \
696                 your judgment: the full suite, a targeted subset, or a no-op"
697            );
698            Ok(1)
699        }
700    }
701}
702
703/// Print the receipt slug for `branch`, defaulting to the checked-out branch.
704fn run_e2e_slug(branch: Option<&str>) -> anyhow::Result<i32> {
705    let slug = match branch {
706        Some(name) => e2e::branch_slug(name),
707        None => {
708            let repo = std::env::current_dir()?;
709            e2e::branch_slug(&e2e::current_branch(&repo)?)
710        }
711    };
712    println!("{slug}");
713    Ok(0)
714}
715
716#[cfg(test)]
717mod tests {
718    use std::sync::atomic::{AtomicU64, Ordering};
719
720    use super::*;
721
722    /// A process-unique scratch tree holding `files`, rebuilt from scratch on every call.
723    fn scratch(slug: &str, files: &[(&str, &str)]) -> PathBuf {
724        static COUNTER: AtomicU64 = AtomicU64::new(0);
725        let root = std::env::temp_dir().join(format!(
726            "tc-lib-{}-{}-{}",
727            slug,
728            std::process::id(),
729            COUNTER.fetch_add(1, Ordering::Relaxed),
730        ));
731        let _ = std::fs::remove_dir_all(&root);
732        std::fs::create_dir_all(&root).unwrap();
733        for (rel, contents) in files {
734            let full = root.join(rel);
735            std::fs::create_dir_all(full.parent().unwrap()).unwrap();
736            std::fs::write(full, contents).unwrap();
737        }
738        root
739    }
740
741    #[test]
742    fn a_workflow_encoding_logic_fails_the_lint_and_a_wiring_one_passes() {
743        let workflow =
744            |body: &str| format!("on: push\njobs:\n  j:\n    steps:\n      - run: |\n{body}");
745        let logic = scratch(
746            "workflow-lint-logic",
747            &[(
748                "ci.yml",
749                &workflow("          for f in *.txt; do\n            echo $f\n          done\n"),
750            )],
751        );
752        assert_eq!(run_workflow_lint(&logic.join("ci.yml")).unwrap(), 1);
753
754        let wiring = scratch(
755            "workflow-lint-wiring",
756            &[("ci.yml", &workflow("          echo hello\n"))],
757        );
758        assert_eq!(run_workflow_lint(&wiring.join("ci.yml")).unwrap(), 0);
759    }
760
761    #[test]
762    fn an_inline_logic_finding_names_the_step_its_location_and_every_reason() {
763        let rendered = inline_logic_finding(&workflow_lint::Finding {
764            file: PathBuf::from(".github/workflows/ci.yml"),
765            line: 42,
766            step: "Publish".to_string(),
767            kind: "run",
768            reasons: vec!["for loop".to_string(), "sed".to_string()],
769        });
770        assert!(
771            rendered.starts_with(".github/workflows/ci.yml:42: run step `Publish` encodes logic inline (for loop; sed)"),
772            "got: {rendered}"
773        );
774        assert!(rendered.contains("one-line `run:`"), "got: {rendered}");
775    }
776
777    #[test]
778    fn a_directory_holding_no_distribution_is_an_error_rather_than_a_clean_run() {
779        // `Ok(0)` here would read as "checked nothing, nothing shipped" — a pass by vacancy.
780        let root = scratch("packaging-empty", &[("README.md", "hi\n")]);
781        assert!(run_packaging(&root, None).is_err());
782    }
783
784    #[test]
785    fn a_rust_file_with_no_inline_tests_makes_the_presence_report_unclean() {
786        let root = scratch(
787            "presence-orphan",
788            &[("src/widget.rs", "pub fn f() -> u8 { 1 }\n")],
789        );
790        let config = root.join("testing-conventions.toml");
791        assert!(
792            !report_colocated_presence(&root, colocated_test::Language::Rust, &config).unwrap()
793        );
794    }
795
796    #[test]
797    fn a_rust_file_carrying_inline_tests_makes_the_presence_report_clean() {
798        let root = scratch(
799            "presence-clean",
800            &[(
801                "src/widget.rs",
802                "pub fn f() -> u8 { 1 }\n#[cfg(test)]\nmod tests { #[test] fn t() {} }\n",
803            )],
804        );
805        let config = root.join("testing-conventions.toml");
806        assert!(report_colocated_presence(&root, colocated_test::Language::Rust, &config).unwrap());
807    }
808
809    #[test]
810    fn rust_is_told_about_inline_test_modules_and_the_others_about_sibling_files() {
811        let (label, summary) = orphan_wording(colocated_test::Language::Rust);
812        assert!(label.contains("inline"), "got: {label}");
813        assert!(summary.contains("inline test module"), "got: {summary}");
814
815        let (label, summary) = orphan_wording(colocated_test::Language::Python);
816        assert!(label.contains("colocated unit test"), "got: {label}");
817        assert!(summary.contains("add a colocated test"), "got: {summary}");
818    }
819
820    #[test]
821    fn a_named_language_makes_the_path_itself_the_distribution() {
822        let found = distributions_at(
823            Path::new("/tmp/pkg.whl"),
824            Some(colocated_test::Language::Python),
825        )
826        .unwrap();
827        assert_eq!(found.len(), 1);
828        assert_eq!(found[0].path, Path::new("/tmp/pkg.whl"));
829    }
830
831    #[test]
832    fn a_search_that_finds_nothing_is_an_error_not_a_clean_run() {
833        let dir = std::env::temp_dir().join(format!(
834            "testing-conventions-no-distributions-{}",
835            std::process::id()
836        ));
837        std::fs::create_dir_all(&dir).unwrap();
838        let err = distributions_at(&dir, None).unwrap_err();
839        assert!(
840            err.to_string().contains("no recognized built distribution"),
841            "got: {err}"
842        );
843    }
844
845    #[test]
846    fn no_args_returns_ok_zero() {
847        assert_eq!(run(["testing-conventions"]).unwrap(), 0);
848    }
849
850    #[test]
851    fn unknown_flag_errors() {
852        assert!(run(["testing-conventions", "--bogus"]).is_err());
853    }
854
855    #[test]
856    fn split_scopes_separates_whole_file_paths_from_line_sets() {
857        let mut scopes = std::collections::BTreeMap::new();
858        scopes.insert("shim.py".to_string(), config::LineScope::WholeFile);
859        scopes.insert(
860            "widget.py".to_string(),
861            config::LineScope::Lines(std::collections::BTreeSet::from([3])),
862        );
863        let (whole_file, line_scoped) = split_scopes(scopes);
864        assert_eq!(whole_file, vec!["shim.py".to_string()]);
865        assert_eq!(line_scoped.len(), 1);
866        assert_eq!(
867            line_scoped["widget.py"],
868            std::collections::BTreeSet::from([3])
869        );
870    }
871
872    #[test]
873    fn help_flag_returns_clap_display_help() {
874        let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
875        let clap_err = err
876            .downcast_ref::<clap::Error>()
877            .expect("error should be a clap::Error");
878        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
879    }
880
881    #[test]
882    fn version_flag_returns_clap_display_version() {
883        let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
884        let clap_err = err
885            .downcast_ref::<clap::Error>()
886            .expect("error should be a clap::Error");
887        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
888    }
889}