Skip to main content

testing_conventions/
lib.rs

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