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        /// testing-conventions config file providing the `exempt` list. Optional:
50        /// if the file is absent, no files are exempt.
51        #[arg(long, default_value = "testing-conventions.toml")]
52        config: PathBuf,
53    },
54    /// Check that the unit suite meets the configured coverage floor.
55    Coverage {
56        /// Directory whose unit suite is run and measured.
57        path: PathBuf,
58        /// Language convention to enforce (required).
59        #[arg(long, value_enum)]
60        language: location::Language,
61        /// testing-conventions config file providing the coverage thresholds.
62        #[arg(long, default_value = "testing-conventions.toml")]
63        config: PathBuf,
64    },
65}
66
67/// Lints enforced on integration tests (mocking mechanism & style, and more to
68/// come). The README's "Integration" taxonomy.
69#[derive(Subcommand, Debug)]
70enum IntegrationRule {
71    /// Lint integration test files for mocking mechanism & style (Python).
72    Lint {
73        /// Directory to scan recursively for Python test files.
74        path: PathBuf,
75        /// Language convention to enforce (required).
76        #[arg(long, value_enum)]
77        language: location::Language,
78    },
79}
80
81pub fn run<I, T>(args: I) -> anyhow::Result<i32>
82where
83    I: IntoIterator<Item = T>,
84    T: Into<std::ffi::OsString> + Clone,
85{
86    let cli = Cli::try_parse_from(args)?;
87    match cli.command {
88        // The config-driven `check` umbrella isn't wired yet; the scaffold
89        // proves the wiring while individual rules land under their test-kind
90        // group (e.g. `unit location`).
91        Some(Command::Check) | None => Ok(0),
92        Some(Command::Unit { rule }) => match rule {
93            UnitRule::Location {
94                path,
95                language,
96                config,
97            } => run_unit_location(&path, language, &config),
98            UnitRule::Coverage {
99                path,
100                language,
101                config,
102            } => run_unit_coverage(&path, language, &config),
103        },
104        Some(Command::Integration { rule }) => match rule {
105            IntegrationRule::Lint { path, language } => run_integration_lint(&path, language),
106        },
107    }
108}
109
110/// Run the unit-test location check over `root` for `language`, reporting orphans.
111///
112/// Loads the `location`-rule exemptions from the config at `config_path` (no
113/// config file → no exemptions). Returns `0` when every source file has its
114/// colocated unit test; otherwise prints each orphan to stderr and returns `1`.
115fn run_unit_location(
116    root: &Path,
117    language: location::Language,
118    config_path: &Path,
119) -> anyhow::Result<i32> {
120    let exempt = location_exemptions(root, language, config_path)?;
121    let orphans = location::missing_unit_tests(root, language, &exempt)?;
122    if orphans.is_empty() {
123        return Ok(0);
124    }
125    for orphan in &orphans {
126        eprintln!("missing colocated unit test: {}", orphan.display());
127    }
128    eprintln!(
129        "error: {} source file(s) missing a colocated unit test \
130         (add a colocated test, or an `exempt` entry with a reason)",
131        orphans.len()
132    );
133    Ok(1)
134}
135
136/// The `location`-rule exempt paths for `language`, resolved (and validated) from
137/// the config at `config_path`. A missing config file means no exemptions — the
138/// check still runs, just with nothing exempted.
139fn location_exemptions(
140    root: &Path,
141    language: location::Language,
142    config_path: &Path,
143) -> anyhow::Result<std::collections::BTreeSet<String>> {
144    if !config_path.exists() {
145        return Ok(std::collections::BTreeSet::new());
146    }
147    let config = config::load_config(config_path)?;
148    config::resolve_exempt(root, config.exemptions(language), config::Rule::Location)
149}
150
151/// Run the unit-test coverage check over `root` for `language`, enforcing the
152/// floor from the config at `config_path`. Returns `0` when the floor is met,
153/// `1` otherwise.
154fn run_unit_coverage(
155    root: &Path,
156    language: location::Language,
157    config_path: &Path,
158) -> anyhow::Result<i32> {
159    let config = config::load_config(config_path)?;
160    let (thresholds, exempt) = match language {
161        location::Language::Python => {
162            let python = config
163                .python
164                .as_ref()
165                .context("config has no [python] table to read coverage thresholds from")?;
166            let coverage = python
167                .coverage
168                .as_ref()
169                .context("config [python] table has no `coverage` thresholds")?;
170            let thresholds = coverage::Thresholds {
171                fail_under: coverage.fail_under,
172                branch: coverage.branch,
173            };
174            let exempt = config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?;
175            (thresholds, exempt)
176        }
177        location::Language::TypeScript => anyhow::bail!(
178            "`unit coverage` supports `--language python` only for now; \
179             TypeScript coverage is a separate item"
180        ),
181    };
182    let omit: Vec<String> = exempt.into_iter().collect();
183    match coverage::measure(root, thresholds, &omit)? {
184        coverage::Outcome::Pass => Ok(0),
185        coverage::Outcome::Fail(reason) => {
186            eprintln!("error: coverage check failed — {reason}");
187            Ok(1)
188        }
189    }
190}
191
192/// Run the integration-test lints over `root` for `language`, printing each
193/// violation to stderr as `path:line: rule — message` and returning `1` when any
194/// are found, `0` otherwise.
195fn run_integration_lint(root: &Path, language: location::Language) -> anyhow::Result<i32> {
196    match language {
197        location::Language::Python => {}
198        location::Language::TypeScript => {
199            anyhow::bail!("`integration lint` supports `--language python` only for now")
200        }
201    }
202    let violations = lint::find_violations(root)?;
203    if violations.is_empty() {
204        return Ok(0);
205    }
206    for v in &violations {
207        eprintln!(
208            "{}:{}: {} — {}",
209            v.file.display(),
210            v.line,
211            v.rule,
212            v.message
213        );
214    }
215    eprintln!("error: {} lint violation(s)", violations.len());
216    Ok(1)
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn no_args_returns_ok_zero() {
225        assert_eq!(run(["testing-conventions"]).unwrap(), 0);
226    }
227
228    #[test]
229    fn check_returns_ok_zero() {
230        assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
231    }
232
233    #[test]
234    fn unknown_flag_errors() {
235        assert!(run(["testing-conventions", "--bogus"]).is_err());
236    }
237
238    #[test]
239    fn help_flag_returns_clap_display_help() {
240        let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
241        let clap_err = err
242            .downcast_ref::<clap::Error>()
243            .expect("error should be a clap::Error");
244        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
245    }
246
247    #[test]
248    fn version_flag_returns_clap_display_version() {
249        let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
250        let clap_err = err
251            .downcast_ref::<clap::Error>()
252            .expect("error should be a clap::Error");
253        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
254    }
255}