Skip to main content

testing_conventions/
lib.rs

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