Skip to main content

testing_conventions/
lib.rs

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