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 (#37) is the exception — it has no default floor yet, so a missing
393/// `[rust].coverage` table is an error rather than a guessed floor.
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            // Rust has no zero-config default floor yet (unlike Python/TypeScript,
443            // #80): a missing `[rust].coverage` table is an error, not a guessed
444            // floor — so a crate opts into a specific floor deliberately.
445            let coverage = rust.coverage.ok_or_else(|| {
446                anyhow::anyhow!(
447                    "Rust coverage needs a `[rust].coverage` table (regions / lines) in `{}` — \
448                     there is no zero-config default floor for Rust yet",
449                    config_path.display()
450                )
451            })?;
452            let thresholds = coverage::RustThresholds {
453                regions: coverage.regions,
454                lines: coverage.lines,
455            };
456            let ignore: Vec<String> =
457                config::resolve_exempt(root, &rust.exempt, config::Rule::Coverage)?
458                    .into_iter()
459                    .collect();
460            match base {
461                Some(base) => patch_coverage::measure_rust(root, base, thresholds, &ignore)?,
462                None => coverage::measure_rust(root, thresholds, &ignore)?,
463            }
464        }
465    };
466    match outcome {
467        coverage::Outcome::Pass => Ok(0),
468        coverage::Outcome::Fail(reason) => {
469            eprintln!("error: coverage check failed — {reason}");
470            Ok(1)
471        }
472    }
473}
474
475/// Run the `unit lint` check over `root` for `language` — the unit-suite
476/// isolation lints (`unmocked-collaborator`, `untyped-mock`, `no-out-of-module-call`,
477/// `no-out-of-module-import`) — printing each violation to stderr as
478/// `path:line: rule — message` and returning `1` when any are found, `0` otherwise.
479fn run_unit_lint(
480    root: &Path,
481    language: isolation::Language,
482    config_path: &Path,
483) -> anyhow::Result<i32> {
484    let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
485        isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
486        isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
487            c.exemptions(colocated_test::Language::TypeScript)
488        }),
489        isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
490            c.exemptions(colocated_test::Language::Python)
491        }),
492    };
493    let violations = apply_waivers(raw, root, config_path, select)?;
494    if violations.is_empty() {
495        return Ok(0);
496    }
497    for v in &violations {
498        eprintln!(
499            "{}:{}: {} — {}",
500            v.file.display(),
501            v.line,
502            v.rule,
503            v.message
504        );
505    }
506    eprintln!("error: {} isolation violation(s)", violations.len());
507    Ok(1)
508}
509
510/// Run the integration-test lints over `root` for `language`, printing each
511/// violation to stderr as `path:line: rule — message` and returning `1` when any
512/// are found, `0` otherwise.
513fn run_integration_lint(
514    root: &Path,
515    language: IntegrationLintLanguage,
516    config_path: &Path,
517) -> anyhow::Result<i32> {
518    let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
519        IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
520            c.exemptions(colocated_test::Language::Python)
521        }),
522        IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
523            c.exemptions(colocated_test::Language::TypeScript)
524        }),
525        IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
526            c.rust_exemptions()
527        }),
528    };
529    let violations = apply_waivers(raw, root, config_path, select)?;
530    if violations.is_empty() {
531        return Ok(0);
532    }
533    for v in &violations {
534        eprintln!(
535            "{}:{}: {} — {}",
536            v.file.display(),
537            v.line,
538            v.rule,
539            v.message
540        );
541    }
542    eprintln!("error: {} lint violation(s)", violations.len());
543    Ok(1)
544}
545
546/// Selects a language's `[[<lang>.exempt]]` table from a loaded config — the one
547/// varying piece between the `unit lint` and `integration lint` waiver paths.
548type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
549
550/// Drop the violations waived by the config's `exempt` list (#32/#102). A
551/// violation is waived when its `rule` is a known [`config::Rule`] and its
552/// `root`-relative path is exempt for that rule. `exemptions` selects the
553/// language's `[[<lang>.exempt]]` table from the loaded config. A missing config
554/// file waives nothing; a reason-less or stale entry errors (via `load_config` /
555/// `resolve_exempt`), so the escape hatch can't silently rot.
556fn apply_waivers(
557    violations: Vec<lint::Violation>,
558    root: &Path,
559    config_path: &Path,
560    exemptions: ExemptSelect,
561) -> anyhow::Result<Vec<lint::Violation>> {
562    use std::collections::hash_map::Entry;
563
564    if !config_path.exists() {
565        return Ok(violations);
566    }
567    let config = config::load_config(config_path)?;
568    let exempt = exemptions(&config);
569    // Resolve each rule's exempt set once (and surface a stale entry as an error).
570    let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
571        std::collections::HashMap::new();
572    let mut kept = Vec::new();
573    for violation in violations {
574        let waived = match config::Rule::from_id(violation.rule) {
575            Some(rule) => {
576                let exempt_paths = match resolved.entry(rule) {
577                    Entry::Occupied(entry) => entry.into_mut(),
578                    Entry::Vacant(entry) => {
579                        entry.insert(config::resolve_exempt(root, exempt, rule)?)
580                    }
581                };
582                violation
583                    .file
584                    .strip_prefix(root)
585                    .ok()
586                    .map(|rel| rel.to_string_lossy().replace('\\', "/"))
587                    .is_some_and(|rel| exempt_paths.contains(&rel))
588            }
589            None => false,
590        };
591        if !waived {
592            kept.push(violation);
593        }
594    }
595    Ok(kept)
596}
597
598/// Run the packaging check: inspect the built artifact at `artifact` for test
599/// files that must not ship (README "Packaging"), per `language`'s test-file
600/// globs.
601///
602/// `artifact` is either an already-unpacked directory or a packed artifact the
603/// rule unpacks itself — a Python wheel (`.whl`) today; the TypeScript (#73) and
604/// Rust (#74) archives follow. Returns `0` when no test file is present, `1`
605/// otherwise (after printing each offending path, relative to the artifact root).
606fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
607    let globs = match language {
608        colocated_test::Language::Python => vec!["*_test.py".to_string()],
609        colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
610        // `#[cfg(test)]` units compile out for free; the only thing to keep out of
611        // the `.crate` source tarball is the crate-root integration `tests/` dir.
612        colocated_test::Language::Rust => vec!["tests/".to_string()],
613    };
614    let offenders = packaging::inspect(artifact, &globs)?;
615    if offenders.is_empty() {
616        return Ok(0);
617    }
618    for offender in &offenders {
619        eprintln!("test file in built artifact: {}", offender.display());
620    }
621    eprintln!(
622        "error: {} test file(s) present in the built artifact \
623         (they must be excluded from packaging)",
624        offenders.len()
625    );
626    Ok(1)
627}
628
629/// Run the workflow guard over `path` (a workflow file or directory): flag every
630/// `testing-conventions` invocation that names a subcommand this binary no longer
631/// exposes, printing each as `path:line: rule — message` and returning `1` when any
632/// are found, `0` otherwise.
633fn run_workflow(path: &Path) -> anyhow::Result<i32> {
634    let violations = workflow::check(path, &command())?;
635    if violations.is_empty() {
636        return Ok(0);
637    }
638    for v in &violations {
639        eprintln!(
640            "{}:{}: {} — {}",
641            v.file.display(),
642            v.line,
643            v.rule,
644            v.message
645        );
646    }
647    eprintln!(
648        "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
649        violations.len()
650    );
651    Ok(1)
652}
653
654/// Run `command` as an e2e suite and write a committed attestation naming the
655/// current commit (#67). Force-runs: the attestation is written regardless of
656/// the command's exit code, so this exits `0` once the attestation is recorded.
657fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
658    let repo = std::env::current_dir()?;
659    let attestation = e2e::attest(&repo, command)?;
660    println!(
661        "e2e attestation recorded for commit {} (command exited {})",
662        attestation.commit, attestation.exit_code
663    );
664    Ok(0)
665}
666
667/// Verify the committed e2e attestation names the latest code commit (#68) — the
668/// CI side of the nudge. Exits `0` when fresh; otherwise prints the actionable
669/// hint and exits `1`. Never runs e2e, never judges the recorded run.
670fn run_e2e_verify() -> anyhow::Result<i32> {
671    let repo = std::env::current_dir()?;
672    match e2e::verify(&repo)? {
673        e2e::Verification::Fresh => Ok(0),
674        e2e::Verification::Missing => {
675            eprintln!(
676                "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
677            );
678            Ok(1)
679        }
680        e2e::Verification::Stale { attested, latest } => {
681            eprintln!(
682                "e2e attestation out of date: attested {}, latest code commit {} — \
683                 run `testing-conventions e2e attest '<your e2e command>'`",
684                &attested[..attested.len().min(7)],
685                &latest[..latest.len().min(7)]
686            );
687            Ok(1)
688        }
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    #[test]
697    fn no_args_returns_ok_zero() {
698        assert_eq!(run(["testing-conventions"]).unwrap(), 0);
699    }
700
701    #[test]
702    fn check_returns_ok_zero() {
703        assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
704    }
705
706    #[test]
707    fn unknown_flag_errors() {
708        assert!(run(["testing-conventions", "--bogus"]).is_err());
709    }
710
711    #[test]
712    fn help_flag_returns_clap_display_help() {
713        let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
714        let clap_err = err
715            .downcast_ref::<clap::Error>()
716            .expect("error should be a clap::Error");
717        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
718    }
719
720    #[test]
721    fn version_flag_returns_clap_display_version() {
722        let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
723        let clap_err = err
724            .downcast_ref::<clap::Error>()
725            .expect("error should be a clap::Error");
726        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
727    }
728
729    #[test]
730    fn unit_coverage_rust_requires_a_coverage_table() {
731        // Zero-config: with no config file the default config carries no
732        // `[rust].coverage` table, so the Rust arm errors asking for one (Rust has
733        // no default floor yet, #37) instead of running `cargo llvm-cov`. The error
734        // is raised before any measurement, so no fixture or toolchain is needed.
735        let err = run([
736            "testing-conventions",
737            "unit",
738            "coverage",
739            "pkg",
740            "--language",
741            "rust",
742        ])
743        .unwrap_err();
744        assert!(err.to_string().contains("[rust].coverage"), "got: {err}");
745    }
746}