Skip to main content

testing_conventions/
lib.rs

1pub mod config;
2pub mod coverage;
3pub mod location;
4
5use std::path::{Path, PathBuf};
6
7use anyhow::Context;
8use clap::{Parser, Subcommand};
9
10#[derive(Parser, Debug)]
11#[command(
12    name = "testing-conventions",
13    version,
14    about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
15    long_about = None,
16)]
17pub struct Cli {
18    #[command(subcommand)]
19    command: Option<Command>,
20}
21
22#[derive(Subcommand, Debug)]
23enum Command {
24    /// Check the repository against its testing-conventions config.
25    Check,
26    /// Unit-test conventions.
27    Unit {
28        #[command(subcommand)]
29        rule: UnitRule,
30    },
31}
32
33/// Rules enforced on the unit-test suite (the README's "Unit" taxonomy).
34#[derive(Subcommand, Debug)]
35enum UnitRule {
36    /// Check that every source file has a colocated unit test.
37    Location {
38        /// Directory to scan recursively.
39        path: PathBuf,
40        /// Language convention to enforce (required).
41        #[arg(long, value_enum)]
42        language: location::Language,
43    },
44    /// Check that the unit suite meets the configured coverage floor.
45    Coverage {
46        /// Directory whose unit suite is run and measured.
47        path: PathBuf,
48        /// Language convention to enforce (required).
49        #[arg(long, value_enum)]
50        language: location::Language,
51        /// testing-conventions config file providing the coverage thresholds.
52        #[arg(long, default_value = "testing-conventions.toml")]
53        config: PathBuf,
54    },
55}
56
57pub fn run<I, T>(args: I) -> anyhow::Result<i32>
58where
59    I: IntoIterator<Item = T>,
60    T: Into<std::ffi::OsString> + Clone,
61{
62    let cli = Cli::try_parse_from(args)?;
63    match cli.command {
64        // The config-driven `check` umbrella isn't wired yet; the scaffold
65        // proves the wiring while individual rules land under their test-kind
66        // group (e.g. `unit location`).
67        Some(Command::Check) | None => Ok(0),
68        Some(Command::Unit { rule }) => match rule {
69            UnitRule::Location { path, language } => run_unit_location(&path, language),
70            UnitRule::Coverage {
71                path,
72                language,
73                config,
74            } => run_unit_coverage(&path, language, &config),
75        },
76    }
77}
78
79/// Run the unit-test location check over `root` for `language`, reporting orphans.
80///
81/// Returns `0` when every source file has its colocated unit test; otherwise
82/// prints each orphan to stderr and returns `1`.
83fn run_unit_location(root: &Path, language: location::Language) -> anyhow::Result<i32> {
84    let orphans = location::missing_unit_tests(root, language)?;
85    if orphans.is_empty() {
86        return Ok(0);
87    }
88    for orphan in &orphans {
89        eprintln!("missing colocated unit test: {}", orphan.display());
90    }
91    eprintln!(
92        "error: {} source file(s) missing a colocated unit test",
93        orphans.len()
94    );
95    Ok(1)
96}
97
98/// Run the unit-test coverage check over `root` for `language`, enforcing the
99/// floor from the config at `config_path`. Returns `0` when the floor is met,
100/// `1` otherwise.
101fn run_unit_coverage(
102    root: &Path,
103    language: location::Language,
104    config_path: &Path,
105) -> anyhow::Result<i32> {
106    let config = config::load_config(config_path)?;
107    let thresholds = match language {
108        location::Language::Python => {
109            let python = config
110                .python
111                .context("config has no [python] table to read coverage thresholds from")?;
112            coverage::Thresholds {
113                fail_under: python.coverage.fail_under,
114                branch: python.coverage.branch,
115            }
116        }
117        location::Language::TypeScript => anyhow::bail!(
118            "`unit coverage` supports `--language python` only for now; \
119             TypeScript coverage is a separate item"
120        ),
121    };
122    match coverage::measure(root, thresholds)? {
123        coverage::Outcome::Pass => Ok(0),
124        coverage::Outcome::Fail(reason) => {
125            eprintln!("error: coverage check failed — {reason}");
126            Ok(1)
127        }
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn no_args_returns_ok_zero() {
137        assert_eq!(run(["testing-conventions"]).unwrap(), 0);
138    }
139
140    #[test]
141    fn check_returns_ok_zero() {
142        assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
143    }
144
145    #[test]
146    fn unknown_flag_errors() {
147        assert!(run(["testing-conventions", "--bogus"]).is_err());
148    }
149
150    #[test]
151    fn help_flag_returns_clap_display_help() {
152        let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
153        let clap_err = err
154            .downcast_ref::<clap::Error>()
155            .expect("error should be a clap::Error");
156        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
157    }
158
159    #[test]
160    fn version_flag_returns_clap_display_version() {
161        let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
162        let clap_err = err
163            .downcast_ref::<clap::Error>()
164            .expect("error should be a clap::Error");
165        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
166    }
167}