From 28bb5414f1673a30c888b8f6b4ed6a8345ca5b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:03:40 +0200 Subject: [PATCH 01/22] docs(spec): add Rust migration design --- .../specs/2026-07-03-rust-migration-design.md | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-03-rust-migration-design.md diff --git a/docs/superpowers/specs/2026-07-03-rust-migration-design.md b/docs/superpowers/specs/2026-07-03-rust-migration-design.md new file mode 100644 index 0000000..90feb04 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-rust-migration-design.md @@ -0,0 +1,136 @@ +# Rust Migration — Commit Message Validator + +**Date:** 2026-07-03 +**Status:** Approved design, ready for implementation planning + +## Goal + +Migrate the bash commit-message validator to Rust. Drivers: maintainability +(the bash state machine + regex are hard to extend), correctness (bash quoting +and macOS/bash-version portability quirks), performance (large ranges spawn git +per commit), and shipping a single distributable binary. + +## Non-goals + +- Changing the validation *rules* or *exit codes*. This is a behavior-compatible + port: same rules, same exit codes. Error message wording and the CLI surface + may be modernized. +- Publishing the core to crates.io (not now — structure allows promotion later). + +## Compatibility contract + +- **Validation rules:** identical to `validator.sh` (regex patterns ported 1:1, + including quirks such as the subject end-class `[^ ^\.]`). +- **Exit codes:** unchanged. `Structure=1, Header=2, HeaderLength=3, Type=4, + Scope=5, Subject=6, BodyLength=7, TrailingSpace=8, Jira=9, Revert=10`. + Success = 0. `temp` commits print `ignoring temporary commit` and exit 0. +- **Env var names:** unchanged (`COMMIT_VALIDATOR_NO_JIRA`, + `COMMIT_VALIDATOR_ALLOW_TEMP`, `COMMIT_VALIDATOR_NO_REVERT_SHA1`, + `GLOBAL_JIRA_IN_HEADER`, `GLOBAL_MAX_LENGTH`, `GLOBAL_BODY_MAX_LENGTH`, + `GLOBAL_JIRA_TYPES`) so `action.yml`'s `inputs → env` wiring keeps working. + Empty/unset boolean env → off; non-empty → on (preserves bash quirk). + +## Architecture (Approach A: library + CLI in one package) + +Single Cargo package `commit-message-validator`. + +``` +src/ + lib.rs # public API: validate_message(&str, &Config) -> Result + config.rs # Config struct + defaults + error.rs # ValidationError enum (carries exit code + Display message) + patterns.rs # compiled regexes (LazyLock), ported 1:1 from validator.sh + parser.rs # WAITING_HEADER→…→FOOTER state machine → ParsedMessage + validate.rs # per-rule fns (header, length, type, scope, subject, body, + # trailing, jira, revert) + preprocess.rs # MERGE_MSG skip, strip `#` comment lines, "merge" first-word skip + main.rs # clap CLI: subcommands `message` / `range`; git subprocess; I/O + exit codes +tests/ + cli.rs # assert_cmd integration tests against the built binary +``` + +**Isolation boundary:** everything under `lib.rs` is pure (string in, `Result` +out — no file/git/process I/O). All I/O (reading the message file, shelling out +to `git`, printing, `process::exit`) lives in `main.rs`. This makes the rules +trivially unit-testable and is what proves equivalence against the `.bats` suite. + +### Data flow + +`main` parses CLI/env → builds `Config` → reads input (file for `message`; +`git log -1 --pretty=%B` per commit for `range`) → `preprocess` (skip/strip) → +`parser` builds `ParsedMessage { header, body, jira, footer }` → `validate` runs +the rule pipeline → `Ok` or `ValidationError` → `main` prints and exits with the +mapped code. + +### Parser notes + +- Line-by-line processing mirrors bash `read` semantics. +- The state machine reproduces `validate_overall_structure`: header → empty line + → body/jira-footer/broken → footer, with the same structure errors + (missing empty line, double empty line, trailing newline, etc.). +- JIRA reference extracted from the footer, or from the header when + `--jira-in-header` is set. + +### Regex + +`regex` crate, patterns compiled once via `LazyLock`, ported verbatim from +`validator.sh`. No backreferences are needed, so `regex` covers all patterns. + +### Git access (range subcommand) + +Shell out to `git` as a subprocess (as today). Rationale: `git` is an +unavoidable dependency for a commit validator, keeps the binary tiny, and keeps +static musl cross-compilation easy — directly serving the prebuilt-binary +distribution model. `git2`/libgit2 was rejected as a heavy C dependency that +complicates cross-builds. + +## CLI surface + +``` +commit-message-validator message # commit-msg hook (replaces check_message.sh) +commit-message-validator range # CI / pre-push (replaces check.sh) +``` + +clap derive. Each option is declared once with an `env` fallback, so a flag wins, +otherwise the env var, otherwise the default: + +| Flag | Env fallback | Default | +|---|---|---| +| `--no-jira` | `COMMIT_VALIDATOR_NO_JIRA` | off | +| `--allow-temp` | `COMMIT_VALIDATOR_ALLOW_TEMP` | off | +| `--no-revert-sha1` | `COMMIT_VALIDATOR_NO_REVERT_SHA1` | off | +| `--jira-in-header` | `GLOBAL_JIRA_IN_HEADER` | off | +| `--header-length ` | `GLOBAL_MAX_LENGTH` | 100 | +| `--body-length ` | `GLOBAL_BODY_MAX_LENGTH` | 100 | +| `--jira-types ` | `GLOBAL_JIRA_TYPES` | `feat fix` | + +## Testing + +- **Unit tests** per module for the pure core — the bulk, ported from + `validator.bats` (~16.7 KB of cases) as table-driven tests asserting + `(exit code, message)` per input. This is the equivalence proof. +- **Integration tests** (`tests/cli.rs`, `assert_cmd` + `predicates`) cover + `message` preprocessing (MERGE_MSG skip, comment stripping, merge-commit skip) + and a small `range` case against a throwaway temp git repo. +- The old `.bats` files are removed once their cases are ported (retained in git + history). + +## Distribution / CI + +- **Release workflow:** build static binaries for `x86_64`/`aarch64` × + linux(musl)/macOS, attach to the GitHub Release for the tag. +- **`action.yml`:** composite action downloads the binary matching the runner + os/arch (cached via `actions/cache`) and runs `range`, keeping the existing + `inputs → env` wiring. +- **pre-push:** thin script downloads/uses the prebuilt binary, calls `range`. +- **pre-commit (`.pre-commit-hooks.yaml`):** `language: script` wrapper that + downloads the prebuilt binary on first run and calls `message`. Keeps a small + bash shim but needs no Rust toolchain on the committer's machine. + +## Phasing + +1. Core library (`config`, `error`, `patterns`, `parser`, `validate`, + `preprocess`) + unit tests ported from `validator.bats`. +2. CLI (`main.rs`, clap subcommands, git subprocess) + `tests/cli.rs`. +3. Distribution: release build workflow, `action.yml`, `pre-push`, + `.pre-commit-hooks.yaml` download shim; remove obsolete bash + `.bats`. From 67a16514e2fc2e4394f3f016a70e7e06e2d2617e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:05:49 +0200 Subject: [PATCH 02/22] docs(spec): add TDD, coverage, CI and release constraints --- .../specs/2026-07-03-rust-migration-design.md | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-07-03-rust-migration-design.md b/docs/superpowers/specs/2026-07-03-rust-migration-design.md index 90feb04..ddf3594 100644 --- a/docs/superpowers/specs/2026-07-03-rust-migration-design.md +++ b/docs/superpowers/specs/2026-07-03-rust-migration-design.md @@ -106,6 +106,13 @@ otherwise the env var, otherwise the default: ## Testing +- **Workflow: strict TDD.** For every rule and code path, write the failing test + first, then the minimal implementation to pass, then refactor. Tests are ported + from the `.bats` suites as the golden reference before the corresponding Rust + code exists. +- **Coverage target: 100%.** Measured with `cargo llvm-cov`. CI fails if coverage + regresses below the threshold. Any intentionally-uncovered line must carry an + explicit justification. - **Unit tests** per module for the pure core — the bulk, ported from `validator.bats` (~16.7 KB of cases) as table-driven tests asserting `(exit code, message)` per input. This is the equivalence proof. @@ -115,10 +122,20 @@ otherwise the env var, otherwise the default: - The old `.bats` files are removed once their cases are ported (retained in git history). +## Commits + +- **Atomic, review-friendly commits.** Each commit is a single logical step that + builds and passes tests on its own — e.g. one commit per module (test+impl + together, since TDD pairs them), one for the CLI, one per distribution surface. + Follow this repo's own commit convention (validated by the tool itself). + ## Distribution / CI -- **Release workflow:** build static binaries for `x86_64`/`aarch64` × - linux(musl)/macOS, attach to the GitHub Release for the tag. +- **CI workflow (GitHub Actions, on push/PR):** `cargo fmt --check`, + `cargo clippy -D warnings`, `cargo test`, and `cargo llvm-cov` with the 100% + coverage gate. This must be green before merge. +- **Release workflow:** on tag push, build static binaries for `x86_64`/`aarch64` + × linux(musl)/macOS and attach them to the GitHub Release for the tag. - **`action.yml`:** composite action downloads the binary matching the runner os/arch (cached via `actions/cache`) and runs `range`, keeping the existing `inputs → env` wiring. @@ -129,8 +146,11 @@ otherwise the env var, otherwise the default: ## Phasing -1. Core library (`config`, `error`, `patterns`, `parser`, `validate`, - `preprocess`) + unit tests ported from `validator.bats`. -2. CLI (`main.rs`, clap subcommands, git subprocess) + `tests/cli.rs`. -3. Distribution: release build workflow, `action.yml`, `pre-push`, - `.pre-commit-hooks.yaml` download shim; remove obsolete bash + `.bats`. +1. Scaffold Cargo package + **CI workflow first** (fmt/clippy/test/llvm-cov gate), + so every subsequent commit is checked in GitHub from the start. +2. Core library (`config`, `error`, `patterns`, `parser`, `validate`, + `preprocess`) built TDD, unit tests ported from `validator.bats`. +3. CLI (`main.rs`, clap subcommands, git subprocess) + `tests/cli.rs`. +4. Release workflow (tagged, cross-platform static binaries). +5. Distribution surfaces: `action.yml`, `pre-push`, `.pre-commit-hooks.yaml` + download shim; remove obsolete bash + `.bats`. From 280fb3584c49c941c46a51f2af6a6d0a3ee01f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:06:08 +0200 Subject: [PATCH 03/22] docs(spec): add quality cadence and draft PR to workflow --- docs/superpowers/specs/2026-07-03-rust-migration-design.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/superpowers/specs/2026-07-03-rust-migration-design.md b/docs/superpowers/specs/2026-07-03-rust-migration-design.md index ddf3594..78885bc 100644 --- a/docs/superpowers/specs/2026-07-03-rust-migration-design.md +++ b/docs/superpowers/specs/2026-07-03-rust-migration-design.md @@ -128,6 +128,9 @@ otherwise the env var, otherwise the default: builds and passes tests on its own — e.g. one commit per module (test+impl together, since TDD pairs them), one for the CLI, one per distribution surface. Follow this repo's own commit convention (validated by the tool itself). +- **Quality cadence.** Run `/simplify` and `/code-review` regularly during + implementation — at minimum before each atomic commit / at the end of each + phase — and address findings before committing. ## Distribution / CI @@ -154,3 +157,6 @@ otherwise the env var, otherwise the default: 4. Release workflow (tagged, cross-platform static binaries). 5. Distribution surfaces: `action.yml`, `pre-push`, `.pre-commit-hooks.yaml` download shim; remove obsolete bash + `.bats`. + +All work happens on a feature branch. At the end, **open a draft PR** against +`master` for review. From 9c8603a7e7f6f124fc0d1a8d2dbfa785a03f91a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:06:56 +0200 Subject: [PATCH 04/22] docs(spec): add repo documentation updates as a deliverable --- .../specs/2026-07-03-rust-migration-design.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/superpowers/specs/2026-07-03-rust-migration-design.md b/docs/superpowers/specs/2026-07-03-rust-migration-design.md index 78885bc..04b748a 100644 --- a/docs/superpowers/specs/2026-07-03-rust-migration-design.md +++ b/docs/superpowers/specs/2026-07-03-rust-migration-design.md @@ -160,3 +160,19 @@ otherwise the env var, otherwise the default: All work happens on a feature branch. At the end, **open a draft PR** against `master` for review. + +## Documentation updates + +Update all repo docs to reflect the Rust tool (part of phase 5): + +- **`README.md`** — replace the "only git and bash" framing with the Rust binary + story; update installation (prebuilt binary download), usage of the `message` + and `range` subcommands, the pre-commit / GitHub Action / pre-push examples, + and the flags/env table. +- **`git-commit-template`** — review for any wording that references the bash + scripts; keep the convention guidance accurate. +- **`.pre-commit-config.yaml`** — update the self-referencing hook example to the + new download-shim hook. +- **`Makefile`** — adjust `lint`/`venv` targets if they reference removed scripts. +- Any inline references to `check.sh` / `check_message.sh` / `validator.sh` + across the repo are updated or removed. From b3b805cabd339fa7e48ece0182c77a67d0719787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:07:21 +0200 Subject: [PATCH 05/22] docs(spec): set CI coverage hard-fail floor at 90% --- .../specs/2026-07-03-rust-migration-design.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-07-03-rust-migration-design.md b/docs/superpowers/specs/2026-07-03-rust-migration-design.md index 04b748a..9c97271 100644 --- a/docs/superpowers/specs/2026-07-03-rust-migration-design.md +++ b/docs/superpowers/specs/2026-07-03-rust-migration-design.md @@ -110,9 +110,9 @@ otherwise the env var, otherwise the default: first, then the minimal implementation to pass, then refactor. Tests are ported from the `.bats` suites as the golden reference before the corresponding Rust code exists. -- **Coverage target: 100%.** Measured with `cargo llvm-cov`. CI fails if coverage - regresses below the threshold. Any intentionally-uncovered line must carry an - explicit justification. +- **Coverage: aim for 100%,** measured with `cargo llvm-cov`. CI **hard-fails + under 90%**. Any intentionally-uncovered line must carry an explicit + justification. - **Unit tests** per module for the pure core — the bulk, ported from `validator.bats` (~16.7 KB of cases) as table-driven tests asserting `(exit code, message)` per input. This is the equivalence proof. @@ -135,8 +135,8 @@ otherwise the env var, otherwise the default: ## Distribution / CI - **CI workflow (GitHub Actions, on push/PR):** `cargo fmt --check`, - `cargo clippy -D warnings`, `cargo test`, and `cargo llvm-cov` with the 100% - coverage gate. This must be green before merge. + `cargo clippy -D warnings`, `cargo test`, and `cargo llvm-cov` with a hard-fail + under 90% coverage (100% aimed for). This must be green before merge. - **Release workflow:** on tag push, build static binaries for `x86_64`/`aarch64` × linux(musl)/macOS and attach them to the GitHub Release for the tag. - **`action.yml`:** composite action downloads the binary matching the runner From 7d827484f9dcae2bba5834a421e9f1fa28f107a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:16:53 +0200 Subject: [PATCH 06/22] docs(plan): add Rust migration implementation plan --- .../plans/2026-07-03-rust-migration.md | 2285 +++++++++++++++++ 1 file changed, 2285 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-03-rust-migration.md diff --git a/docs/superpowers/plans/2026-07-03-rust-migration.md b/docs/superpowers/plans/2026-07-03-rust-migration.md new file mode 100644 index 0000000..4cfa674 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-rust-migration.md @@ -0,0 +1,2285 @@ +# Rust Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reimplement the bash commit-message validator as a single Rust binary with identical validation rules and exit codes, a cleaner CLI, prebuilt cross-platform release binaries, and full test coverage. + +**Architecture:** A pure library core (`config`, `error`, `patterns`, `parser`, `validate`, `preprocess`) does all string-in/`Result`-out work with no I/O. A thin `clap` CLI (`main.rs`) with two subcommands (`message `, `range `) handles file/git/process I/O and maps `ValidationError` variants to the historical exit codes. Git access is via subprocess. The `.bats` suites are the golden reference and are ported to Rust tests. + +**Tech Stack:** Rust (edition 2021, rust-version 1.80 for `std::sync::LazyLock`), `clap` (derive), `regex`; dev: `assert_cmd`, `predicates`, `tempfile`; CI coverage via `cargo-llvm-cov`. + +## Global Constraints + +- **Behavior-compatible port.** Validation rules are identical to `validator.sh` (regexes ported verbatim, including quirks). Error message *wording* may be tidied; rules and exit codes may not change. +- **Exit codes (verbatim):** Structure=1, Header=2, HeaderLength=3, Type=4, Scope=5, Subject=6, BodyLength=7, TrailingSpace=8, Jira=9, Revert=10. Success=0. `temp` commits print `ignoring temporary commit` and exit 0. +- **Env var names unchanged:** `COMMIT_VALIDATOR_NO_JIRA`, `COMMIT_VALIDATOR_ALLOW_TEMP`, `COMMIT_VALIDATOR_NO_REVERT_SHA1`, `GLOBAL_JIRA_IN_HEADER`, `GLOBAL_MAX_LENGTH`, `GLOBAL_BODY_MAX_LENGTH`, `GLOBAL_JIRA_TYPES`. Empty/unset boolean env → off; non-empty → on. Empty/unset numeric/list env → default. +- **Defaults:** header length 100, body length 100, jira types `feat fix`. +- **Crate & binary name:** `commit-message-validator`. +- **Workflow:** strict TDD (failing test → minimal impl → refactor). Atomic, individually-green commits following this repo's own commit convention. Run `/simplify` and `/code-review` before each commit / at end of each phase and address findings. All work on a feature branch; open a **draft PR** at the end. +- **Quality gates:** every commit must pass `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test`. CI runs `cargo llvm-cov` and **hard-fails under 90%** line coverage (aim 100%). + +--- + +## Task 1: Scaffold Cargo package + CI workflow + +Sets up the branch, crate skeleton, dependencies, and the GitHub CI workflow so every later commit is checked. Deliverable: `cargo test` runs (with a trivial placeholder test) and CI is defined. + +**Files:** +- Create: `Cargo.toml` +- Create: `src/lib.rs` (temporary placeholder) +- Create: `src/main.rs` (temporary placeholder) +- Create: `.github/workflows/ci.yml` +- Create: `rust-toolchain.toml` +- Create: `.gitignore` entry for `/target` + +**Interfaces:** +- Produces: crate `commit_message_validator` (lib) + binary `commit-message-validator`. + +- [ ] **Step 1: Create the feature branch** + +```bash +git checkout -b feat/rust-migration +``` + +- [ ] **Step 2: Write `Cargo.toml`** + +```toml +[package] +name = "commit-message-validator" +version = "2.0.0" +edition = "2021" +rust-version = "1.80" +description = "Enforce angular commit message convention" +license = "MIT" + +[[bin]] +name = "commit-message-validator" +path = "src/main.rs" + +[lib] +name = "commit_message_validator" +path = "src/lib.rs" + +[dependencies] +clap = { version = "4", features = ["derive", "env"] } +regex = "1" + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +tempfile = "3" +``` + +- [ ] **Step 3: Add `/target` to `.gitignore`** + +Append `/target` on its own line to the existing `.gitignore`. + +- [ ] **Step 4: Write `rust-toolchain.toml`** + +```toml +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] +``` + +- [ ] **Step 5: Write placeholder `src/lib.rs`** + +```rust +#[cfg(test)] +mod tests { + #[test] + fn scaffold_compiles() { + assert_eq!(2 + 2, 4); + } +} +``` + +- [ ] **Step 6: Write placeholder `src/main.rs`** + +```rust +fn main() { + println!("commit-message-validator"); +} +``` + +- [ ] **Step 7: Write `.github/workflows/ci.yml`** + +```yaml +--- +name: CI +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: Format + run: cargo fmt --all -- --check + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + - name: Test + run: cargo test --all-targets + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + - name: Coverage (fail under 90%) + run: cargo llvm-cov --all-targets --fail-under-lines 90 +``` + +- [ ] **Step 8: Verify it builds and tests pass** + +Run: `cargo fmt --all -- --check && cargo clippy --all-targets -- -D warnings && cargo test` +Expected: PASS (1 test `scaffold_compiles`). + +- [ ] **Step 9: Commit** + +```bash +git add Cargo.toml Cargo.lock rust-toolchain.toml .gitignore src/lib.rs src/main.rs .github/workflows/ci.yml +git commit -m "chore(rust): scaffold cargo package and CI workflow" +``` + +--- + +## Task 2: Error types (`error.rs`) + +The `ValidationError` enum: one variant per failure, each carrying a message and mapping to its exit code. + +**Files:** +- Create: `src/error.rs` +- Modify: `src/lib.rs` (add `pub mod error;`) + +**Interfaces:** +- Produces: `pub enum ValidationError` with variants `Structure`, `Header`, `HeaderLength`, `Type`, `Scope`, `Subject`, `BodyLength`, `TrailingSpace`, `Jira`, `Revert`, each `(String)`; method `pub fn code(&self) -> i32`; `impl Display`. + +- [ ] **Step 1: Write the failing test** (in `src/error.rs`) + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codes_match_historical_values() { + assert_eq!(ValidationError::Structure(String::new()).code(), 1); + assert_eq!(ValidationError::Header(String::new()).code(), 2); + assert_eq!(ValidationError::HeaderLength(String::new()).code(), 3); + assert_eq!(ValidationError::Type(String::new()).code(), 4); + assert_eq!(ValidationError::Scope(String::new()).code(), 5); + assert_eq!(ValidationError::Subject(String::new()).code(), 6); + assert_eq!(ValidationError::BodyLength(String::new()).code(), 7); + assert_eq!(ValidationError::TrailingSpace(String::new()).code(), 8); + assert_eq!(ValidationError::Jira(String::new()).code(), 9); + assert_eq!(ValidationError::Revert(String::new()).code(), 10); + } + + #[test] + fn display_renders_message() { + assert_eq!(ValidationError::Type("boom".into()).to_string(), "boom"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --lib error` +Expected: FAIL (compile error — `ValidationError` not defined). + +- [ ] **Step 3: Write the implementation** (top of `src/error.rs`) + +```rust +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValidationError { + Structure(String), + Header(String), + HeaderLength(String), + Type(String), + Scope(String), + Subject(String), + BodyLength(String), + TrailingSpace(String), + Jira(String), + Revert(String), +} + +impl ValidationError { + pub fn code(&self) -> i32 { + match self { + ValidationError::Structure(_) => 1, + ValidationError::Header(_) => 2, + ValidationError::HeaderLength(_) => 3, + ValidationError::Type(_) => 4, + ValidationError::Scope(_) => 5, + ValidationError::Subject(_) => 6, + ValidationError::BodyLength(_) => 7, + ValidationError::TrailingSpace(_) => 8, + ValidationError::Jira(_) => 9, + ValidationError::Revert(_) => 10, + } + } + + fn message(&self) -> &str { + match self { + ValidationError::Structure(m) + | ValidationError::Header(m) + | ValidationError::HeaderLength(m) + | ValidationError::Type(m) + | ValidationError::Scope(m) + | ValidationError::Subject(m) + | ValidationError::BodyLength(m) + | ValidationError::TrailingSpace(m) + | ValidationError::Jira(m) + | ValidationError::Revert(m) => m, + } + } +} + +impl fmt::Display for ValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message()) + } +} +``` + +- [ ] **Step 4: Wire into `src/lib.rs`** — replace the placeholder file with: + +```rust +pub mod error; + +pub use error::ValidationError; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test --lib && cargo clippy --all-targets -- -D warnings` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/error.rs src/lib.rs +git commit -m "feat(error): add ValidationError with exit codes" +``` + +--- + +## Task 3: Config + resolution (`config.rs`) + +The pure `Config` struct, its defaults, and a pure `resolve()` that combines CLI overrides with an env lookup using the bash-compatible empty/unset semantics. + +**Files:** +- Create: `src/config.rs` +- Modify: `src/lib.rs` (add `pub mod config;` and re-exports) + +**Interfaces:** +- Produces: + - `pub struct Config { pub no_jira: bool, pub allow_temp: bool, pub no_revert_sha1: bool, pub jira_in_header: bool, pub header_max_length: usize, pub body_max_length: usize, pub jira_types: Vec }` with `impl Default`. + - `pub struct Overrides { pub no_jira: bool, pub allow_temp: bool, pub no_revert_sha1: bool, pub jira_in_header: bool, pub header_length: Option, pub body_length: Option, pub jira_types: Option }` with `#[derive(Default)]`. + - `pub fn Config::resolve(o: &Overrides, env: impl Fn(&str) -> Option) -> Config`. + +- [ ] **Step 1: Write the failing tests** (in `src/config.rs`) + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn no_env(_: &str) -> Option { + None + } + + #[test] + fn defaults_when_nothing_set() { + let c = Config::resolve(&Overrides::default(), no_env); + assert!(!c.no_jira && !c.allow_temp && !c.no_revert_sha1 && !c.jira_in_header); + assert_eq!(c.header_max_length, 100); + assert_eq!(c.body_max_length, 100); + assert_eq!(c.jira_types, vec!["feat".to_string(), "fix".to_string()]); + } + + #[test] + fn cli_flag_enables_bool() { + let o = Overrides { no_jira: true, ..Default::default() }; + assert!(Config::resolve(&o, no_env).no_jira); + } + + #[test] + fn nonempty_env_enables_bool_empty_does_not() { + let on = |n: &str| (n == "COMMIT_VALIDATOR_NO_JIRA").then(|| "1".to_string()); + let empty = |n: &str| (n == "COMMIT_VALIDATOR_NO_JIRA").then(String::new); + assert!(Config::resolve(&Overrides::default(), on).no_jira); + assert!(!Config::resolve(&Overrides::default(), empty).no_jira); + } + + #[test] + fn cli_length_overrides_env_and_default() { + let o = Overrides { header_length: Some(150), ..Default::default() }; + assert_eq!(Config::resolve(&o, no_env).header_max_length, 150); + } + + #[test] + fn env_length_used_when_no_cli_and_empty_falls_back() { + let set = |n: &str| (n == "GLOBAL_BODY_MAX_LENGTH").then(|| "150".to_string()); + let empty = |n: &str| (n == "GLOBAL_BODY_MAX_LENGTH").then(String::new); + assert_eq!(Config::resolve(&Overrides::default(), set).body_max_length, 150); + assert_eq!(Config::resolve(&Overrides::default(), empty).body_max_length, 100); + } + + #[test] + fn jira_types_split_on_whitespace() { + let o = Overrides { jira_types: Some("feat fix chore".into()), ..Default::default() }; + let c = Config::resolve(&o, no_env); + assert_eq!(c.jira_types, vec!["feat", "fix", "chore"]); + } + + #[test] + fn empty_env_jira_types_falls_back_to_default() { + let empty = |n: &str| (n == "GLOBAL_JIRA_TYPES").then(String::new); + assert_eq!( + Config::resolve(&Overrides::default(), empty).jira_types, + vec!["feat".to_string(), "fix".to_string()] + ); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib config` +Expected: FAIL (compile error — `Config`/`Overrides` not defined). + +- [ ] **Step 3: Write the implementation** (top of `src/config.rs`) + +```rust +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + pub no_jira: bool, + pub allow_temp: bool, + pub no_revert_sha1: bool, + pub jira_in_header: bool, + pub header_max_length: usize, + pub body_max_length: usize, + pub jira_types: Vec, +} + +impl Default for Config { + fn default() -> Self { + Config { + no_jira: false, + allow_temp: false, + no_revert_sha1: false, + jira_in_header: false, + header_max_length: 100, + body_max_length: 100, + jira_types: vec!["feat".to_string(), "fix".to_string()], + } + } +} + +#[derive(Debug, Default)] +pub struct Overrides { + pub no_jira: bool, + pub allow_temp: bool, + pub no_revert_sha1: bool, + pub jira_in_header: bool, + pub header_length: Option, + pub body_length: Option, + pub jira_types: Option, +} + +fn resolve_usize(cli: Option, env: Option, default: usize) -> usize { + if let Some(n) = cli { + return n; + } + env.and_then(|v| v.parse().ok()).unwrap_or(default) +} + +impl Config { + pub fn resolve(o: &Overrides, env: impl Fn(&str) -> Option) -> Config { + let flag = |cli: bool, name: &str| { + cli || env(name).map(|v| !v.is_empty()).unwrap_or(false) + }; + let default = Config::default(); + let jira_types = o + .jira_types + .clone() + .or_else(|| env("GLOBAL_JIRA_TYPES").filter(|v| !v.is_empty())) + .map(|t| t.split_whitespace().map(String::from).collect()) + .unwrap_or(default.jira_types); + Config { + no_jira: flag(o.no_jira, "COMMIT_VALIDATOR_NO_JIRA"), + allow_temp: flag(o.allow_temp, "COMMIT_VALIDATOR_ALLOW_TEMP"), + no_revert_sha1: flag(o.no_revert_sha1, "COMMIT_VALIDATOR_NO_REVERT_SHA1"), + jira_in_header: flag(o.jira_in_header, "GLOBAL_JIRA_IN_HEADER"), + header_max_length: resolve_usize( + o.header_length, + env("GLOBAL_MAX_LENGTH"), + default.header_max_length, + ), + body_max_length: resolve_usize( + o.body_length, + env("GLOBAL_BODY_MAX_LENGTH"), + default.body_max_length, + ), + jira_types, + } + } +} +``` + +- [ ] **Step 4: Wire into `src/lib.rs`** + +```rust +pub mod config; +pub mod error; + +pub use config::{Config, Overrides}; +pub use error::ValidationError; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test --lib && cargo clippy --all-targets -- -D warnings` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/config.rs src/lib.rs +git commit -m "feat(config): add Config with env/flag resolution" +``` + +--- + +## Task 4: Regex patterns (`patterns.rs`) + +All patterns from `validator.sh`, compiled once via `LazyLock`, ported verbatim. + +**Files:** +- Create: `src/patterns.rs` +- Modify: `src/lib.rs` (add `mod patterns;` — crate-private) + +**Interfaces:** +- Produces (all `pub`): `HEADER`, `TYPE`, `SCOPE`, `SUBJECT`, `JIRA_FOOTER`, `JIRA_HEADER`, `BROKE`, `TRAILING_SPACE`, `REVERT_HEADER`, `REVERT_COMMIT`, `TEMP_HEADER` as `LazyLock`; const `JIRA: &str`. + +- [ ] **Step 1: Write the failing tests** (in `src/patterns.rs`) + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn header_captures_type_scope_subject() { + let caps = HEADER.captures("type(scope): message").unwrap(); + assert_eq!(&caps[1], "type"); + assert_eq!(&caps[2], "scope"); + assert_eq!(&caps[3], "message"); + } + + #[test] + fn header_rejects_malformed() { + assert!(HEADER.captures("type(scope):message").is_none()); + assert!(HEADER.captures("type scope: message").is_none()); + assert!(HEADER.captures("type(scope): ").is_none()); + } + + #[test] + fn type_only_known_lowercase() { + assert!(TYPE.is_match("feat")); + assert!(!TYPE.is_match("Feat")); + assert!(!TYPE.is_match("feat ")); + assert!(!TYPE.is_match("plop")); + } + + #[test] + fn scope_is_kebab_case() { + assert!(SCOPE.is_match("p2")); + assert!(SCOPE.is_match("pl2op-plop1-plop-0001")); + assert!(!SCOPE.is_match("")); + assert!(!SCOPE.is_match("plopPlop")); + assert!(!SCOPE.is_match("plop plop")); + assert!(!SCOPE.is_match("plop ")); + } + + #[test] + fn subject_rules() { + assert!(SUBJECT.is_match("0002 dedezf ef")); + assert!(!SUBJECT.is_match("")); + assert!(!SUBJECT.is_match("plop ")); + assert!(!SUBJECT.is_match("plop.")); + } + + #[test] + fn jira_footer_and_header() { + assert!(JIRA_FOOTER.is_match("ABC-1234")); + assert!(JIRA_FOOTER.is_match("ABC-1234 DE-1234")); + assert!(!JIRA_FOOTER.is_match("2345")); + let caps = JIRA_HEADER.captures("feat(abc): ABC-1234").unwrap(); + assert_eq!(&caps[1], "ABC-1234"); + } + + #[test] + fn misc_patterns() { + assert!(BROKE.is_match("BROKEN:")); + assert!(TRAILING_SPACE.is_match("foo ")); + assert!(!TRAILING_SPACE.is_match("foo")); + assert!(REVERT_HEADER.is_match("revert: type(scope): message")); + assert!(REVERT_HEADER.is_match("Revert \"type(scope): message\"")); + assert!(REVERT_COMMIT.captures("This reverts commit 1234abcd.").is_some()); + assert!(TEMP_HEADER.is_match("fixup! foo")); + assert!(TEMP_HEADER.is_match("squash! foo")); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib patterns` +Expected: FAIL (compile error — patterns not defined). + +- [ ] **Step 3: Write the implementation** (top of `src/patterns.rs`) + +```rust +use regex::Regex; +use std::sync::LazyLock; + +pub const JIRA: &str = r"[A-Z]{2,7}[0-9]{0,6}-[0-9]{1,6}"; + +pub static HEADER: LazyLock = + LazyLock::new(|| Regex::new(r"^([^(]+)\(([^)]+)\): (.+)$").unwrap()); +pub static TYPE: LazyLock = + LazyLock::new(|| Regex::new(r"^(feat|fix|docs|gen|lint|refactor|test|chore)$").unwrap()); +pub static SCOPE: LazyLock = + LazyLock::new(|| Regex::new(r"^([a-z][a-z0-9]*)(-[a-z0-9]+)*$").unwrap()); +pub static SUBJECT: LazyLock = + LazyLock::new(|| Regex::new(r"^[A-Za-z0-9].*[^ ^.]$").unwrap()); +pub static JIRA_FOOTER: LazyLock = + LazyLock::new(|| Regex::new(&format!(r"^({JIRA} ?)+$")).unwrap()); +pub static JIRA_HEADER: LazyLock = + LazyLock::new(|| Regex::new(&format!(r"^.*[^A-Z]({JIRA}).*$")).unwrap()); +pub static BROKE: LazyLock = LazyLock::new(|| Regex::new(r"^BROKEN:$").unwrap()); +pub static TRAILING_SPACE: LazyLock = LazyLock::new(|| Regex::new(r" +$").unwrap()); +pub static REVERT_HEADER: LazyLock = + LazyLock::new(|| Regex::new(r"^[Rr](evert|eapply)[: ].*$").unwrap()); +pub static REVERT_COMMIT: LazyLock = + LazyLock::new(|| Regex::new(r"^This reverts commit ([a-f0-9]+)").unwrap()); +pub static TEMP_HEADER: LazyLock = + LazyLock::new(|| Regex::new(r"^(fixup!|squash!).*$").unwrap()); +``` + +> Note the subject class `[^ ^.]` is intentionally `{space, caret, dot}` negated — a faithful port of the bash `[^ ^\.]`. + +- [ ] **Step 4: Wire into `src/lib.rs`** (add the private module line, keep it out of the public re-exports) + +```rust +mod patterns; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test --lib && cargo clippy --all-targets -- -D warnings` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/patterns.rs src/lib.rs +git commit -m "feat(patterns): port validator regexes to Rust" +``` + +--- + +## Task 5: Structure parser (`parser.rs`) + +The line-by-line state machine that splits a message into `{ header, body, jira, footer }`, reproducing `validate_overall_structure`. Iterating `message.split('\n')` exactly matches bash `read` over the here-string. + +**Files:** +- Create: `src/parser.rs` +- Modify: `src/lib.rs` (add `mod parser;`) + +**Interfaces:** +- Consumes: `crate::error::ValidationError`, `crate::patterns`. +- Produces: + - `pub struct ParsedMessage { pub header: String, pub body: String, pub jira: String, pub footer: String }` deriving `Debug, Default, PartialEq, Eq`. + - `pub fn parse(message: &str, jira_in_header: bool) -> Result`. + +- [ ] **Step 1: Write the failing tests** (in `src/parser.rs`) — these port every `structure:` case from `validator.bats`. + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn ok(msg: &str, jira_in_header: bool) -> ParsedMessage { + parse(msg, jira_in_header).expect("expected valid structure") + } + + fn err_code(msg: &str) -> i32 { + parse(msg, false).expect_err("expected structure error").code() + } + + #[test] + fn structure_errors() { + // one trailing line + assert_eq!(err_code("plop plop\n"), 1); + // missing empty line after header (body) + assert_eq!(err_code("plop plop\nplop\n\nplop\n"), 1); + // missing empty line after header (jira) + assert_eq!(err_code("plop plop\nABC-1234\n"), 1); + // missing empty line after header (broken) + assert_eq!(err_code("plop plop\nBROKEN:\n"), 1); + // missing empty line after body with jira ref + assert_eq!(err_code("plop plop\n\nplop\nplop\nplop\nplop\nLUM-1234\n"), 1); + // missing empty line after body with broken + assert_eq!(err_code("plop plop\n\nplop\nplop\nplop\nplop\nBROKEN:\n"), 1); + // empty line after the body (double empty) + assert_eq!(err_code("plop plop\n\nplop\nplop\nplop\nplop\n\n"), 1); + } + + #[test] + fn header_only() { + let p = ok("plop plop", false); + assert_eq!(p, ParsedMessage { header: "plop plop".into(), ..Default::default() }); + } + + #[test] + fn header_and_jira() { + let p = ok("plop plop\n\nABC-1234", false); + assert_eq!(p.header, "plop plop"); + assert_eq!(p.jira, "ABC-1234"); + assert_eq!(p.body, ""); + assert_eq!(p.footer, ""); + } + + #[test] + fn jira_in_header_extracted() { + let p = ok("feat(abc): ABC-1234\n\nplop", true); + assert_eq!(p.header, "feat(abc): ABC-1234"); + assert_eq!(p.jira, "ABC-1234"); + assert_eq!(p.body, "plop\n"); + } + + #[test] + fn header_and_multiple_jira() { + let p = ok("plop plop\n\nABC-1234 DE-1234", false); + assert_eq!(p.jira, "ABC-1234 DE-1234"); + } + + #[test] + fn header_and_broken() { + let p = ok("plop plop\n\nBROKEN:\n- plop\n- plop", false); + assert_eq!(p.footer, "- plop\n- plop\n"); + assert_eq!(p.body, ""); + } + + #[test] + fn header_jira_and_broken() { + let p = ok("plop plop\n\nABC-1234\nBROKEN:\n- plop\n- plop", false); + assert_eq!(p.jira, "ABC-1234"); + assert_eq!(p.footer, "- plop\n- plop\n"); + } + + #[test] + fn header_and_body() { + let p = ok("plop plop\n\nhello", false); + assert_eq!(p.body, "hello\n"); + } + + #[test] + fn multiline_body() { + let p = ok("plop plop\n\nhello\n\nplopplop\nplopplop\n\ntoto", false); + assert_eq!(p.body, "hello\nplopplop\nplopplop\ntoto\n"); + } + + #[test] + fn multiline_body_and_jira() { + let p = ok("plop plop\n\nhello\n\nplopplop\nplopplop\n\ntoto\n\nABC-1234", false); + assert_eq!(p.body, "hello\nplopplop\nplopplop\ntoto\n"); + assert_eq!(p.jira, "ABC-1234"); + } + + #[test] + fn multiline_body_and_broken() { + let p = ok("plop plop\n\nhello\n\nplopplop\nplopplop\n\ntoto\n\nBROKEN:\n- plop\n- plop", false); + assert_eq!(p.body, "hello\nplopplop\nplopplop\ntoto\n"); + assert_eq!(p.footer, "- plop\n- plop\n"); + } + + #[test] + fn multiline_body_jira_and_broken() { + let p = ok( + "plop plop\n\nhello\n\nplopplop\nplopplop\n\ntoto\n\nABC-1234\nBROKEN:\n- plop\n- plop", + false, + ); + assert_eq!(p.body, "hello\nplopplop\nplopplop\ntoto\n"); + assert_eq!(p.jira, "ABC-1234"); + assert_eq!(p.footer, "- plop\n- plop\n"); + } + + #[test] + fn only_broken_allowed_after_jira() { + // JIRA ref then a non-broken line -> structure error + assert_eq!(err_code("plop plop\n\nABC-1234\nnope"), 1); + } + + #[test] + fn no_empty_line_in_broken_part() { + assert_eq!(err_code("plop plop\n\nBROKEN:\n- plop\n\n- plop"), 1); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib parser` +Expected: FAIL (compile error — `parse`/`ParsedMessage` not defined). + +- [ ] **Step 3: Write the implementation** (top of `src/parser.rs`) + +```rust +use crate::error::ValidationError; +use crate::patterns; + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct ParsedMessage { + pub header: String, + pub body: String, + pub jira: String, + pub footer: String, +} + +#[derive(PartialEq)] +enum State { + WaitingHeader, + WaitingEmpty, + StartText, + ReadingBody, + ReadingBroken, + ReadingFooter, +} + +fn structure(msg: &str) -> ValidationError { + ValidationError::Structure(msg.to_string()) +} + +pub fn parse(message: &str, jira_in_header: bool) -> Result { + let mut parsed = ParsedMessage::default(); + let mut state = State::WaitingHeader; + + for line in message.split('\n') { + match state { + State::WaitingHeader => { + parsed.header = line.to_string(); + state = State::WaitingEmpty; + if jira_in_header { + if let Some(caps) = patterns::JIRA_HEADER.captures(line) { + parsed.jira = caps[1].to_string(); + } + } + } + State::WaitingEmpty => { + if !line.is_empty() { + return Err(structure( + "missing empty line in commit message between header and body or body and footer", + )); + } + state = State::StartText; + } + State::StartText => { + if line.is_empty() { + return Err(structure("double empty line is not allowed")); + } + if patterns::BROKE.is_match(line) { + state = State::ReadingFooter; + } else if patterns::JIRA_FOOTER.is_match(line) { + state = State::ReadingBroken; + parsed.jira = line.to_string(); + } else { + state = State::ReadingBody; + parsed.body.push_str(line); + parsed.body.push('\n'); + } + } + State::ReadingBody => { + if patterns::BROKE.is_match(line) { + return Err(structure("missing empty line before broke part")); + } + if patterns::JIRA_FOOTER.is_match(line) { + return Err(structure("missing empty line before JIRA reference")); + } + if line.is_empty() { + state = State::StartText; + } else { + parsed.body.push_str(line); + parsed.body.push('\n'); + } + } + State::ReadingBroken => { + if patterns::BROKE.is_match(line) { + state = State::ReadingFooter; + } else { + return Err(structure( + "only broken part could be after the JIRA reference", + )); + } + } + State::ReadingFooter => { + if line.is_empty() { + return Err(structure("no empty line allowed in broken part")); + } + parsed.footer.push_str(line); + parsed.footer.push('\n'); + } + } + } + + if state == State::StartText { + return Err(structure("new line at the end of the commit is not allowed")); + } + + Ok(parsed) +} +``` + +- [ ] **Step 4: Wire into `src/lib.rs`** (add `mod parser;`). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test --lib && cargo clippy --all-targets -- -D warnings` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/parser.rs src/lib.rs +git commit -m "feat(parser): port commit structure state machine" +``` + +--- + +## Task 6: Validation rules (`validate.rs`) + +Per-rule functions and header classification, ported from the `validate_*` and `need_jira` bash functions. + +**Files:** +- Create: `src/validate.rs` +- Modify: `src/lib.rs` (add `mod validate;`) + +**Interfaces:** +- Consumes: `crate::config::Config`, `crate::error::ValidationError`, `crate::patterns`. +- Produces: + - `pub enum HeaderKind { Temp, Revert, Conventional { type_: String, scope: String, subject: String } }`. + - `pub fn classify_header(header: &str, config: &Config) -> Result`. + - `pub fn header_length(header: &str, max: usize) -> Result<(), ValidationError>`. + - `pub fn commit_type(type_: &str) -> Result<(), ValidationError>`. + - `pub fn scope(scope: &str) -> Result<(), ValidationError>`. + - `pub fn subject(subject: &str) -> Result<(), ValidationError>`. + - `pub fn body_length(text: &str, max: usize) -> Result<(), ValidationError>`. + - `pub fn trailing_space(text: &str) -> Result<(), ValidationError>`. + - `pub fn need_jira(type_: &str, config: &Config) -> bool`. + - `pub fn jira(type_: &str, jira: &str, config: &Config) -> Result<(), ValidationError>`. + - `pub fn revert(body: &str, config: &Config) -> Result<(), ValidationError>`. + +- [ ] **Step 1: Write the failing tests** (in `src/validate.rs`) — porting every rule case from `validator.bats`. + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn cfg() -> Config { + Config::default() + } + + #[test] + fn classify_rejects_malformed_headers() { + for bad in [ + "type", + "type(scope)", + "type(scope) message", + "type(scope) : message", + "type(scope: message", + "type scope: message", + "type(scope):message", + ] { + assert_eq!(classify_header(bad, &cfg()).unwrap_err().code(), 2, "{bad}"); + } + } + + #[test] + fn classify_temp_only_when_allowed() { + let mut c = cfg(); + assert_eq!(classify_header("fixup! x", &c).unwrap_err().code(), 2); + assert_eq!(classify_header("squash! x", &c).unwrap_err().code(), 2); + c.allow_temp = true; + assert!(matches!(classify_header("fixup! x", &c).unwrap(), HeaderKind::Temp)); + assert!(matches!(classify_header("squash! x", &c).unwrap(), HeaderKind::Temp)); + } + + #[test] + fn classify_revert() { + assert!(matches!( + classify_header("revert: type(scope): message", &cfg()).unwrap(), + HeaderKind::Revert + )); + assert!(matches!( + classify_header("Revert \"type(scope): message\"", &cfg()).unwrap(), + HeaderKind::Revert + )); + } + + #[test] + fn classify_conventional_extracts_parts() { + let k = classify_header("type(scope): message", &cfg()).unwrap(); + match k { + HeaderKind::Conventional { type_, scope, subject } => { + assert_eq!(type_, "type"); + assert_eq!(scope, "scope"); + assert_eq!(subject, "message"); + } + _ => panic!("expected conventional"), + } + } + + #[test] + fn header_length_boundary() { + let at = "0".repeat(100); + let over = "0".repeat(101); + assert!(header_length(&at, 100).is_ok()); + assert_eq!(header_length(&over, 100).unwrap_err().code(), 3); + assert!(header_length(&over, 150).is_ok()); + } + + #[test] + fn commit_type_rules() { + assert!(commit_type("feat").is_ok()); + assert_eq!(commit_type("plop").unwrap_err().code(), 4); + assert_eq!(commit_type("feat ").unwrap_err().code(), 4); + assert_eq!(commit_type("Feat").unwrap_err().code(), 4); + } + + #[test] + fn scope_rules() { + assert!(scope("p2").is_ok()); + assert!(scope("pl2op-plop1-plop-0001").is_ok()); + for bad in ["", "plopPlop", "plop plop", "plop "] { + assert_eq!(scope(bad).unwrap_err().code(), 5, "{bad}"); + } + } + + #[test] + fn subject_rules() { + assert!(subject("0002 dedezf ef zefzef").is_ok()); + for bad in ["", "plop ", "plop."] { + assert_eq!(subject(bad).unwrap_err().code(), 6, "{bad}"); + } + } + + #[test] + fn body_length_rules() { + let over = "12345678 ".to_string() + &"0".repeat(93); // 102 chars, has a space + assert_eq!(body_length(&over, 100).unwrap_err().code(), 7); + // long line with no space is skipped + let long_no_space = "0".repeat(260); + assert!(body_length(&long_no_space, 100).is_ok()); + // 100-char line with space is fine + let at = "12345678 ".to_string() + &"0".repeat(91); // 100 chars + assert!(body_length(&at, 100).is_ok()); + assert!(body_length(&over, 150).is_ok()); + } + + #[test] + fn trailing_space_rules() { + assert_eq!(trailing_space("pdzofjzf ").unwrap_err().code(), 8); + assert_eq!(trailing_space("\nrerer\n\n \nLUM-2345").unwrap_err().code(), 8); + assert!(trailing_space("\nrerer\n\n\nLUM-2345").is_ok()); + } + + #[test] + fn need_jira_rules() { + let mut c = cfg(); + assert!(need_jira("feat", &c)); + assert!(need_jira("fix", &c)); + assert!(!need_jira("docs", &c)); + assert!(!need_jira("test", &c)); + c.no_jira = true; + assert!(!need_jira("feat", &c)); + } + + #[test] + fn jira_rules() { + assert_eq!(jira("feat", "", &cfg()).unwrap_err().code(), 9); + assert!(jira("lint", "", &cfg()).is_ok()); + assert!(jira("feat", "ABC-123", &cfg()).is_ok()); + assert!(jira("feat", "AB-123", &cfg()).is_ok()); + } + + #[test] + fn revert_rules() { + let no_sha = "rerer\n\nLUM-2345"; + let with_sha = "rerer\n\nThis reverts commit 1234567890.\n\nLUM-2345"; + assert_eq!(revert(no_sha, &cfg()).unwrap_err().code(), 10); + assert!(revert(with_sha, &cfg()).is_ok()); + let mut c = cfg(); + c.no_revert_sha1 = true; + assert!(revert(no_sha, &c).is_ok()); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib validate` +Expected: FAIL (compile error — functions not defined). + +- [ ] **Step 3: Write the implementation** (top of `src/validate.rs`) + +```rust +use crate::config::Config; +use crate::error::ValidationError; +use crate::patterns; + +pub enum HeaderKind { + Temp, + Revert, + Conventional { + type_: String, + scope: String, + subject: String, + }, +} + +pub fn classify_header(header: &str, config: &Config) -> Result { + if config.allow_temp && patterns::TEMP_HEADER.is_match(header) { + Ok(HeaderKind::Temp) + } else if patterns::REVERT_HEADER.is_match(header) { + Ok(HeaderKind::Revert) + } else if let Some(caps) = patterns::HEADER.captures(header) { + Ok(HeaderKind::Conventional { + type_: caps[1].to_string(), + scope: caps[2].to_string(), + subject: caps[3].to_string(), + }) + } else { + Err(ValidationError::Header( + "commit header doesn't match overall header pattern: 'type(scope): message'" + .to_string(), + )) + } +} + +pub fn header_length(header: &str, max: usize) -> Result<(), ValidationError> { + if header.chars().count() > max { + return Err(ValidationError::HeaderLength(format!( + "commit header length is more than {max} characters" + ))); + } + Ok(()) +} + +pub fn commit_type(type_: &str) -> Result<(), ValidationError> { + if patterns::TYPE.is_match(type_) { + Ok(()) + } else { + Err(ValidationError::Type(format!("commit type '{type_}' is unknown"))) + } +} + +pub fn scope(scope: &str) -> Result<(), ValidationError> { + if patterns::SCOPE.is_match(scope) { + Ok(()) + } else { + Err(ValidationError::Scope(format!( + "commit scope '{scope}' is not kebab-case" + ))) + } +} + +pub fn subject(subject: &str) -> Result<(), ValidationError> { + if patterns::SUBJECT.is_match(subject) { + Ok(()) + } else { + Err(ValidationError::Subject(format!( + "commit subject '{subject}' should not end with a '.'" + ))) + } +} + +pub fn body_length(text: &str, max: usize) -> Result<(), ValidationError> { + for line in text.split('\n') { + // Skip lines with no whitespace as they can't be wrapped. + if !line.contains(|c: char| c.is_ascii_whitespace()) { + continue; + } + if line.chars().count() > max { + return Err(ValidationError::BodyLength(format!( + "body message line length is more than {max} characters" + ))); + } + } + Ok(()) +} + +pub fn trailing_space(text: &str) -> Result<(), ValidationError> { + for line in text.split('\n') { + if patterns::TRAILING_SPACE.is_match(line) { + return Err(ValidationError::TrailingSpace( + "body message must not have trailing spaces".to_string(), + )); + } + } + Ok(()) +} + +pub fn need_jira(type_: &str, config: &Config) -> bool { + if config.no_jira { + return false; + } + config.jira_types.iter().any(|t| t == type_) +} + +pub fn jira(type_: &str, jira: &str, config: &Config) -> Result<(), ValidationError> { + if need_jira(type_, config) && jira.is_empty() { + return Err(ValidationError::Jira(format!( + "commits with type '{type_}' need to include a reference to a JIRA ticket, by adding the project prefix and the issue number to the commit message, this could be done easily with: git commit -m 'feat(widget): add a wonderful widget' -m LUM-1234" + ))); + } + Ok(()) +} + +pub fn revert(body: &str, config: &Config) -> Result<(), ValidationError> { + if config.no_revert_sha1 { + return Ok(()); + } + let has_sha = body + .split('\n') + .any(|line| patterns::REVERT_COMMIT.is_match(line)); + if has_sha { + Ok(()) + } else { + Err(ValidationError::Revert( + "revert commit should contain the reverted sha1".to_string(), + )) + } +} +``` + +- [ ] **Step 4: Wire into `src/lib.rs`** (add `mod validate;`). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test --lib && cargo clippy --all-targets -- -D warnings` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/validate.rs src/lib.rs +git commit -m "feat(validate): port per-rule validation functions" +``` + +--- + +## Task 7: Orchestration (`validate_message` in `lib.rs`) + +Ties parser + rules together in the exact order of the bash `validate` function. + +**Files:** +- Modify: `src/lib.rs` + +**Interfaces:** +- Consumes: `parser::parse`, `validate::*`, `config::Config`. +- Produces: + - `pub enum Outcome { Valid, Temp }`. + - `pub fn validate_message(message: &str, config: &Config) -> Result`. + +- [ ] **Step 1: Write the failing tests** (append to `src/lib.rs`) — porting the `overall validation` cases from `validator.bats`. + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn code(msg: &str, config: &Config) -> i32 { + validate_message(msg, config).map(|_| 0).unwrap_or_else(|e| e.code()) + } + + #[test] + fn invalid_structure() { + assert_eq!(code("plop\nplop", &Config::default()), 1); + } + + #[test] + fn invalid_header() { + assert_eq!(code("plop", &Config::default()), 2); + } + + #[test] + fn invalid_header_length() { + let msg = "feat(plop): 012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"; + assert_eq!(code(msg, &Config::default()), 3); + } + + #[test] + fn invalid_type() { + let msg = "Feat(scope1): subject\n\nCommit about stuff\n\nLUM-2345"; + assert_eq!(code(msg, &Config::default()), 4); + } + + #[test] + fn invalid_scope() { + let msg = "feat(scope 1): subject\n\nCommit about stuff\n\nLUM-2345"; + assert_eq!(code(msg, &Config::default()), 5); + } + + #[test] + fn valid_capitalized_subject_is_not_subject_error() { + let msg = "feat(scope1): Subject\n\nCommit about stuff\n\nLUM-2345"; + assert_ne!(code(msg, &Config::default()), 6); + } + + #[test] + fn invalid_body_length() { + let msg = "feat(scope1): subject\n\n1 2 3 4 5678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901\n\nLUM-2345"; + assert_eq!(code(msg, &Config::default()), 7); + } + + #[test] + fn invalid_body_trailing_space() { + let msg = "chore(scope1): subject\n\n123456789012345678901234567890123456789012 "; + assert_eq!(code(msg, &Config::default()), 8); + } + + #[test] + fn invalid_footer_length() { + let msg = "feat(scope1): subject\n\nplop\n\nLUM-2345\nBROKEN:\n- 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901"; + assert_eq!(code(msg, &Config::default()), 7); + } + + #[test] + fn invalid_footer_trailing_space() { + let msg = "feat(scope1): subject\n\nplop\n\nLUM-2345\nBROKEN:\n- 123456 "; + assert_eq!(code(msg, &Config::default()), 8); + } + + #[test] + fn missing_jira() { + let msg = "feat(scope1): subject\n\nCommit about stuff\n\n2345"; + assert_eq!(code(msg, &Config::default()), 9); + } + + #[test] + fn fully_valid() { + let msg = "feat(scope1): subject\n\nCommit about stuff dezd\n\n12345678901234567890123456789012345678901234567890\n12345678901234567890123456789012345678901234567890\n\nLUM-2345\nBROKEN:\n- plop\n- plop"; + assert_eq!(code(msg, &Config::default()), 0); + } + + #[test] + fn revert_valid_with_sha() { + let msg = "Revert \"feat(scope1): subject\"\n\nThis reverts commit 12345678900.\nCommit about stuff dezd\n\n12345678901234567890123456789012345678901234567890\n\nLUM-2345\nBROKEN:\n- plop\n- plop"; + assert_eq!(code(msg, &Config::default()), 0); + } + + #[test] + fn fixup_valid_when_allowed_rejected_otherwise() { + let msg = "fixup! plepozkfopezr\n\nCommit about stuff dezd\n\nLUM-2345\nBROKEN:\n- plop\n- plop"; + let mut c = Config::default(); + assert_eq!(code(msg, &c), 2); + c.allow_temp = true; + assert_eq!(code(msg, &c), 0); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib` +Expected: FAIL (compile error — `validate_message`/`Outcome` not defined). + +- [ ] **Step 3: Write the implementation** — the final `src/lib.rs` reads: + +```rust +pub mod config; +pub mod error; +mod parser; +mod patterns; +mod validate; + +pub use config::{Config, Overrides}; +pub use error::ValidationError; + +use validate::HeaderKind; + +#[derive(Debug, PartialEq, Eq)] +pub enum Outcome { + Valid, + Temp, +} + +pub fn validate_message(message: &str, config: &Config) -> Result { + let parsed = parser::parse(message, config.jira_in_header)?; + + match validate::classify_header(&parsed.header, config)? { + HeaderKind::Temp => Ok(Outcome::Temp), + HeaderKind::Revert => { + validate::revert(&parsed.body, config)?; + Ok(Outcome::Valid) + } + HeaderKind::Conventional { type_, scope, subject } => { + validate::header_length(&parsed.header, config.header_max_length)?; + validate::commit_type(&type_)?; + validate::scope(&scope)?; + validate::subject(&subject)?; + validate::body_length(&parsed.body, config.body_max_length)?; + validate::body_length(&parsed.footer, config.body_max_length)?; + validate::trailing_space(&parsed.body)?; + validate::trailing_space(&parsed.footer)?; + validate::jira(&type_, &parsed.jira, config)?; + Ok(Outcome::Valid) + } + } +} +``` + +(Keep the `#[cfg(test)] mod tests { ... }` block from Step 1 at the bottom of the file.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --lib && cargo clippy --all-targets -- -D warnings` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib.rs +git commit -m "feat(lib): orchestrate full commit validation" +``` + +--- + +## Task 8: Message preprocessing (`preprocess.rs`) + +Reproduces `check_message.sh`'s pre-validation handling: skip `*MERGE_MSG` paths, strip `#` comment lines, skip messages whose first word is `merge` (any case). + +**Files:** +- Create: `src/preprocess.rs` +- Modify: `src/lib.rs` (add `pub mod preprocess;`) + +**Interfaces:** +- Produces: + - `pub enum Preprocessed { Skip, Message(String) }`. + - `pub fn preprocess_message_file(path: &str, contents: &str) -> Preprocessed`. + +- [ ] **Step 1: Write the failing tests** (in `src/preprocess.rs`) — porting `check_message.bats` preprocessing cases. + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn is_skip(path: &str, contents: &str) -> bool { + matches!(preprocess_message_file(path, contents), Preprocessed::Skip) + } + + #[test] + fn skips_merge_msg_path() { + assert!(is_skip("/some/path/MERGE_MSG", "Merge branch 'foo' into 'bar'")); + } + + #[test] + fn skips_merge_first_word_any_case() { + for msg in [ + "Merge branch 'foo' into 'bar'", + "Merge pull request #1", + "MERGE branch 'feature' into 'main'", + "MeRgE branch 'test'", + "merge whatever", + ] { + assert!(is_skip("/x/COMMIT_EDITMSG", msg), "{msg}"); + } + } + + #[test] + fn strips_comment_lines() { + match preprocess_message_file("/x/COMMIT_EDITMSG", "# a comment\nfeat(scope): valid subject\n") { + Preprocessed::Message(m) => assert_eq!(m, "feat(scope): valid subject"), + Preprocessed::Skip => panic!("should not skip"), + } + } + + #[test] + fn returns_message_for_normal_commit() { + match preprocess_message_file("/x/COMMIT_EDITMSG", "feat(widget): add a widget") { + Preprocessed::Message(m) => assert_eq!(m, "feat(widget): add a widget"), + Preprocessed::Skip => panic!("should not skip"), + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib preprocess` +Expected: FAIL (compile error — not defined). + +- [ ] **Step 3: Write the implementation** (top of `src/preprocess.rs`) + +```rust +pub enum Preprocessed { + Skip, + Message(String), +} + +pub fn preprocess_message_file(path: &str, contents: &str) -> Preprocessed { + if path.ends_with("MERGE_MSG") { + return Preprocessed::Skip; + } + + // Remove comment lines (leading '#'), mirroring `sed '/^#/d'`. + let stripped = contents + .lines() + .filter(|line| !line.starts_with('#')) + .collect::>() + .join("\n"); + + // First word (up to the first space), lowercased, like `${MSG%% *}`. + let first_word = stripped + .split(' ') + .next() + .unwrap_or("") + .to_lowercase(); + if first_word == "merge" { + return Preprocessed::Skip; + } + + Preprocessed::Message(stripped) +} +``` + +- [ ] **Step 4: Wire into `src/lib.rs`** — add `pub mod preprocess;` near the other module declarations. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test --lib && cargo clippy --all-targets -- -D warnings` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/preprocess.rs src/lib.rs +git commit -m "feat(preprocess): port merge/comment message handling" +``` + +--- + +## Task 9: CLI — `message` subcommand (`main.rs`) + +The `clap` CLI, config assembly from flags+env, and the `message` subcommand. `range` is added in Task 10. + +**Files:** +- Modify: `src/main.rs` +- Create: `tests/cli.rs` + +**Interfaces:** +- Consumes: `commit_message_validator::{validate_message, Config, Overrides, Outcome, ValidationError}` and `commit_message_validator::preprocess::{preprocess_message_file, Preprocessed}`. +- Produces: binary `commit-message-validator` with subcommand `message `; process exit code = `ValidationError::code()` on failure, 0 on success/temp/skip. + +- [ ] **Step 1: Write the failing integration tests** (in `tests/cli.rs`) + +```rust +use assert_cmd::Command; +use std::io::Write; +use tempfile::NamedTempFile; + +fn write_msg(contents: &str) -> NamedTempFile { + let mut f = NamedTempFile::new().unwrap(); + f.write_all(contents.as_bytes()).unwrap(); + f +} + +#[test] +fn message_accepts_valid_commit() { + let f = write_msg("feat(widget): add a wonderful widget\n"); + Command::cargo_bin("commit-message-validator") + .unwrap() + .args(["message", f.path().to_str().unwrap()]) + .arg("--no-jira") + .assert() + .success(); +} + +#[test] +fn message_rejects_invalid_commit() { + let f = write_msg("this is not valid\n"); + Command::cargo_bin("commit-message-validator") + .unwrap() + .args(["--no-jira", "message", f.path().to_str().unwrap()]) + .assert() + .failure() + .code(2); +} + +#[test] +fn message_skips_merge_commit() { + let f = write_msg("Merge branch 'foo' into 'bar'\n"); + Command::cargo_bin("commit-message-validator") + .unwrap() + .args(["message", f.path().to_str().unwrap()]) + .assert() + .success(); +} + +#[test] +fn message_strips_comments() { + let f = write_msg("# comment\nfeat(scope): valid subject\n"); + Command::cargo_bin("commit-message-validator") + .unwrap() + .args(["--no-jira", "message", f.path().to_str().unwrap()]) + .assert() + .success(); +} + +#[test] +fn jira_types_flag_requires_jira_for_feat_not_fix() { + let feat = write_msg("feat(widget): add a wonderful widget\n"); + Command::cargo_bin("commit-message-validator") + .unwrap() + .args(["--jira-types", "feat", "message", feat.path().to_str().unwrap()]) + .assert() + .failure() + .code(9); + + let fix = write_msg("fix(widget): correct a bug\n"); + Command::cargo_bin("commit-message-validator") + .unwrap() + .args(["--jira-types", "feat", "message", fix.path().to_str().unwrap()]) + .assert() + .success(); +} + +#[test] +fn no_jira_via_env_var() { + let f = write_msg("feat(widget): add a wonderful widget\n"); + Command::cargo_bin("commit-message-validator") + .unwrap() + .env("COMMIT_VALIDATOR_NO_JIRA", "1") + .args(["message", f.path().to_str().unwrap()]) + .assert() + .success(); +} + +#[test] +fn message_missing_file_errors() { + Command::cargo_bin("commit-message-validator") + .unwrap() + .args(["message", "/no/such/file/xyz"]) + .assert() + .failure(); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --test cli` +Expected: FAIL (no `message` subcommand yet / arg parse errors). + +- [ ] **Step 3: Write the implementation** — full `src/main.rs` + +```rust +use std::process::ExitCode; + +use clap::{Args, Parser, Subcommand}; +use commit_message_validator::preprocess::{preprocess_message_file, Preprocessed}; +use commit_message_validator::{validate_message, Config, Outcome, Overrides, ValidationError}; + +#[derive(Parser)] +#[command(name = "commit-message-validator", version, about = "Enforce angular commit message convention")] +struct Cli { + #[command(flatten)] + opts: CliOptions, + #[command(subcommand)] + command: Command, +} + +#[derive(Args)] +struct CliOptions { + /// Do not require JIRA references. + #[arg(long, global = true)] + no_jira: bool, + /// Allow `fixup!` / `squash!` commits. + #[arg(long, global = true)] + allow_temp: bool, + /// Do not require the reverted sha1 in revert commits. + #[arg(long, global = true)] + no_revert_sha1: bool, + /// Allow the JIRA reference to appear in the header. + #[arg(long, global = true)] + jira_in_header: bool, + /// Maximum header length (default 100). + #[arg(long, global = true)] + header_length: Option, + /// Maximum body line length (default 100). + #[arg(long, global = true)] + body_length: Option, + /// Space-separated commit types that require a JIRA reference (default "feat fix"). + #[arg(long, global = true)] + jira_types: Option, +} + +#[derive(Subcommand)] +enum Command { + /// Validate a single commit message file (commit-msg hook). + Message { file: String }, + /// Validate every commit in a git revision range. + Range { range: String }, +} + +impl CliOptions { + fn to_overrides(&self) -> Overrides { + Overrides { + no_jira: self.no_jira, + allow_temp: self.allow_temp, + no_revert_sha1: self.no_revert_sha1, + jira_in_header: self.jira_in_header, + header_length: self.header_length, + body_length: self.body_length, + jira_types: self.jira_types.clone(), + } + } +} + +fn report(result: Result) -> u8 { + match result { + Ok(Outcome::Valid) => 0, + Ok(Outcome::Temp) => { + println!("ignoring temporary commit"); + 0 + } + Err(e) => { + eprintln!("{e}"); + e.code() as u8 + } + } +} + +fn run_message(file: &str, config: &Config) -> u8 { + let contents = match std::fs::read_to_string(file) { + Ok(c) => c, + Err(e) => { + eprintln!("cannot read commit message file '{file}': {e}"); + return 1; + } + }; + match preprocess_message_file(file, &contents) { + Preprocessed::Skip => 0, + Preprocessed::Message(msg) => report(validate_message(&msg, config)), + } +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let config = Config::resolve(&cli.opts.to_overrides(), |name| std::env::var(name).ok()); + + let code = match &cli.command { + Command::Message { file } => run_message(file, &config), + Command::Range { range } => { + eprintln!("range not yet implemented for '{range}'"); + 1 + } + }; + ExitCode::from(code) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --test cli && cargo clippy --all-targets -- -D warnings` +Expected: PASS (the `range` placeholder test is not exercised yet). + +- [ ] **Step 5: Commit** + +```bash +git add src/main.rs tests/cli.rs +git commit -m "feat(cli): add message subcommand with flag/env config" +``` + +--- + +## Task 10: CLI — `range` subcommand + git adapter + +Adds range validation by shelling out to git, matching `check.sh` (`--no-merges`, per-commit `%B`, stop on first failure). + +**Files:** +- Modify: `src/main.rs` +- Modify: `tests/cli.rs` + +**Interfaces:** +- Consumes: `std::process::Command` (git subprocess). +- Produces: subcommand `range `; exits with the first failing commit's code, else 0. + +- [ ] **Step 1: Write the failing integration tests** (append to `tests/cli.rs`) + +```rust +use std::process::Command as StdCommand; + +fn git(repo: &std::path::Path, args: &[&str]) { + let status = StdCommand::new("git") + .current_dir(repo) + .args(args) + .status() + .unwrap(); + assert!(status.success(), "git {args:?} failed"); +} + +fn init_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + git(dir.path(), &["init", "-q"]); + git(dir.path(), &["config", "user.email", "test@test.com"]); + git(dir.path(), &["config", "user.name", "Test"]); + git(dir.path(), &["commit", "--allow-empty", "-q", "-m", "chore(init): initial commit"]); + dir +} + +#[test] +fn range_accepts_valid_commits() { + let dir = init_repo(); + git(dir.path(), &["commit", "--allow-empty", "-q", "-m", "feat(widget): add widget"]); + Command::cargo_bin("commit-message-validator") + .unwrap() + .current_dir(dir.path()) + .env("COMMIT_VALIDATOR_NO_JIRA", "1") + .args(["range", "HEAD~1..HEAD"]) + .assert() + .success(); +} + +#[test] +fn range_rejects_invalid_commit() { + let dir = init_repo(); + git(dir.path(), &["commit", "--allow-empty", "-q", "-m", "bad commit message"]); + Command::cargo_bin("commit-message-validator") + .unwrap() + .current_dir(dir.path()) + .env("COMMIT_VALIDATOR_NO_JIRA", "1") + .args(["range", "HEAD~1..HEAD"]) + .assert() + .failure(); +} + +#[test] +fn range_empty_succeeds() { + let dir = init_repo(); + Command::cargo_bin("commit-message-validator") + .unwrap() + .current_dir(dir.path()) + .env("COMMIT_VALIDATOR_NO_JIRA", "1") + .args(["range", "HEAD..HEAD"]) + .assert() + .success(); +} + +#[test] +fn range_bad_revision_errors() { + let dir = init_repo(); + Command::cargo_bin("commit-message-validator") + .unwrap() + .current_dir(dir.path()) + .args(["range", "not-a-real-ref..HEAD"]) + .assert() + .failure(); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --test cli range` +Expected: FAIL (range prints "not yet implemented", exits 1 — `range_accepts_valid_commits` and `range_empty_succeeds` fail). + +- [ ] **Step 3: Write the implementation** — add git helpers and replace the `Range` arm in `src/main.rs`. + +Add these functions above `main`: + +```rust +fn git_output(args: &[&str]) -> Result { + let output = std::process::Command::new("git") + .args(args) + .output() + .map_err(|e| format!("failed to run git: {e}"))?; + if !output.status.success() { + return Err(format!( + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn run_range(range: &str, config: &Config) -> u8 { + let hashes = match git_output(&["log", "--no-merges", "--pretty=%H", "--no-decorate", range]) { + Ok(out) => out, + Err(e) => { + eprintln!("{e}"); + return 1; + } + }; + for hash in hashes.lines() { + let message = match git_output(&["log", "-1", "--pretty=%B", hash]) { + Ok(m) => m, + Err(e) => { + eprintln!("{e}"); + return 1; + } + }; + println!("checking commit {hash}..."); + // %B carries a trailing newline; trim it like bash command substitution. + let code = report(validate_message(message.trim_end_matches('\n'), config)); + if code != 0 { + return code; + } + } + println!("All commits successfully checked"); + 0 +} +``` + +Replace the `Range` arm in `main`: + +```rust + Command::Range { range } => run_range(range, &config), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --test cli && cargo clippy --all-targets -- -D warnings` +Expected: PASS. + +- [ ] **Step 5: Verify coverage locally** + +Run: `cargo llvm-cov --all-targets --fail-under-lines 90` +Expected: PASS (≥90%; investigate any uncovered lines and add tests toward 100%). + +- [ ] **Step 6: Commit** + +```bash +git add src/main.rs tests/cli.rs +git commit -m "feat(cli): add range subcommand via git subprocess" +``` + +--- + +## Task 11: Release workflow (prebuilt binaries) + +On tag push, build static binaries for the four target triples and attach them to the GitHub Release. + +**Files:** +- Create: `.github/workflows/release.yml` + +**Interfaces:** +- Produces: release assets named `commit-message-validator-` for `x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl`, `x86_64-apple-darwin`, `aarch64-apple-darwin`. + +- [ ] **Step 1: Write `.github/workflows/release.yml`** + +```yaml +--- +name: Release +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + build: + strategy: + matrix: + include: + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-unknown-linux-musl + os: ubuntu-latest + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - name: Install cross-compilation deps (linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y musl-tools + cargo install cross --locked + - name: Build (linux via cross) + if: runner.os == 'Linux' + run: cross build --release --target ${{ matrix.target }} + - name: Build (macos) + if: runner.os == 'macOS' + run: cargo build --release --target ${{ matrix.target }} + - name: Rename artifact + run: | + cp target/${{ matrix.target }}/release/commit-message-validator \ + commit-message-validator-${{ matrix.target }} + - name: Attach to release + uses: softprops/action-gh-release@v2 + with: + files: commit-message-validator-${{ matrix.target }} +``` + +- [ ] **Step 2: Validate the workflow YAML locally** + +Run: `python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/release.yml'))" && echo OK` +Expected: `OK`. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "ci(release): build cross-platform binaries on tag" +``` + +--- + +## Task 12: GitHub composite Action (`action.yml`) + +Rewrite the composite Action to download the prebuilt binary for the runner and run `range`, preserving the existing `inputs → env` wiring. + +**Files:** +- Modify: `action.yml` + +**Interfaces:** +- Consumes: release assets from Task 11; env var names from Global Constraints. +- Produces: composite action that validates `base..head`. + +- [ ] **Step 1: Rewrite `action.yml`** + +```yaml +--- +name: 'Commit message validator' +description: > + Enforce angular commit message convention using a prebuilt Rust binary. +author: 'Sébastien Boulle' +branding: + icon: 'check-square' + color: 'green' +inputs: + no_jira: + description: 'If not empty, no validation is done on JIRA refs.' + required: false + allow_temp: + description: 'If not empty, no validation is done on `fixup!` and `squash!` commits.' + required: false + no_revert_sha1: + description: 'If not empty, reverted sha1 commit is not mandatory in revert commit message.' + required: false + header_length: + description: 'If not empty, max header length.' + required: false + body_length: + description: 'If not empty, max body length.' + required: false + jira_types: + description: 'If not empty, space separated list of types that require Jira refs.' + required: false + jira_in_header: + description: 'If not empty, allow for jira ref in header.' + required: false + version: + description: 'Release tag of the validator binary to download (defaults to the action ref).' + required: false + default: '' +runs: + using: "composite" + steps: + - name: Ensure that base is fetched + run: git fetch origin ${{ github.event.pull_request.base.sha }} + shell: bash + + - name: Ensure that head is fetched + run: git fetch origin ${{ github.event.pull_request.head.sha }} + shell: bash + + - name: Download validator binary + shell: bash + run: | + set -euo pipefail + case "$(uname -s)-$(uname -m)" in + Linux-x86_64) target=x86_64-unknown-linux-musl ;; + Linux-aarch64) target=aarch64-unknown-linux-musl ;; + Darwin-x86_64) target=x86_64-apple-darwin ;; + Darwin-arm64) target=aarch64-apple-darwin ;; + *) echo "unsupported platform: $(uname -s)-$(uname -m)" >&2; exit 1 ;; + esac + version="${{ inputs.version }}" + if [ -z "$version" ]; then version="${{ github.action_ref }}"; fi + url="https://github.com/lumapps/commit-message-validator/releases/download/${version}/commit-message-validator-${target}" + curl -sSfL "$url" -o /tmp/commit-message-validator + chmod +x /tmp/commit-message-validator + + - name: Validation + run: | + /tmp/commit-message-validator range \ + ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} \ + | tee -a "$GITHUB_STEP_SUMMARY" + env: + COMMIT_VALIDATOR_NO_JIRA: ${{ inputs.no_jira }} + COMMIT_VALIDATOR_ALLOW_TEMP: ${{ inputs.allow_temp }} + COMMIT_VALIDATOR_NO_REVERT_SHA1: ${{ inputs.no_revert_sha1 }} + GLOBAL_JIRA_IN_HEADER: ${{ inputs.jira_in_header }} + GLOBAL_JIRA_TYPES: ${{ inputs.jira_types }} + GLOBAL_MAX_LENGTH: ${{ inputs.header_length }} + GLOBAL_BODY_MAX_LENGTH: ${{ inputs.body_length }} + shell: bash +``` + +- [ ] **Step 2: Validate the YAML** + +Run: `python3 -c "import yaml; yaml.safe_load(open('action.yml'))" && echo OK` +Expected: `OK`. + +- [ ] **Step 3: Commit** + +```bash +git add action.yml +git commit -m "feat(action): run prebuilt rust binary in composite action" +``` + +--- + +## Task 13: pre-commit hook + pre-push (download shims) + +Replace the `language: script` entry so pre-commit downloads the prebuilt binary and runs `message`; update `pre-push` to use `range`. + +**Files:** +- Modify: `.pre-commit-hooks.yaml` +- Create: `hooks/commit-message-validator` (download-and-run shim) +- Modify: `pre-push` + +**Interfaces:** +- Consumes: release assets from Task 11. +- Produces: pre-commit `commit-message-validator` hook (commit-msg stage) and a working `pre-push`. + +- [ ] **Step 1: Write the shim `hooks/commit-message-validator`** + +```bash +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${COMMIT_VALIDATOR_VERSION:-master}" +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) target=x86_64-unknown-linux-musl ;; + Linux-aarch64) target=aarch64-unknown-linux-musl ;; + Darwin-x86_64) target=x86_64-apple-darwin ;; + Darwin-arm64) target=aarch64-apple-darwin ;; + *) echo "unsupported platform: $(uname -s)-$(uname -m)" >&2; exit 1 ;; +esac + +cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/commit-message-validator/${VERSION}" +bin="${cache_dir}/commit-message-validator" +if [ ! -x "$bin" ]; then + mkdir -p "$cache_dir" + url="https://github.com/lumapps/commit-message-validator/releases/download/${VERSION}/commit-message-validator-${target}" + curl -sSfL "$url" -o "$bin" + chmod +x "$bin" +fi + +exec "$bin" message "$@" +``` + +- [ ] **Step 2: Make it executable** + +```bash +chmod +x hooks/commit-message-validator +``` + +- [ ] **Step 3: Update `.pre-commit-hooks.yaml`** + +```yaml +--- +- id: commit-message-validator + name: Commit Message Validator + description: Checks that commit messages are compliant with Lumapps rules. + entry: hooks/commit-message-validator + language: script + stages: [commit-msg] +``` + +- [ ] **Step 4: Rewrite `pre-push`** to use the shim's binary via `range`. + +```bash +#!/bin/bash -e + +remote="$1" +url="$2" + +z40=0000000000000000000000000000000000000000 + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" + +while read -r local_ref local_sha remote_ref remote_sha +do + if [ "$local_sha" = $z40 ]; then + : + else + if [ "$remote_sha" = $z40 ]; then + range="$local_sha" + else + range="$remote_sha..$local_sha" + fi + "$DIR/hooks/commit-message-validator" range "$range" || exit $? + fi +done + +exit 0 +``` + +> Note: `hooks/commit-message-validator` accepts the `range` subcommand because it `exec`s the binary with all passed args — but it hardcodes `message`. Adjust the shim to forward the subcommand instead: change its last line to `exec "$bin" "$@"` and update `.pre-commit-hooks.yaml` entry to `hooks/commit-message-validator message` via `args`? Simpler: keep two thin wrappers. See Step 5. + +- [ ] **Step 5: Reconcile the shim for both subcommands** — make the shim subcommand-agnostic. + +Change the last line of `hooks/commit-message-validator` from: + +```bash +exec "$bin" message "$@" +``` + +to: + +```bash +exec "$bin" "$@" +``` + +And set the pre-commit entry to pass the subcommand explicitly in `.pre-commit-hooks.yaml`: + +```yaml +--- +- id: commit-message-validator + name: Commit Message Validator + description: Checks that commit messages are compliant with Lumapps rules. + entry: hooks/commit-message-validator message + language: script + stages: [commit-msg] +``` + +(`pre-push` already calls `"$DIR/hooks/commit-message-validator" range "$range"`, which now works.) + +- [ ] **Step 6: Shell-check the shim and pre-push parse** + +Run: `bash -n hooks/commit-message-validator && bash -n pre-push && echo OK` +Expected: `OK`. + +- [ ] **Step 7: Commit** + +```bash +git add hooks/commit-message-validator .pre-commit-hooks.yaml pre-push +git commit -m "feat(hooks): download prebuilt binary in pre-commit and pre-push" +``` + +--- + +## Task 14: Update docs + remove obsolete bash & bats + +Update `README.md` and other docs to the Rust story; delete the superseded bash scripts and `.bats` tests. + +**Files:** +- Modify: `README.md` +- Modify: `.pre-commit-config.yaml` +- Modify: `Makefile` +- Modify: `git-commit-template` (only if it references the scripts) +- Delete: `validator.sh`, `check.sh`, `check_message.sh`, `validator.bats`, `check.bats`, `check_message.bats` + +**Interfaces:** none (documentation + cleanup). + +- [ ] **Step 1: Update `README.md`** + +Make these concrete edits: +- In the intro/tagline, replace "with minimal dependancy only git and bash" with wording describing a single prebuilt Rust binary (git still required to read commits). +- Replace any installation section that references cloning/sourcing `check.sh`/`check_message.sh` with: download the binary for your platform from the Releases page (list the four target names), `chmod +x`, and place on `PATH`; or use the pre-commit hook / GitHub Action. +- Replace usage examples that call `check_message.sh ` with `commit-message-validator message `, and `check.sh ` with `commit-message-validator range `. +- Update the options documentation to the flag table (with env equivalents) from the Global Constraints. +- Keep the commit-convention rules section unchanged (rules are identical). + +- [ ] **Step 2: Update `.pre-commit-config.yaml`** + +In the self-referencing local hook block, ensure the `commit-message-validator` hook entry matches the new `.pre-commit-hooks.yaml` (id + `stages: [commit-msg]`, args `[--no-jira, --allow-temp]` preserved). No change needed to the other third-party hooks. + +- [ ] **Step 3: Update `Makefile`** + +The `lint`/`venv` targets drive pre-commit and don't call the removed scripts directly, so they remain valid. Verify by reading the file; if any target references `check.sh`/`validator.sh`, remove that reference. (Expected: no change required.) + +- [ ] **Step 4: Check `git-commit-template`** + +Read it; it documents the message format for humans. Only edit if it names the bash scripts. (Expected: no change required.) + +- [ ] **Step 5: Delete obsolete bash and bats files** + +```bash +git rm validator.sh check.sh check_message.sh validator.bats check.bats check_message.bats +``` + +- [ ] **Step 6: Verify nothing else references the removed files** + +Run: `grep -rn -e validator.sh -e check_message.sh -e 'check\.sh' --include='*.yml' --include='*.yaml' --include='*.md' --include='Makefile' . || echo "no dangling references"` +Expected: `no dangling references` (fix any that appear). + +- [ ] **Step 7: Full verification** + +Run: `cargo fmt --all -- --check && cargo clippy --all-targets -- -D warnings && cargo test && cargo llvm-cov --all-targets --fail-under-lines 90` +Expected: all PASS. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "docs(readme): document rust binary and remove bash implementation" +``` + +--- + +## Task 15: Open the draft PR + +Push the branch and open a draft PR against `master`. + +**Files:** none. + +- [ ] **Step 1: Run `/simplify` and `/code-review` over the full branch diff** and address any findings (commit fixes atomically). + +- [ ] **Step 2: Push the branch** + +```bash +git push -u origin feat/rust-migration +``` + +- [ ] **Step 3: Open the draft PR** + +```bash +gh pr create --draft --base master --title "Migrate commit validator to Rust" \ + --body "Reimplements the bash commit-message validator as a single Rust binary. Behavior-compatible (same rules and exit codes), cleaner CLI (\`message\`/\`range\` subcommands), prebuilt cross-platform release binaries, CI with a 90% coverage gate. See docs/superpowers/specs/2026-07-03-rust-migration-design.md." +``` + +- [ ] **Step 4: Confirm CI is green on the PR** + +Run: `gh pr checks --watch` +Expected: all checks pass. + +--- + +## Notes for the implementer + +- **`split('\n')` equals bash `read`:** bash here-strings append a trailing newline, so `while read` over `$MSG` yields exactly the segments `message.split('\n')` produces. Do not use `.lines()` in the parser or rule loops — it would drop a trailing empty segment and change structure results. +- **Regex fidelity:** the subject end-class `[^ ^.]` negates `{space, caret, dot}` on purpose. Don't "fix" it. +- **`%B` trailing newline:** `git log --pretty=%B` appends a newline; `run_range` trims trailing newlines to mirror bash command substitution before validating. +- **Coverage:** if a line can't be covered (e.g. an unreachable arm), add a targeted test or justify it in the PR; the CI floor is 90% but aim for 100%. From ef67242c11f894d3826b10879e32b094468cd4ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:19:51 +0200 Subject: [PATCH 07/22] chore(rust): scaffold cargo package and CI workflow --- .github/workflows/ci.yml | 35 +-- .gitignore | 1 + Cargo.lock | 450 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 24 +++ rust-toolchain.toml | 3 + src/lib.rs | 7 + src/main.rs | 3 + 7 files changed, 508 insertions(+), 15 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 rust-toolchain.toml create mode 100644 src/lib.rs create mode 100644 src/main.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 047eaeb..14e717a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,20 +1,25 @@ -name: 'Automated tests' - +--- +name: CI on: - [pull_request, push] + push: + branches: [master] + pull_request: jobs: - tests: + test: runs-on: ubuntu-latest - steps: - - name: Install bats - run: | - git clone https://github.com/bats-core/bats-core.git $HOME/bats-core - sudo $HOME/bats-core/install.sh /usr/local - - - name: Checkout - uses: actions/checkout@v2 - - - name: Unit tests - run: bats ./*.bats + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: Format + run: cargo fmt --all -- --check + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + - name: Test + run: cargo test --all-targets + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + - name: Coverage (fail under 90%) + run: cargo llvm-cov --all-targets --fail-under-lines 90 diff --git a/.gitignore b/.gitignore index 5ceb386..c031de5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ venv +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..808d578 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,450 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "commit-message-validator" +version = "2.0.0" +dependencies = [ + "assert_cmd", + "clap", + "predicates", + "regex", + "tempfile", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2041808 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "commit-message-validator" +version = "2.0.0" +edition = "2021" +rust-version = "1.80" +description = "Enforce angular commit message convention" +license = "MIT" + +[[bin]] +name = "commit-message-validator" +path = "src/main.rs" + +[lib] +name = "commit_message_validator" +path = "src/lib.rs" + +[dependencies] +clap = { version = "4", features = ["derive", "env"] } +regex = "1" + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +tempfile = "3" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..73cb934 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..fbb020e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +#[cfg(test)] +mod tests { + #[test] + fn scaffold_compiles() { + assert_eq!(2 + 2, 4); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..7d0e9a5 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("commit-message-validator"); +} From 261800cc52d73c4d1bff8d05fab1fef2397019f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:25:26 +0200 Subject: [PATCH 08/22] feat(error): add ValidationError with exit codes --- src/error.rs | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 10 ++----- 2 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 src/error.rs diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..ebaf17b --- /dev/null +++ b/src/error.rs @@ -0,0 +1,77 @@ +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValidationError { + Structure(String), + Header(String), + HeaderLength(String), + Type(String), + Scope(String), + Subject(String), + BodyLength(String), + TrailingSpace(String), + Jira(String), + Revert(String), +} + +impl ValidationError { + pub fn code(&self) -> i32 { + match self { + ValidationError::Structure(_) => 1, + ValidationError::Header(_) => 2, + ValidationError::HeaderLength(_) => 3, + ValidationError::Type(_) => 4, + ValidationError::Scope(_) => 5, + ValidationError::Subject(_) => 6, + ValidationError::BodyLength(_) => 7, + ValidationError::TrailingSpace(_) => 8, + ValidationError::Jira(_) => 9, + ValidationError::Revert(_) => 10, + } + } + + fn message(&self) -> &str { + match self { + ValidationError::Structure(m) + | ValidationError::Header(m) + | ValidationError::HeaderLength(m) + | ValidationError::Type(m) + | ValidationError::Scope(m) + | ValidationError::Subject(m) + | ValidationError::BodyLength(m) + | ValidationError::TrailingSpace(m) + | ValidationError::Jira(m) + | ValidationError::Revert(m) => m, + } + } +} + +impl fmt::Display for ValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codes_match_historical_values() { + assert_eq!(ValidationError::Structure(String::new()).code(), 1); + assert_eq!(ValidationError::Header(String::new()).code(), 2); + assert_eq!(ValidationError::HeaderLength(String::new()).code(), 3); + assert_eq!(ValidationError::Type(String::new()).code(), 4); + assert_eq!(ValidationError::Scope(String::new()).code(), 5); + assert_eq!(ValidationError::Subject(String::new()).code(), 6); + assert_eq!(ValidationError::BodyLength(String::new()).code(), 7); + assert_eq!(ValidationError::TrailingSpace(String::new()).code(), 8); + assert_eq!(ValidationError::Jira(String::new()).code(), 9); + assert_eq!(ValidationError::Revert(String::new()).code(), 10); + } + + #[test] + fn display_renders_message() { + assert_eq!(ValidationError::Type("boom".into()).to_string(), "boom"); + } +} diff --git a/src/lib.rs b/src/lib.rs index fbb020e..16fb042 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,3 @@ -#[cfg(test)] -mod tests { - #[test] - fn scaffold_compiles() { - assert_eq!(2 + 2, 4); - } -} +pub mod error; + +pub use error::ValidationError; From 35b8ce7ebf2cb67a94ac4c5cff587a807fe69204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:28:11 +0200 Subject: [PATCH 09/22] feat(config): add Config with env/flag resolution --- src/config.rs | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + 2 files changed, 151 insertions(+) create mode 100644 src/config.rs diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..ce90a89 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,149 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + pub no_jira: bool, + pub allow_temp: bool, + pub no_revert_sha1: bool, + pub jira_in_header: bool, + pub header_max_length: usize, + pub body_max_length: usize, + pub jira_types: Vec, +} + +impl Default for Config { + fn default() -> Self { + Config { + no_jira: false, + allow_temp: false, + no_revert_sha1: false, + jira_in_header: false, + header_max_length: 100, + body_max_length: 100, + jira_types: vec!["feat".to_string(), "fix".to_string()], + } + } +} + +#[derive(Debug, Default)] +pub struct Overrides { + pub no_jira: bool, + pub allow_temp: bool, + pub no_revert_sha1: bool, + pub jira_in_header: bool, + pub header_length: Option, + pub body_length: Option, + pub jira_types: Option, +} + +fn resolve_usize(cli: Option, env: Option, default: usize) -> usize { + if let Some(n) = cli { + return n; + } + env.and_then(|v| v.parse().ok()).unwrap_or(default) +} + +impl Config { + pub fn resolve(o: &Overrides, env: impl Fn(&str) -> Option) -> Config { + let flag = |cli: bool, name: &str| cli || env(name).map(|v| !v.is_empty()).unwrap_or(false); + let default = Config::default(); + let jira_types = o + .jira_types + .clone() + .or_else(|| env("GLOBAL_JIRA_TYPES").filter(|v| !v.is_empty())) + .map(|t| t.split_whitespace().map(String::from).collect()) + .unwrap_or(default.jira_types); + Config { + no_jira: flag(o.no_jira, "COMMIT_VALIDATOR_NO_JIRA"), + allow_temp: flag(o.allow_temp, "COMMIT_VALIDATOR_ALLOW_TEMP"), + no_revert_sha1: flag(o.no_revert_sha1, "COMMIT_VALIDATOR_NO_REVERT_SHA1"), + jira_in_header: flag(o.jira_in_header, "GLOBAL_JIRA_IN_HEADER"), + header_max_length: resolve_usize( + o.header_length, + env("GLOBAL_MAX_LENGTH"), + default.header_max_length, + ), + body_max_length: resolve_usize( + o.body_length, + env("GLOBAL_BODY_MAX_LENGTH"), + default.body_max_length, + ), + jira_types, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn no_env(_: &str) -> Option { + None + } + + #[test] + fn defaults_when_nothing_set() { + let c = Config::resolve(&Overrides::default(), no_env); + assert!(!c.no_jira && !c.allow_temp && !c.no_revert_sha1 && !c.jira_in_header); + assert_eq!(c.header_max_length, 100); + assert_eq!(c.body_max_length, 100); + assert_eq!(c.jira_types, vec!["feat".to_string(), "fix".to_string()]); + } + + #[test] + fn cli_flag_enables_bool() { + let o = Overrides { + no_jira: true, + ..Default::default() + }; + assert!(Config::resolve(&o, no_env).no_jira); + } + + #[test] + fn nonempty_env_enables_bool_empty_does_not() { + let on = |n: &str| (n == "COMMIT_VALIDATOR_NO_JIRA").then(|| "1".to_string()); + let empty = |n: &str| (n == "COMMIT_VALIDATOR_NO_JIRA").then(String::new); + assert!(Config::resolve(&Overrides::default(), on).no_jira); + assert!(!Config::resolve(&Overrides::default(), empty).no_jira); + } + + #[test] + fn cli_length_overrides_env_and_default() { + let o = Overrides { + header_length: Some(150), + ..Default::default() + }; + assert_eq!(Config::resolve(&o, no_env).header_max_length, 150); + } + + #[test] + fn env_length_used_when_no_cli_and_empty_falls_back() { + let set = |n: &str| (n == "GLOBAL_BODY_MAX_LENGTH").then(|| "150".to_string()); + let empty = |n: &str| (n == "GLOBAL_BODY_MAX_LENGTH").then(String::new); + assert_eq!( + Config::resolve(&Overrides::default(), set).body_max_length, + 150 + ); + assert_eq!( + Config::resolve(&Overrides::default(), empty).body_max_length, + 100 + ); + } + + #[test] + fn jira_types_split_on_whitespace() { + let o = Overrides { + jira_types: Some("feat fix chore".into()), + ..Default::default() + }; + let c = Config::resolve(&o, no_env); + assert_eq!(c.jira_types, vec!["feat", "fix", "chore"]); + } + + #[test] + fn empty_env_jira_types_falls_back_to_default() { + let empty = |n: &str| (n == "GLOBAL_JIRA_TYPES").then(String::new); + assert_eq!( + Config::resolve(&Overrides::default(), empty).jira_types, + vec!["feat".to_string(), "fix".to_string()] + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 16fb042..5f72e64 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +pub mod config; pub mod error; +pub use config::{Config, Overrides}; pub use error::ValidationError; From 19d0cbac14d357d3731125f964ce3ccb289dbcd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:31:14 +0200 Subject: [PATCH 10/22] feat(patterns): port validator regexes to Rust --- src/lib.rs | 2 + src/patterns.rs | 106 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 src/patterns.rs diff --git a/src/lib.rs b/src/lib.rs index 5f72e64..0f3e57d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ pub mod config; pub mod error; +mod patterns; + pub use config::{Config, Overrides}; pub use error::ValidationError; diff --git a/src/patterns.rs b/src/patterns.rs new file mode 100644 index 0000000..e82524a --- /dev/null +++ b/src/patterns.rs @@ -0,0 +1,106 @@ +use regex::Regex; +use std::sync::LazyLock; + +#[allow(dead_code)] +pub const JIRA: &str = r"[A-Z]{2,7}[0-9]{0,6}-[0-9]{1,6}"; + +#[allow(dead_code)] +pub static HEADER: LazyLock = + LazyLock::new(|| Regex::new(r"^([^(]+)\(([^)]+)\): (.+)$").unwrap()); +#[allow(dead_code)] +pub static TYPE: LazyLock = + LazyLock::new(|| Regex::new(r"^(feat|fix|docs|gen|lint|refactor|test|chore)$").unwrap()); +#[allow(dead_code)] +pub static SCOPE: LazyLock = + LazyLock::new(|| Regex::new(r"^([a-z][a-z0-9]*)(-[a-z0-9]+)*$").unwrap()); +#[allow(dead_code)] +pub static SUBJECT: LazyLock = + LazyLock::new(|| Regex::new(r"^[A-Za-z0-9].*[^ ^.]$").unwrap()); +#[allow(dead_code)] +pub static JIRA_FOOTER: LazyLock = + LazyLock::new(|| Regex::new(&format!(r"^({JIRA} ?)+$")).unwrap()); +#[allow(dead_code)] +pub static JIRA_HEADER: LazyLock = + LazyLock::new(|| Regex::new(&format!(r"^.*[^A-Z]({JIRA}).*$")).unwrap()); +#[allow(dead_code)] +pub static BROKE: LazyLock = LazyLock::new(|| Regex::new(r"^BROKEN:$").unwrap()); +#[allow(dead_code)] +pub static TRAILING_SPACE: LazyLock = LazyLock::new(|| Regex::new(r" +$").unwrap()); +#[allow(dead_code)] +pub static REVERT_HEADER: LazyLock = + LazyLock::new(|| Regex::new(r"^[Rr](evert|eapply)[: ].*$").unwrap()); +#[allow(dead_code)] +pub static REVERT_COMMIT: LazyLock = + LazyLock::new(|| Regex::new(r"^This reverts commit ([a-f0-9]+)").unwrap()); +#[allow(dead_code)] +pub static TEMP_HEADER: LazyLock = + LazyLock::new(|| Regex::new(r"^(fixup!|squash!).*$").unwrap()); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn header_captures_type_scope_subject() { + let caps = HEADER.captures("type(scope): message").unwrap(); + assert_eq!(&caps[1], "type"); + assert_eq!(&caps[2], "scope"); + assert_eq!(&caps[3], "message"); + } + + #[test] + fn header_rejects_malformed() { + assert!(HEADER.captures("type(scope):message").is_none()); + assert!(HEADER.captures("type scope: message").is_none()); + assert!(HEADER.captures("type(scope): ").is_none()); + } + + #[test] + fn type_only_known_lowercase() { + assert!(TYPE.is_match("feat")); + assert!(!TYPE.is_match("Feat")); + assert!(!TYPE.is_match("feat ")); + assert!(!TYPE.is_match("plop")); + } + + #[test] + fn scope_is_kebab_case() { + assert!(SCOPE.is_match("p2")); + assert!(SCOPE.is_match("pl2op-plop1-plop-0001")); + assert!(!SCOPE.is_match("")); + assert!(!SCOPE.is_match("plopPlop")); + assert!(!SCOPE.is_match("plop plop")); + assert!(!SCOPE.is_match("plop ")); + } + + #[test] + fn subject_rules() { + assert!(SUBJECT.is_match("0002 dedezf ef")); + assert!(!SUBJECT.is_match("")); + assert!(!SUBJECT.is_match("plop ")); + assert!(!SUBJECT.is_match("plop.")); + } + + #[test] + fn jira_footer_and_header() { + assert!(JIRA_FOOTER.is_match("ABC-1234")); + assert!(JIRA_FOOTER.is_match("ABC-1234 DE-1234")); + assert!(!JIRA_FOOTER.is_match("2345")); + let caps = JIRA_HEADER.captures("feat(abc): ABC-1234").unwrap(); + assert_eq!(&caps[1], "ABC-1234"); + } + + #[test] + fn misc_patterns() { + assert!(BROKE.is_match("BROKEN:")); + assert!(TRAILING_SPACE.is_match("foo ")); + assert!(!TRAILING_SPACE.is_match("foo")); + assert!(REVERT_HEADER.is_match("revert: type(scope): message")); + assert!(REVERT_HEADER.is_match("Revert \"type(scope): message\"")); + assert!(REVERT_COMMIT + .captures("This reverts commit 1234abcd.") + .is_some()); + assert!(TEMP_HEADER.is_match("fixup! foo")); + assert!(TEMP_HEADER.is_match("squash! foo")); + } +} From 1e683ca56e7ab54a00186a60c2bbd2d979415cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:35:35 +0200 Subject: [PATCH 11/22] feat(parser): port commit structure state machine --- src/lib.rs | 1 + src/parser.rs | 246 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 src/parser.rs diff --git a/src/lib.rs b/src/lib.rs index 0f3e57d..e7a95cc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod config; pub mod error; +pub mod parser; mod patterns; diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..80cc0ce --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,246 @@ +use crate::error::ValidationError; +use crate::patterns; + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct ParsedMessage { + pub header: String, + pub body: String, + pub jira: String, + pub footer: String, +} + +#[derive(PartialEq)] +enum State { + WaitingHeader, + WaitingEmpty, + StartText, + ReadingBody, + ReadingBroken, + ReadingFooter, +} + +fn structure(msg: &str) -> ValidationError { + ValidationError::Structure(msg.to_string()) +} + +pub fn parse(message: &str, jira_in_header: bool) -> Result { + let mut parsed = ParsedMessage::default(); + let mut state = State::WaitingHeader; + + for line in message.split('\n') { + match state { + State::WaitingHeader => { + parsed.header = line.to_string(); + state = State::WaitingEmpty; + if jira_in_header { + if let Some(caps) = patterns::JIRA_HEADER.captures(line) { + parsed.jira = caps[1].to_string(); + } + } + } + State::WaitingEmpty => { + if !line.is_empty() { + return Err(structure( + "missing empty line in commit message between header and body or body and footer", + )); + } + state = State::StartText; + } + State::StartText => { + if line.is_empty() { + return Err(structure("double empty line is not allowed")); + } + if patterns::BROKE.is_match(line) { + state = State::ReadingFooter; + } else if patterns::JIRA_FOOTER.is_match(line) { + state = State::ReadingBroken; + parsed.jira = line.to_string(); + } else { + state = State::ReadingBody; + parsed.body.push_str(line); + parsed.body.push('\n'); + } + } + State::ReadingBody => { + if patterns::BROKE.is_match(line) { + return Err(structure("missing empty line before broke part")); + } + if patterns::JIRA_FOOTER.is_match(line) { + return Err(structure("missing empty line before JIRA reference")); + } + if line.is_empty() { + state = State::StartText; + } else { + parsed.body.push_str(line); + parsed.body.push('\n'); + } + } + State::ReadingBroken => { + if patterns::BROKE.is_match(line) { + state = State::ReadingFooter; + } else { + return Err(structure( + "only broken part could be after the JIRA reference", + )); + } + } + State::ReadingFooter => { + if line.is_empty() { + return Err(structure("no empty line allowed in broken part")); + } + parsed.footer.push_str(line); + parsed.footer.push('\n'); + } + } + } + + if state == State::StartText { + return Err(structure( + "new line at the end of the commit is not allowed", + )); + } + + Ok(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ok(msg: &str, jira_in_header: bool) -> ParsedMessage { + parse(msg, jira_in_header).expect("expected valid structure") + } + + fn err_code(msg: &str) -> i32 { + parse(msg, false) + .expect_err("expected structure error") + .code() + } + + #[test] + fn structure_errors() { + // one trailing line + assert_eq!(err_code("plop plop\n"), 1); + // missing empty line after header (body) + assert_eq!(err_code("plop plop\nplop\n\nplop\n"), 1); + // missing empty line after header (jira) + assert_eq!(err_code("plop plop\nABC-1234\n"), 1); + // missing empty line after header (broken) + assert_eq!(err_code("plop plop\nBROKEN:\n"), 1); + // missing empty line after body with jira ref + assert_eq!( + err_code("plop plop\n\nplop\nplop\nplop\nplop\nLUM-1234\n"), + 1 + ); + // missing empty line after body with broken + assert_eq!( + err_code("plop plop\n\nplop\nplop\nplop\nplop\nBROKEN:\n"), + 1 + ); + // empty line after the body (double empty) + assert_eq!(err_code("plop plop\n\nplop\nplop\nplop\nplop\n\n"), 1); + } + + #[test] + fn header_only() { + let p = ok("plop plop", false); + assert_eq!( + p, + ParsedMessage { + header: "plop plop".into(), + ..Default::default() + } + ); + } + + #[test] + fn header_and_jira() { + let p = ok("plop plop\n\nABC-1234", false); + assert_eq!(p.header, "plop plop"); + assert_eq!(p.jira, "ABC-1234"); + assert_eq!(p.body, ""); + assert_eq!(p.footer, ""); + } + + #[test] + fn jira_in_header_extracted() { + let p = ok("feat(abc): ABC-1234\n\nplop", true); + assert_eq!(p.header, "feat(abc): ABC-1234"); + assert_eq!(p.jira, "ABC-1234"); + assert_eq!(p.body, "plop\n"); + } + + #[test] + fn header_and_multiple_jira() { + let p = ok("plop plop\n\nABC-1234 DE-1234", false); + assert_eq!(p.jira, "ABC-1234 DE-1234"); + } + + #[test] + fn header_and_broken() { + let p = ok("plop plop\n\nBROKEN:\n- plop\n- plop", false); + assert_eq!(p.footer, "- plop\n- plop\n"); + assert_eq!(p.body, ""); + } + + #[test] + fn header_jira_and_broken() { + let p = ok("plop plop\n\nABC-1234\nBROKEN:\n- plop\n- plop", false); + assert_eq!(p.jira, "ABC-1234"); + assert_eq!(p.footer, "- plop\n- plop\n"); + } + + #[test] + fn header_and_body() { + let p = ok("plop plop\n\nhello", false); + assert_eq!(p.body, "hello\n"); + } + + #[test] + fn multiline_body() { + let p = ok("plop plop\n\nhello\n\nplopplop\nplopplop\n\ntoto", false); + assert_eq!(p.body, "hello\nplopplop\nplopplop\ntoto\n"); + } + + #[test] + fn multiline_body_and_jira() { + let p = ok( + "plop plop\n\nhello\n\nplopplop\nplopplop\n\ntoto\n\nABC-1234", + false, + ); + assert_eq!(p.body, "hello\nplopplop\nplopplop\ntoto\n"); + assert_eq!(p.jira, "ABC-1234"); + } + + #[test] + fn multiline_body_and_broken() { + let p = ok( + "plop plop\n\nhello\n\nplopplop\nplopplop\n\ntoto\n\nBROKEN:\n- plop\n- plop", + false, + ); + assert_eq!(p.body, "hello\nplopplop\nplopplop\ntoto\n"); + assert_eq!(p.footer, "- plop\n- plop\n"); + } + + #[test] + fn multiline_body_jira_and_broken() { + let p = ok( + "plop plop\n\nhello\n\nplopplop\nplopplop\n\ntoto\n\nABC-1234\nBROKEN:\n- plop\n- plop", + false, + ); + assert_eq!(p.body, "hello\nplopplop\nplopplop\ntoto\n"); + assert_eq!(p.jira, "ABC-1234"); + assert_eq!(p.footer, "- plop\n- plop\n"); + } + + #[test] + fn only_broken_allowed_after_jira() { + // JIRA ref then a non-broken line -> structure error + assert_eq!(err_code("plop plop\n\nABC-1234\nnope"), 1); + } + + #[test] + fn no_empty_line_in_broken_part() { + assert_eq!(err_code("plop plop\n\nBROKEN:\n- plop\n\n- plop"), 1); + } +} From 36760cd80479e2a1d52ed8bf091c75d8fbacdcaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:41:34 +0200 Subject: [PATCH 12/22] feat(validate): port per-rule validation functions --- src/lib.rs | 1 + src/patterns.rs | 12 -- src/validate.rs | 289 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 12 deletions(-) create mode 100644 src/validate.rs diff --git a/src/lib.rs b/src/lib.rs index e7a95cc..719187d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod config; pub mod error; pub mod parser; +pub mod validate; mod patterns; diff --git a/src/patterns.rs b/src/patterns.rs index e82524a..53ff0b0 100644 --- a/src/patterns.rs +++ b/src/patterns.rs @@ -1,38 +1,26 @@ use regex::Regex; use std::sync::LazyLock; -#[allow(dead_code)] pub const JIRA: &str = r"[A-Z]{2,7}[0-9]{0,6}-[0-9]{1,6}"; -#[allow(dead_code)] pub static HEADER: LazyLock = LazyLock::new(|| Regex::new(r"^([^(]+)\(([^)]+)\): (.+)$").unwrap()); -#[allow(dead_code)] pub static TYPE: LazyLock = LazyLock::new(|| Regex::new(r"^(feat|fix|docs|gen|lint|refactor|test|chore)$").unwrap()); -#[allow(dead_code)] pub static SCOPE: LazyLock = LazyLock::new(|| Regex::new(r"^([a-z][a-z0-9]*)(-[a-z0-9]+)*$").unwrap()); -#[allow(dead_code)] pub static SUBJECT: LazyLock = LazyLock::new(|| Regex::new(r"^[A-Za-z0-9].*[^ ^.]$").unwrap()); -#[allow(dead_code)] pub static JIRA_FOOTER: LazyLock = LazyLock::new(|| Regex::new(&format!(r"^({JIRA} ?)+$")).unwrap()); -#[allow(dead_code)] pub static JIRA_HEADER: LazyLock = LazyLock::new(|| Regex::new(&format!(r"^.*[^A-Z]({JIRA}).*$")).unwrap()); -#[allow(dead_code)] pub static BROKE: LazyLock = LazyLock::new(|| Regex::new(r"^BROKEN:$").unwrap()); -#[allow(dead_code)] pub static TRAILING_SPACE: LazyLock = LazyLock::new(|| Regex::new(r" +$").unwrap()); -#[allow(dead_code)] pub static REVERT_HEADER: LazyLock = LazyLock::new(|| Regex::new(r"^[Rr](evert|eapply)[: ].*$").unwrap()); -#[allow(dead_code)] pub static REVERT_COMMIT: LazyLock = LazyLock::new(|| Regex::new(r"^This reverts commit ([a-f0-9]+)").unwrap()); -#[allow(dead_code)] pub static TEMP_HEADER: LazyLock = LazyLock::new(|| Regex::new(r"^(fixup!|squash!).*$").unwrap()); diff --git a/src/validate.rs b/src/validate.rs new file mode 100644 index 0000000..1f4ff51 --- /dev/null +++ b/src/validate.rs @@ -0,0 +1,289 @@ +use crate::config::Config; +use crate::error::ValidationError; +use crate::patterns; + +#[derive(Debug)] +pub enum HeaderKind { + Temp, + Revert, + Conventional { + type_: String, + scope: String, + subject: String, + }, +} + +pub fn classify_header(header: &str, config: &Config) -> Result { + if config.allow_temp && patterns::TEMP_HEADER.is_match(header) { + Ok(HeaderKind::Temp) + } else if patterns::REVERT_HEADER.is_match(header) { + Ok(HeaderKind::Revert) + } else if let Some(caps) = patterns::HEADER.captures(header) { + Ok(HeaderKind::Conventional { + type_: caps[1].to_string(), + scope: caps[2].to_string(), + subject: caps[3].to_string(), + }) + } else { + Err(ValidationError::Header( + "commit header doesn't match overall header pattern: 'type(scope): message'" + .to_string(), + )) + } +} + +pub fn header_length(header: &str, max: usize) -> Result<(), ValidationError> { + if header.chars().count() > max { + return Err(ValidationError::HeaderLength(format!( + "commit header length is more than {max} characters" + ))); + } + Ok(()) +} + +pub fn commit_type(type_: &str) -> Result<(), ValidationError> { + if patterns::TYPE.is_match(type_) { + Ok(()) + } else { + Err(ValidationError::Type(format!( + "commit type '{type_}' is unknown" + ))) + } +} + +pub fn scope(scope: &str) -> Result<(), ValidationError> { + if patterns::SCOPE.is_match(scope) { + Ok(()) + } else { + Err(ValidationError::Scope(format!( + "commit scope '{scope}' is not kebab-case" + ))) + } +} + +pub fn subject(subject: &str) -> Result<(), ValidationError> { + if patterns::SUBJECT.is_match(subject) { + Ok(()) + } else { + Err(ValidationError::Subject(format!( + "commit subject '{subject}' should not end with a '.'" + ))) + } +} + +pub fn body_length(text: &str, max: usize) -> Result<(), ValidationError> { + for line in text.split('\n') { + // Skip lines with no whitespace as they can't be wrapped. + if !line.contains(|c: char| c.is_ascii_whitespace()) { + continue; + } + if line.chars().count() > max { + return Err(ValidationError::BodyLength(format!( + "body message line length is more than {max} characters" + ))); + } + } + Ok(()) +} + +pub fn trailing_space(text: &str) -> Result<(), ValidationError> { + for line in text.split('\n') { + if patterns::TRAILING_SPACE.is_match(line) { + return Err(ValidationError::TrailingSpace( + "body message must not have trailing spaces".to_string(), + )); + } + } + Ok(()) +} + +pub fn need_jira(type_: &str, config: &Config) -> bool { + if config.no_jira { + return false; + } + config.jira_types.iter().any(|t| t == type_) +} + +pub fn jira(type_: &str, jira: &str, config: &Config) -> Result<(), ValidationError> { + if need_jira(type_, config) && jira.is_empty() { + return Err(ValidationError::Jira(format!( + "commits with type '{type_}' need to include a reference to a JIRA ticket, by adding the project prefix and the issue number to the commit message, this could be done easily with: git commit -m 'feat(widget): add a wonderful widget' -m LUM-1234" + ))); + } + Ok(()) +} + +pub fn revert(body: &str, config: &Config) -> Result<(), ValidationError> { + if config.no_revert_sha1 { + return Ok(()); + } + let has_sha = body + .split('\n') + .any(|line| patterns::REVERT_COMMIT.is_match(line)); + if has_sha { + Ok(()) + } else { + Err(ValidationError::Revert( + "revert commit should contain the reverted sha1".to_string(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn cfg() -> Config { + Config::default() + } + + #[test] + fn classify_rejects_malformed_headers() { + for bad in [ + "type", + "type(scope)", + "type(scope) message", + "type(scope) : message", + "type(scope: message", + "type scope: message", + "type(scope):message", + ] { + assert_eq!(classify_header(bad, &cfg()).unwrap_err().code(), 2, "{bad}"); + } + } + + #[test] + fn classify_temp_only_when_allowed() { + let mut c = cfg(); + assert_eq!(classify_header("fixup! x", &c).unwrap_err().code(), 2); + assert_eq!(classify_header("squash! x", &c).unwrap_err().code(), 2); + c.allow_temp = true; + assert!(matches!( + classify_header("fixup! x", &c).unwrap(), + HeaderKind::Temp + )); + assert!(matches!( + classify_header("squash! x", &c).unwrap(), + HeaderKind::Temp + )); + } + + #[test] + fn classify_revert() { + assert!(matches!( + classify_header("revert: type(scope): message", &cfg()).unwrap(), + HeaderKind::Revert + )); + assert!(matches!( + classify_header("Revert \"type(scope): message\"", &cfg()).unwrap(), + HeaderKind::Revert + )); + } + + #[test] + fn classify_conventional_extracts_parts() { + let k = classify_header("type(scope): message", &cfg()).unwrap(); + match k { + HeaderKind::Conventional { + type_, + scope, + subject, + } => { + assert_eq!(type_, "type"); + assert_eq!(scope, "scope"); + assert_eq!(subject, "message"); + } + _ => panic!("expected conventional"), + } + } + + #[test] + fn header_length_boundary() { + let at = "0".repeat(100); + let over = "0".repeat(101); + assert!(header_length(&at, 100).is_ok()); + assert_eq!(header_length(&over, 100).unwrap_err().code(), 3); + assert!(header_length(&over, 150).is_ok()); + } + + #[test] + fn commit_type_rules() { + assert!(commit_type("feat").is_ok()); + assert_eq!(commit_type("plop").unwrap_err().code(), 4); + assert_eq!(commit_type("feat ").unwrap_err().code(), 4); + assert_eq!(commit_type("Feat").unwrap_err().code(), 4); + } + + #[test] + fn scope_rules() { + assert!(scope("p2").is_ok()); + assert!(scope("pl2op-plop1-plop-0001").is_ok()); + for bad in ["", "plopPlop", "plop plop", "plop "] { + assert_eq!(scope(bad).unwrap_err().code(), 5, "{bad}"); + } + } + + #[test] + fn subject_rules() { + assert!(subject("0002 dedezf ef zefzef").is_ok()); + for bad in ["", "plop ", "plop."] { + assert_eq!(subject(bad).unwrap_err().code(), 6, "{bad}"); + } + } + + #[test] + fn body_length_rules() { + let over = "12345678 ".to_string() + &"0".repeat(93); // 102 chars, has a space + assert_eq!(body_length(&over, 100).unwrap_err().code(), 7); + // long line with no space is skipped + let long_no_space = "0".repeat(260); + assert!(body_length(&long_no_space, 100).is_ok()); + // 100-char line with space is fine + let at = "12345678 ".to_string() + &"0".repeat(91); // 100 chars + assert!(body_length(&at, 100).is_ok()); + assert!(body_length(&over, 150).is_ok()); + } + + #[test] + fn trailing_space_rules() { + assert_eq!(trailing_space("pdzofjzf ").unwrap_err().code(), 8); + assert_eq!( + trailing_space("\nrerer\n\n \nLUM-2345") + .unwrap_err() + .code(), + 8 + ); + assert!(trailing_space("\nrerer\n\n\nLUM-2345").is_ok()); + } + + #[test] + fn need_jira_rules() { + let mut c = cfg(); + assert!(need_jira("feat", &c)); + assert!(need_jira("fix", &c)); + assert!(!need_jira("docs", &c)); + assert!(!need_jira("test", &c)); + c.no_jira = true; + assert!(!need_jira("feat", &c)); + } + + #[test] + fn jira_rules() { + assert_eq!(jira("feat", "", &cfg()).unwrap_err().code(), 9); + assert!(jira("lint", "", &cfg()).is_ok()); + assert!(jira("feat", "ABC-123", &cfg()).is_ok()); + assert!(jira("feat", "AB-123", &cfg()).is_ok()); + } + + #[test] + fn revert_rules() { + let no_sha = "rerer\n\nLUM-2345"; + let with_sha = "rerer\n\nThis reverts commit 1234567890.\n\nLUM-2345"; + assert_eq!(revert(no_sha, &cfg()).unwrap_err().code(), 10); + assert!(revert(with_sha, &cfg()).is_ok()); + let mut c = cfg(); + c.no_revert_sha1 = true; + assert!(revert(no_sha, &c).is_ok()); + } +} From 9fe05db807bee2d1eeef1faf5d2c4227e7cb99d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:45:15 +0200 Subject: [PATCH 13/22] feat(lib): orchestrate full commit validation --- src/lib.rs | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 135 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 719187d..53c154f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,141 @@ pub mod config; pub mod error; -pub mod parser; -pub mod validate; - +mod parser; mod patterns; +mod validate; pub use config::{Config, Overrides}; pub use error::ValidationError; + +use validate::HeaderKind; + +#[derive(Debug, PartialEq, Eq)] +pub enum Outcome { + Valid, + Temp, +} + +pub fn validate_message(message: &str, config: &Config) -> Result { + let parsed = parser::parse(message, config.jira_in_header)?; + + match validate::classify_header(&parsed.header, config)? { + HeaderKind::Temp => Ok(Outcome::Temp), + HeaderKind::Revert => { + validate::revert(&parsed.body, config)?; + Ok(Outcome::Valid) + } + HeaderKind::Conventional { + type_, + scope, + subject, + } => { + validate::header_length(&parsed.header, config.header_max_length)?; + validate::commit_type(&type_)?; + validate::scope(&scope)?; + validate::subject(&subject)?; + validate::body_length(&parsed.body, config.body_max_length)?; + validate::body_length(&parsed.footer, config.body_max_length)?; + validate::trailing_space(&parsed.body)?; + validate::trailing_space(&parsed.footer)?; + validate::jira(&type_, &parsed.jira, config)?; + Ok(Outcome::Valid) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn code(msg: &str, config: &Config) -> i32 { + validate_message(msg, config) + .map(|_| 0) + .unwrap_or_else(|e| e.code()) + } + + #[test] + fn invalid_structure() { + assert_eq!(code("plop\nplop", &Config::default()), 1); + } + + #[test] + fn invalid_header() { + assert_eq!(code("plop", &Config::default()), 2); + } + + #[test] + fn invalid_header_length() { + let msg = "feat(plop): 012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"; + assert_eq!(code(msg, &Config::default()), 3); + } + + #[test] + fn invalid_type() { + let msg = "Feat(scope1): subject\n\nCommit about stuff\n\nLUM-2345"; + assert_eq!(code(msg, &Config::default()), 4); + } + + #[test] + fn invalid_scope() { + let msg = "feat(scope 1): subject\n\nCommit about stuff\n\nLUM-2345"; + assert_eq!(code(msg, &Config::default()), 5); + } + + #[test] + fn valid_capitalized_subject_is_not_subject_error() { + let msg = "feat(scope1): Subject\n\nCommit about stuff\n\nLUM-2345"; + assert_ne!(code(msg, &Config::default()), 6); + } + + #[test] + fn invalid_body_length() { + let msg = "feat(scope1): subject\n\n1 2 3 4 5678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901\n\nLUM-2345"; + assert_eq!(code(msg, &Config::default()), 7); + } + + #[test] + fn invalid_body_trailing_space() { + let msg = "chore(scope1): subject\n\n123456789012345678901234567890123456789012 "; + assert_eq!(code(msg, &Config::default()), 8); + } + + #[test] + fn invalid_footer_length() { + let msg = "feat(scope1): subject\n\nplop\n\nLUM-2345\nBROKEN:\n- 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901"; + assert_eq!(code(msg, &Config::default()), 7); + } + + #[test] + fn invalid_footer_trailing_space() { + let msg = "feat(scope1): subject\n\nplop\n\nLUM-2345\nBROKEN:\n- 123456 "; + assert_eq!(code(msg, &Config::default()), 8); + } + + #[test] + fn missing_jira() { + let msg = "feat(scope1): subject\n\nCommit about stuff\n\n2345"; + assert_eq!(code(msg, &Config::default()), 9); + } + + #[test] + fn fully_valid() { + let msg = "feat(scope1): subject\n\nCommit about stuff dezd\n\n12345678901234567890123456789012345678901234567890\n12345678901234567890123456789012345678901234567890\n\nLUM-2345\nBROKEN:\n- plop\n- plop"; + assert_eq!(code(msg, &Config::default()), 0); + } + + #[test] + fn revert_valid_with_sha() { + let msg = "Revert \"feat(scope1): subject\"\n\nThis reverts commit 12345678900.\nCommit about stuff dezd\n\n12345678901234567890123456789012345678901234567890\n\nLUM-2345\nBROKEN:\n- plop\n- plop"; + assert_eq!(code(msg, &Config::default()), 0); + } + + #[test] + fn fixup_valid_when_allowed_rejected_otherwise() { + let msg = + "fixup! plepozkfopezr\n\nCommit about stuff dezd\n\nLUM-2345\nBROKEN:\n- plop\n- plop"; + let mut c = Config::default(); + assert_eq!(code(msg, &c), 2); + c.allow_temp = true; + assert_eq!(code(msg, &c), 0); + } +} From e0ecb653b52ae33acba4f62282a5533d75d3396a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:47:40 +0200 Subject: [PATCH 14/22] feat(preprocess): port merge/comment message handling --- src/lib.rs | 1 + src/preprocess.rs | 74 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/preprocess.rs diff --git a/src/lib.rs b/src/lib.rs index 53c154f..b214da2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod config; pub mod error; mod parser; mod patterns; +pub mod preprocess; mod validate; pub use config::{Config, Overrides}; diff --git a/src/preprocess.rs b/src/preprocess.rs new file mode 100644 index 0000000..adb4148 --- /dev/null +++ b/src/preprocess.rs @@ -0,0 +1,74 @@ +pub enum Preprocessed { + Skip, + Message(String), +} + +pub fn preprocess_message_file(path: &str, contents: &str) -> Preprocessed { + if path.ends_with("MERGE_MSG") { + return Preprocessed::Skip; + } + + // Remove comment lines (leading '#'), mirroring `sed '/^#/d'`. + let stripped = contents + .lines() + .filter(|line| !line.starts_with('#')) + .collect::>() + .join("\n"); + + // First word (up to the first space), lowercased, like `${MSG%% *}`. + let first_word = stripped.split(' ').next().unwrap_or("").to_lowercase(); + if first_word == "merge" { + return Preprocessed::Skip; + } + + Preprocessed::Message(stripped) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn is_skip(path: &str, contents: &str) -> bool { + matches!(preprocess_message_file(path, contents), Preprocessed::Skip) + } + + #[test] + fn skips_merge_msg_path() { + assert!(is_skip( + "/some/path/MERGE_MSG", + "Merge branch 'foo' into 'bar'" + )); + } + + #[test] + fn skips_merge_first_word_any_case() { + for msg in [ + "Merge branch 'foo' into 'bar'", + "Merge pull request #1", + "MERGE branch 'feature' into 'main'", + "MeRgE branch 'test'", + "merge whatever", + ] { + assert!(is_skip("/x/COMMIT_EDITMSG", msg), "{msg}"); + } + } + + #[test] + fn strips_comment_lines() { + match preprocess_message_file( + "/x/COMMIT_EDITMSG", + "# a comment\nfeat(scope): valid subject\n", + ) { + Preprocessed::Message(m) => assert_eq!(m, "feat(scope): valid subject"), + Preprocessed::Skip => panic!("should not skip"), + } + } + + #[test] + fn returns_message_for_normal_commit() { + match preprocess_message_file("/x/COMMIT_EDITMSG", "feat(widget): add a widget") { + Preprocessed::Message(m) => assert_eq!(m, "feat(widget): add a widget"), + Preprocessed::Skip => panic!("should not skip"), + } + } +} From 7953e51a798a2da990324642028ddef84ef4bbf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:50:18 +0200 Subject: [PATCH 15/22] feat(cli): add message subcommand with flag/env config --- src/main.rs | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++- tests/cli.rs | 99 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 tests/cli.rs diff --git a/src/main.rs b/src/main.rs index 7d0e9a5..b83c777 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,107 @@ -fn main() { - println!("commit-message-validator"); +use std::process::ExitCode; + +use clap::{Args, Parser, Subcommand}; +use commit_message_validator::preprocess::{preprocess_message_file, Preprocessed}; +use commit_message_validator::{validate_message, Config, Outcome, Overrides, ValidationError}; + +#[derive(Parser)] +#[command( + name = "commit-message-validator", + version, + about = "Enforce angular commit message convention" +)] +struct Cli { + #[command(flatten)] + opts: CliOptions, + #[command(subcommand)] + command: Command, +} + +#[derive(Args)] +struct CliOptions { + /// Do not require JIRA references. + #[arg(long, global = true)] + no_jira: bool, + /// Allow `fixup!` / `squash!` commits. + #[arg(long, global = true)] + allow_temp: bool, + /// Do not require the reverted sha1 in revert commits. + #[arg(long, global = true)] + no_revert_sha1: bool, + /// Allow the JIRA reference to appear in the header. + #[arg(long, global = true)] + jira_in_header: bool, + /// Maximum header length (default 100). + #[arg(long, global = true)] + header_length: Option, + /// Maximum body line length (default 100). + #[arg(long, global = true)] + body_length: Option, + /// Space-separated commit types that require a JIRA reference (default "feat fix"). + #[arg(long, global = true)] + jira_types: Option, +} + +#[derive(Subcommand)] +enum Command { + /// Validate a single commit message file (commit-msg hook). + Message { file: String }, + /// Validate every commit in a git revision range. + Range { range: String }, +} + +impl CliOptions { + fn to_overrides(&self) -> Overrides { + Overrides { + no_jira: self.no_jira, + allow_temp: self.allow_temp, + no_revert_sha1: self.no_revert_sha1, + jira_in_header: self.jira_in_header, + header_length: self.header_length, + body_length: self.body_length, + jira_types: self.jira_types.clone(), + } + } +} + +fn report(result: Result) -> u8 { + match result { + Ok(Outcome::Valid) => 0, + Ok(Outcome::Temp) => { + println!("ignoring temporary commit"); + 0 + } + Err(e) => { + eprintln!("{e}"); + e.code() as u8 + } + } +} + +fn run_message(file: &str, config: &Config) -> u8 { + let contents = match std::fs::read_to_string(file) { + Ok(c) => c, + Err(e) => { + eprintln!("cannot read commit message file '{file}': {e}"); + return 1; + } + }; + match preprocess_message_file(file, &contents) { + Preprocessed::Skip => 0, + Preprocessed::Message(msg) => report(validate_message(&msg, config)), + } +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let config = Config::resolve(&cli.opts.to_overrides(), |name| std::env::var(name).ok()); + + let code = match &cli.command { + Command::Message { file } => run_message(file, &config), + Command::Range { range } => { + eprintln!("range not yet implemented for '{range}'"); + 1 + } + }; + ExitCode::from(code) } diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..c1d5505 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,99 @@ +use assert_cmd::Command; +use std::io::Write; +use tempfile::NamedTempFile; + +fn write_msg(contents: &str) -> NamedTempFile { + let mut f = NamedTempFile::new().unwrap(); + f.write_all(contents.as_bytes()).unwrap(); + f +} + +#[test] +fn message_accepts_valid_commit() { + let f = write_msg("feat(widget): add a wonderful widget\n"); + Command::cargo_bin("commit-message-validator") + .unwrap() + .args(["message", f.path().to_str().unwrap()]) + .arg("--no-jira") + .assert() + .success(); +} + +#[test] +fn message_rejects_invalid_commit() { + let f = write_msg("this is not valid\n"); + Command::cargo_bin("commit-message-validator") + .unwrap() + .args(["--no-jira", "message", f.path().to_str().unwrap()]) + .assert() + .failure() + .code(2); +} + +#[test] +fn message_skips_merge_commit() { + let f = write_msg("Merge branch 'foo' into 'bar'\n"); + Command::cargo_bin("commit-message-validator") + .unwrap() + .args(["message", f.path().to_str().unwrap()]) + .assert() + .success(); +} + +#[test] +fn message_strips_comments() { + let f = write_msg("# comment\nfeat(scope): valid subject\n"); + Command::cargo_bin("commit-message-validator") + .unwrap() + .args(["--no-jira", "message", f.path().to_str().unwrap()]) + .assert() + .success(); +} + +#[test] +fn jira_types_flag_requires_jira_for_feat_not_fix() { + let feat = write_msg("feat(widget): add a wonderful widget\n"); + Command::cargo_bin("commit-message-validator") + .unwrap() + .args([ + "--jira-types", + "feat", + "message", + feat.path().to_str().unwrap(), + ]) + .assert() + .failure() + .code(9); + + let fix = write_msg("fix(widget): correct a bug\n"); + Command::cargo_bin("commit-message-validator") + .unwrap() + .args([ + "--jira-types", + "feat", + "message", + fix.path().to_str().unwrap(), + ]) + .assert() + .success(); +} + +#[test] +fn no_jira_via_env_var() { + let f = write_msg("feat(widget): add a wonderful widget\n"); + Command::cargo_bin("commit-message-validator") + .unwrap() + .env("COMMIT_VALIDATOR_NO_JIRA", "1") + .args(["message", f.path().to_str().unwrap()]) + .assert() + .success(); +} + +#[test] +fn message_missing_file_errors() { + Command::cargo_bin("commit-message-validator") + .unwrap() + .args(["message", "/no/such/file/xyz"]) + .assert() + .failure(); +} From 96cdfff5eff6206869793f25ba4acffa8ff7e18c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:55:47 +0200 Subject: [PATCH 16/22] feat(cli): add range subcommand via git subprocess --- src/main.rs | 46 ++++++++++++++++++++++++--- tests/cli.rs | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index b83c777..9761ad0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,16 +92,54 @@ fn run_message(file: &str, config: &Config) -> u8 { } } +fn git_output(args: &[&str]) -> Result { + let output = std::process::Command::new("git") + .args(args) + .output() + .map_err(|e| format!("failed to run git: {e}"))?; + if !output.status.success() { + return Err(format!( + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn run_range(range: &str, config: &Config) -> u8 { + let hashes = match git_output(&["log", "--no-merges", "--pretty=%H", "--no-decorate", range]) { + Ok(out) => out, + Err(e) => { + eprintln!("{e}"); + return 1; + } + }; + for hash in hashes.lines() { + let message = match git_output(&["log", "-1", "--pretty=%B", hash]) { + Ok(m) => m, + Err(e) => { + eprintln!("{e}"); + return 1; + } + }; + println!("checking commit {hash}..."); + // %B carries a trailing newline; trim it like bash command substitution. + let code = report(validate_message(message.trim_end_matches('\n'), config)); + if code != 0 { + return code; + } + } + println!("All commits successfully checked"); + 0 +} + fn main() -> ExitCode { let cli = Cli::parse(); let config = Config::resolve(&cli.opts.to_overrides(), |name| std::env::var(name).ok()); let code = match &cli.command { Command::Message { file } => run_message(file, &config), - Command::Range { range } => { - eprintln!("range not yet implemented for '{range}'"); - 1 - } + Command::Range { range } => run_range(range, &config), }; ExitCode::from(code) } diff --git a/tests/cli.rs b/tests/cli.rs index c1d5505..5e44eda 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -97,3 +97,93 @@ fn message_missing_file_errors() { .assert() .failure(); } + +use std::process::Command as StdCommand; + +fn git(repo: &std::path::Path, args: &[&str]) { + let status = StdCommand::new("git") + .current_dir(repo) + .args(args) + .status() + .unwrap(); + assert!(status.success(), "git {args:?} failed"); +} + +fn init_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + git(dir.path(), &["init", "-q"]); + git(dir.path(), &["config", "user.email", "test@test.com"]); + git(dir.path(), &["config", "user.name", "Test"]); + git( + dir.path(), + &[ + "commit", + "--allow-empty", + "-q", + "-m", + "chore(init): initial commit", + ], + ); + dir +} + +#[test] +fn range_accepts_valid_commits() { + let dir = init_repo(); + git( + dir.path(), + &[ + "commit", + "--allow-empty", + "-q", + "-m", + "feat(widget): add widget", + ], + ); + Command::cargo_bin("commit-message-validator") + .unwrap() + .current_dir(dir.path()) + .env("COMMIT_VALIDATOR_NO_JIRA", "1") + .args(["range", "HEAD~1..HEAD"]) + .assert() + .success(); +} + +#[test] +fn range_rejects_invalid_commit() { + let dir = init_repo(); + git( + dir.path(), + &["commit", "--allow-empty", "-q", "-m", "bad commit message"], + ); + Command::cargo_bin("commit-message-validator") + .unwrap() + .current_dir(dir.path()) + .env("COMMIT_VALIDATOR_NO_JIRA", "1") + .args(["range", "HEAD~1..HEAD"]) + .assert() + .failure(); +} + +#[test] +fn range_empty_succeeds() { + let dir = init_repo(); + Command::cargo_bin("commit-message-validator") + .unwrap() + .current_dir(dir.path()) + .env("COMMIT_VALIDATOR_NO_JIRA", "1") + .args(["range", "HEAD..HEAD"]) + .assert() + .success(); +} + +#[test] +fn range_bad_revision_errors() { + let dir = init_repo(); + Command::cargo_bin("commit-message-validator") + .unwrap() + .current_dir(dir.path()) + .args(["range", "not-a-real-ref..HEAD"]) + .assert() + .failure(); +} From 2d50b8c1781bd2c6805de45c47651c49761d6115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 00:58:57 +0200 Subject: [PATCH 17/22] chore(release): build cross-platform binaries on tag --- .github/workflows/release.yml | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7a8fbff --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +--- +name: Release +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + build: + strategy: + matrix: + include: + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-unknown-linux-musl + os: ubuntu-latest + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - name: Install cross-compilation deps (linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y musl-tools + cargo install cross --locked + - name: Build (linux via cross) + if: runner.os == 'Linux' + run: cross build --release --target ${{ matrix.target }} + - name: Build (macos) + if: runner.os == 'macOS' + run: cargo build --release --target ${{ matrix.target }} + - name: Rename artifact + run: | + cp target/${{ matrix.target }}/release/commit-message-validator \ + commit-message-validator-${{ matrix.target }} + - name: Attach to release + uses: softprops/action-gh-release@v2 + with: + files: commit-message-validator-${{ matrix.target }} From dabd18c995d52094bbd3c9c5a6aa7c42e3ac572a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 01:02:18 +0200 Subject: [PATCH 18/22] feat(action): run prebuilt rust binary in composite action --- action.yml | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/action.yml b/action.yml index 984aef6..d5297a5 100644 --- a/action.yml +++ b/action.yml @@ -1,7 +1,7 @@ --- name: 'Commit message validator' description: > - Enforce angular commit message convention with minimal dependency only git and bash. + Enforce angular commit message convention using a prebuilt Rust binary. author: 'Sébastien Boulle' branding: icon: 'check-square' @@ -17,17 +17,21 @@ inputs: description: 'If not empty, reverted sha1 commit is not mandatory in revert commit message.' required: false header_length: - description: 'If not empty, max header_length' + description: 'If not empty, max header length.' required: false body_length: - description: 'If not empty, max body_length' + description: 'If not empty, max body length.' required: false jira_types: - description: 'If not empty, space separated list of types that require Jira refs' + description: 'If not empty, space separated list of types that require Jira refs.' required: false jira_in_header: - description: 'If not empty, allow for jira ref in header' + description: 'If not empty, allow for jira ref in header.' required: false + version: + description: 'Release tag of the validator binary to download (defaults to the action ref).' + required: false + default: '' runs: using: "composite" steps: @@ -39,16 +43,34 @@ runs: run: git fetch origin ${{ github.event.pull_request.head.sha }} shell: bash + - name: Download validator binary + shell: bash + run: | + set -euo pipefail + case "$(uname -s)-$(uname -m)" in + Linux-x86_64) target=x86_64-unknown-linux-musl ;; + Linux-aarch64) target=aarch64-unknown-linux-musl ;; + Darwin-x86_64) target=x86_64-apple-darwin ;; + Darwin-arm64) target=aarch64-apple-darwin ;; + *) echo "unsupported platform: $(uname -s)-$(uname -m)" >&2; exit 1 ;; + esac + version="${{ inputs.version }}" + if [ -z "$version" ]; then version="${{ github.action_ref }}"; fi + url="https://github.com/lumapps/commit-message-validator/releases/download/${version}/commit-message-validator-${target}" + curl -sSfL "$url" -o /tmp/commit-message-validator + chmod +x /tmp/commit-message-validator + - name: Validation run: | - ${{ github.action_path }}/check.sh \ - ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} | tee -a $GITHUB_STEP_SUMMARY + /tmp/commit-message-validator range \ + ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} \ + | tee -a "$GITHUB_STEP_SUMMARY" env: COMMIT_VALIDATOR_NO_JIRA: ${{ inputs.no_jira }} COMMIT_VALIDATOR_ALLOW_TEMP: ${{ inputs.allow_temp }} COMMIT_VALIDATOR_NO_REVERT_SHA1: ${{ inputs.no_revert_sha1 }} + GLOBAL_JIRA_IN_HEADER: ${{ inputs.jira_in_header }} GLOBAL_JIRA_TYPES: ${{ inputs.jira_types }} GLOBAL_MAX_LENGTH: ${{ inputs.header_length }} GLOBAL_BODY_MAX_LENGTH: ${{ inputs.body_length }} - GLOBAL_JIRA_IN_HEADER: ${{ inputs.jira_in_header }} shell: bash From 44b137abd3303b29e7628abde1507f1919404719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 01:05:34 +0200 Subject: [PATCH 19/22] feat(hooks): download prebuilt binary in pre-commit and pre-push --- .pre-commit-hooks.yaml | 3 ++- hooks/commit-message-validator | 22 ++++++++++++++++++++++ pre-push | 16 ++++++---------- 3 files changed, 30 insertions(+), 11 deletions(-) create mode 100755 hooks/commit-message-validator diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 5630da2..3298446 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -2,5 +2,6 @@ - id: commit-message-validator name: Commit Message Validator description: Checks that commit messages are compliant with Lumapps rules. - entry: check_message.sh + entry: hooks/commit-message-validator message language: script + stages: [commit-msg] diff --git a/hooks/commit-message-validator b/hooks/commit-message-validator new file mode 100755 index 0000000..bf5b755 --- /dev/null +++ b/hooks/commit-message-validator @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${COMMIT_VALIDATOR_VERSION:-master}" +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) target=x86_64-unknown-linux-musl ;; + Linux-aarch64) target=aarch64-unknown-linux-musl ;; + Darwin-x86_64) target=x86_64-apple-darwin ;; + Darwin-arm64) target=aarch64-apple-darwin ;; + *) echo "unsupported platform: $(uname -s)-$(uname -m)" >&2; exit 1 ;; +esac + +cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/commit-message-validator/${VERSION}" +bin="${cache_dir}/commit-message-validator" +if [ ! -x "$bin" ]; then + mkdir -p "$cache_dir" + url="https://github.com/lumapps/commit-message-validator/releases/download/${VERSION}/commit-message-validator-${target}" + curl -sSfL "$url" -o "$bin" + chmod +x "$bin" +fi + +exec "$bin" "$@" diff --git a/pre-push b/pre-push index 5178ab4..dc880c9 100755 --- a/pre-push +++ b/pre-push @@ -5,23 +5,19 @@ url="$2" z40=0000000000000000000000000000000000000000 -while read local_ref local_sha remote_ref remote_sha +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" + +while read -r local_ref local_sha remote_ref remote_sha do - if [ "$local_sha" = $z40 ] - then - # Handle delete + if [ "$local_sha" = $z40 ]; then : else - if [ "$remote_sha" = $z40 ] - then - # New branch, examine all commits + if [ "$remote_sha" = $z40 ]; then range="$local_sha" else - # Update to existing branch, examine new commits range="$remote_sha..$local_sha" fi - - bash ./check.sh "$range" + "$DIR/hooks/commit-message-validator" range "$range" || exit $? fi done From c08eb50f972fd2ea510d8da1194ccedde70ce6b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 01:10:10 +0200 Subject: [PATCH 20/22] docs(readme): document rust binary and remove bash implementation --- README.md | 80 ++--- check.bats | 34 --- check.sh | 16 - check_message.bats | 70 ----- check_message.sh | 54 ---- validator.bats | 745 --------------------------------------------- validator.sh | 295 ------------------ 7 files changed, 44 insertions(+), 1250 deletions(-) delete mode 100644 check.bats delete mode 100755 check.sh delete mode 100644 check_message.bats delete mode 100755 check_message.sh delete mode 100644 validator.bats delete mode 100644 validator.sh diff --git a/README.md b/README.md index 6485518..a11b544 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@

Commit message validator

- Enforce angular commit message convention with minimal dependancy only - git and bash. + Enforce angular commit message convention with a single prebuilt Rust + binary (git is still required to read commits).
Explore the docs » @@ -58,7 +58,7 @@ ## About The Project -The provided script enforce Angular commit message convention, with an +The provided binary enforces Angular commit message convention, with an opinionated reduction of allowed types. Moreover, it enforces reference to a project management tools named JIRA. @@ -171,8 +171,7 @@ Thus we won't enforce one or the other, we will only enfore: ### Built With -- [bash](https://www.gnu.org/software/bash/) -- [bats](https://github.com/sstephenson/bats) +- [Rust](https://www.rust-lang.org/) @@ -182,59 +181,68 @@ To get a local copy up and running follow these steps. ### Prerequisites -1. Install bash +- `git`, to read the commits being validated. - ```sh - sudo apt install bash - ``` - -2. Install bats for development testing +### Installation - ```sh - sudo apt install bats - ``` +Download the prebuilt binary for your platform from the +[Releases page](https://github.com/lumapps/commit-message-validator/releases), +one of: -### Installation +- `commit-message-validator-x86_64-unknown-linux-musl` +- `commit-message-validator-aarch64-unknown-linux-musl` +- `commit-message-validator-x86_64-apple-darwin` +- `commit-message-validator-aarch64-apple-darwin` -1. Clone the commit-message-validator +then make it executable and put it on your `PATH`: - ```sh - git clone https://github.com/lumapps/commit-message-validator.git - ``` +```sh +chmod +x commit-message-validator- +mv commit-message-validator- /usr/local/bin/commit-message-validator +``` -That's all, your ready to go ! +Alternatively, use the [pre-commit hook](#add-pre-commit-plugin) or the +[GitHub Action](#getting-started-with-github-action), which download the +binary for you. ## Usage -Check the commit message referenced by \: +Check a single commit message file (used as a `commit-msg` hook): ```sh -./check.sh +commit-message-validator message ``` -Check all the commits between 2 references: +Check all the commits in a range: ```sh -./check.sh .. +commit-message-validator range .. ``` -Behind the hood, the script use `git log` to list all the commit thus any -syntax allowed by git will be working. +Behind the hood, the `range` subcommand uses `git log` to list all the +commits, thus any revision range syntax allowed by git will work. -You can also use the pre-push commit validator, simply copy, `pre-push`, -`validator.sh` and `check.sh` files -in `.git/hooks` directory of your repository. +You can also use the pre-push commit validator: copy the `pre-push` file +and the whole `hooks/` directory into the `.git/hooks` directory of your +repository (the `pre-push` script downloads/invokes the +`hooks/commit-message-validator` shim to validate each pushed commit). ### Command line Options -- if `COMMIT_VALIDATOR_NO_JIRA` environment variable is not empty, - no validation is done on JIRA refs. -- if `COMMIT_VALIDATOR_ALLOW_TEMP` environment variable is not empty, - no validation is done on `fixup!` and `squash!` commits. -- if `COMMIT_VALIDATOR_NO_REVERT_SHA1` environment variable is not empty, - no validation is done revert commits. +| Flag | Environment variable | Default | Description | +| --- | --- | --- | --- | +| `--no-jira` | `COMMIT_VALIDATOR_NO_JIRA` | disabled | No validation is done on JIRA refs. | +| `--allow-temp` | `COMMIT_VALIDATOR_ALLOW_TEMP` | disabled | No validation is done on `fixup!` and `squash!` commits. | +| `--no-revert-sha1` | `COMMIT_VALIDATOR_NO_REVERT_SHA1` | disabled | No validation is done on revert commits. | +| `--jira-in-header` | `GLOBAL_JIRA_IN_HEADER` | disabled | Allow the JIRA reference to appear in the header. | +| `--header-length N` | `GLOBAL_MAX_LENGTH` | `100` | Maximum length of the header line. | +| `--body-length N` | `GLOBAL_BODY_MAX_LENGTH` | `100` | Maximum length of body lines. | +| `--jira-types LIST` | `GLOBAL_JIRA_TYPES` | `"feat fix"` | Space separated list of types requiring a JIRA reference. | + +A CLI flag always takes precedence over its environment variable +equivalent. ### Commit template @@ -352,7 +360,7 @@ learn, inspire, and create. Any contributions you make are **greatly appreciated 1. Fork the Project 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) -4. Run the tests (`bats -j 100 validator.bats`) +4. Run the tests (`cargo test`) 5. Push to the Branch (`git push origin feature/AmazingFeature`) 6. Open a pull request diff --git a/check.bats b/check.bats deleted file mode 100644 index efc48ed..0000000 --- a/check.bats +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bats - -setup() { - REPO=$(mktemp -d) - SCRIPT="$BATS_TEST_DIRNAME/check.sh" - git -C "$REPO" init -q - git -C "$REPO" config user.email "test@test.com" - git -C "$REPO" config user.name "Test" - # base commit to anchor the range - git -C "$REPO" commit --allow-empty -q -m "chore(init): initial commit" - BASE=$(git -C "$REPO" rev-parse HEAD) - export REPO BASE SCRIPT -} - -teardown() { - rm -rf "$REPO" -} - -@test "check: accepts range with valid commits" { - git -C "$REPO" commit --allow-empty -q -m "feat(widget): add widget" - run env COMMIT_VALIDATOR_NO_JIRA=1 bash -c "cd '$REPO' && bash '$SCRIPT' '$BASE..HEAD'" - [ "$status" -eq 0 ] -} - -@test "check: rejects range containing invalid commit" { - git -C "$REPO" commit --allow-empty -q -m "bad commit message" - run env COMMIT_VALIDATOR_NO_JIRA=1 bash -c "cd '$REPO' && bash '$SCRIPT' '$BASE..HEAD'" - [ "$status" -ne 0 ] -} - -@test "check: empty range succeeds" { - run env COMMIT_VALIDATOR_NO_JIRA=1 bash -c "cd '$REPO' && bash '$SCRIPT' '$BASE..$BASE'" - [ "$status" -eq 0 ] -} diff --git a/check.sh b/check.sh deleted file mode 100755 index 0c87e5b..0000000 --- a/check.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -set -eu - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" -source $DIR/validator.sh - -git log --no-merges --pretty="%H" --no-decorate $1 | -while IFS= read -r COMMIT -do - MESSAGE=`git log -1 --pretty='%B' $COMMIT` - echo "checking commit ${COMMIT}..." - validate "$MESSAGE" -done - -echo "All commits successfully checked" diff --git a/check_message.bats b/check_message.bats deleted file mode 100644 index df892cf..0000000 --- a/check_message.bats +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bats - -setup() { - TMPFILE=$(mktemp) - SCRIPT="$BATS_TEST_DIRNAME/check_message.sh" -} - -teardown() { - rm -f "$TMPFILE" -} - -@test "check_message: skips MERGE_MSG path" { - echo "Merge branch 'foo' into 'bar'" > "$TMPFILE" - run bash "$SCRIPT" "/some/path/MERGE_MSG" - [ "$status" -eq 0 ] -} - -@test "check_message: skips message starting with 'merge'" { - echo "Merge branch 'foo' into 'bar'" > "$TMPFILE" - run bash "$SCRIPT" "$TMPFILE" - [ "$status" -eq 0 ] -} - -@test "check_message: skips message starting with 'Merge' (capital)" { - echo "Merge pull request #1" > "$TMPFILE" - run bash "$SCRIPT" "$TMPFILE" - [ "$status" -eq 0 ] -} - -@test "check_message: skips message starting with 'MERGE' (all caps)" { - echo "MERGE branch 'feature' into 'main'" > "$TMPFILE" - run bash "$SCRIPT" "$TMPFILE" - [ "$status" -eq 0 ] -} - -@test "check_message: skips message starting with 'MeRgE' (mixed case)" { - echo "MeRgE branch 'test'" > "$TMPFILE" - run bash "$SCRIPT" "$TMPFILE" - [ "$status" -eq 0 ] -} - -@test "check_message: strips comment lines before validating" { - printf "# This is a comment\nfeat(scope): valid subject\n" > "$TMPFILE" - run bash "$SCRIPT" --no-jira "$TMPFILE" - [ "$status" -eq 0 ] -} - -@test "check_message: accepts valid commit message" { - echo "feat(widget): add a wonderful widget" > "$TMPFILE" - run bash "$SCRIPT" --no-jira "$TMPFILE" - [ "$status" -eq 0 ] -} - -@test "check_message: rejects invalid commit message" { - echo "this is not valid" > "$TMPFILE" - run bash "$SCRIPT" --no-jira "$TMPFILE" - [ "$status" -ne 0 ] -} - -@test "check_message: --jira-types=feat requires JIRA for feat commits" { - echo "feat(widget): add a wonderful widget" > "$TMPFILE" - run bash "$SCRIPT" --jira-types=feat "$TMPFILE" - [ "$status" -ne 0 ] -} - -@test "check_message: --jira-types=feat does not require JIRA for fix commits" { - echo "fix(widget): correct a bug" > "$TMPFILE" - run bash "$SCRIPT" --jira-types=feat "$TMPFILE" - [ "$status" -eq 0 ] -} diff --git a/check_message.sh b/check_message.sh deleted file mode 100755 index a7a5887..0000000 --- a/check_message.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash - -set -eu - -unset COMMIT_VALIDATOR_ALLOW_TEMP COMMIT_VALIDATOR_NO_JIRA COMMIT_VALIDATOR_NO_REVERT_SHA1 GLOBAL_JIRA_IN_HEADER GLOBAL_MAX_LENGTH GLOBAL_BODY_MAX_LENGTH GLOBAL_JIRA_TYPES - -while [[ $# -gt 0 ]]; do - case "$1" in - --no-jira ) COMMIT_VALIDATOR_NO_JIRA=1; shift ;; - --allow-temp ) COMMIT_VALIDATOR_ALLOW_TEMP=1; shift ;; - --no-revert-sha1 ) COMMIT_VALIDATOR_NO_REVERT_SHA1=1; shift ;; - --jira-in-header ) GLOBAL_JIRA_IN_HEADER=1; shift ;; - --header-length=* ) GLOBAL_MAX_LENGTH="${1#*=}"; shift ;; - --header-length ) GLOBAL_MAX_LENGTH="$2"; shift 2 ;; - --body-length=* ) GLOBAL_BODY_MAX_LENGTH="${1#*=}"; shift ;; - --body-length ) GLOBAL_BODY_MAX_LENGTH="$2"; shift 2 ;; - --jira-types=* ) GLOBAL_JIRA_TYPES="${1#*=}"; shift ;; - --jira-types ) GLOBAL_JIRA_TYPES="$2"; shift 2 ;; - -- ) shift; break ;; - * ) break ;; - esac -done - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" -# shellcheck source=validator.sh -source "$DIR/validator.sh" - - -if [[ "$1" == *MERGE_MSG ]] -then - # ignore merge message (merge with --no-ff without conflict) - exit -fi - -# removing comment lines from message -MESSAGE=$(sed '/^#/d' "$1") - -FIRST_WORD=$(echo "${MESSAGE%% *}" | tr '[:upper:]' '[:lower:]') -if [[ "${FIRST_WORD}" == merge ]] -then - # ignore merge commits (merge after conflict resolution) - exit - -fi - -# print message so you don't lose it in case of errors -# (in case you are not using `-m` option) -echo "Options: " -echo " JIRA=${COMMIT_VALIDATOR_NO_JIRA:-}" -echo " TEMP=${COMMIT_VALIDATOR_ALLOW_TEMP:-}" -echo " NO_REVERT_SHA1=${COMMIT_VALIDATOR_NO_REVERT_SHA1:-}" -printf "checking commit message:\n\n#BEGIN#\n%s\n#END#\n\n" "$MESSAGE" - -validate "$MESSAGE" diff --git a/validator.bats b/validator.bats deleted file mode 100644 index 201f382..0000000 --- a/validator.bats +++ /dev/null @@ -1,745 +0,0 @@ -#!/usr/bin/env bats - -source $BATS_TEST_DIRNAME/validator.sh - -@test "structure: one trailing line" { - COMMIT="plop plop -" - run validate_overall_structure "$COMMIT" - [ "$status" -eq $ERROR_STRUCTURE ] -} - -@test "structure: missing empty line after the header" { - COMMIT="plop plop -plop - -plop -" - run validate_overall_structure "$COMMIT" - [ "$status" -eq $ERROR_STRUCTURE ] -} - -@test "structure: missing empty line after the header for jira" { - COMMIT="plop plop -ABC-1234 -" - run validate_overall_structure "$COMMIT" - [ "$status" -eq $ERROR_STRUCTURE ] -} - -@test "structure: missing empty line after the header for broken" { - COMMIT="plop plop -BROKEN: -" - run validate_overall_structure "$COMMIT" - [ "$status" -eq $ERROR_STRUCTURE ] -} - -@test "structure: missing empty line after the body with jira ref" { - COMMIT="plop plop - -plop -plop -plop -plop -LUM-1234 -" - run validate_overall_structure "$COMMIT" - [ "$status" -eq $ERROR_STRUCTURE ] -} - -@test "structure: missing empty line after the body with broken stuff" { - COMMIT="plop plop - -plop -plop -plop -plop -BROKEN: -" - run validate_overall_structure "$COMMIT" - [ "$status" -eq $ERROR_STRUCTURE ] -} - - -@test "structure: empty line after the body" { - COMMIT="plop plop - -plop -plop -plop -plop - -" - run validate_overall_structure "$COMMIT" - [ "$status" -eq $ERROR_STRUCTURE ] -} - -@test "structure: valid commit message with header only" { - COMMIT="plop plop" - - validate_overall_structure "$COMMIT" - [[ $GLOBAL_HEADER == "plop plop" ]] - [[ $GLOBAL_BODY == "" ]] - [[ $GLOBAL_JIRA == "" ]] - [[ $GLOBAL_FOOTER == "" ]] -} - -@test "structure: valid commit message with header and JIRA" { - COMMIT="plop plop - -ABC-1234" - - validate_overall_structure "$COMMIT" - [[ $GLOBAL_HEADER == "plop plop" ]] - [[ $GLOBAL_BODY == "" ]] - [[ $GLOBAL_JIRA == "ABC-1234" ]] - [[ $GLOBAL_FOOTER == "" ]] -} - -@test "structure: valid commit message with JIRA in header" { - COMMIT="feat(abc): ABC-1234 - -plop" - - GLOBAL_JIRA_IN_HEADER="allow" validate_overall_structure "$COMMIT" - [[ $GLOBAL_HEADER == "feat(abc): ABC-1234" ]] - [[ $GLOBAL_JIRA == "ABC-1234" ]] - [[ $GLOBAL_BODY == "plop"$'\n' ]] - [[ $GLOBAL_FOOTER == "" ]] -} - -@test "structure: valid commit message with header and multiple JIRA" { - COMMIT="plop plop - -ABC-1234 DE-1234" - - validate_overall_structure "$COMMIT" - [[ $GLOBAL_HEADER == "plop plop" ]] - [[ $GLOBAL_BODY == "" ]] - [[ $GLOBAL_JIRA == "ABC-1234 DE-1234" ]] - [[ $GLOBAL_FOOTER == "" ]] -} - -@test "structure: valid commit message with header and broken" { - COMMIT="plop plop - -BROKEN: -- plop -- plop" - - validate_overall_structure "$COMMIT" - [[ $GLOBAL_HEADER == "plop plop" ]] - [[ $GLOBAL_BODY == "" ]] - [[ $GLOBAL_JIRA == "" ]] - [[ $GLOBAL_FOOTER == "- plop"$'\n'"- plop"$'\n' ]] -} - -@test "structure: valid commit message with header, jira and broken" { - COMMIT="plop plop - -ABC-1234 -BROKEN: -- plop -- plop" - - validate_overall_structure "$COMMIT" - [[ $GLOBAL_HEADER == "plop plop" ]] - [[ $GLOBAL_BODY == "" ]] - [[ $GLOBAL_JIRA == "ABC-1234" ]] - [[ $GLOBAL_FOOTER == "- plop"$'\n'"- plop"$'\n' ]] -} - -@test "structure: valid commit message with header and body" { - COMMIT="plop plop - -hello" - - validate_overall_structure "$COMMIT" - [[ $GLOBAL_HEADER == "plop plop" ]] - [[ $GLOBAL_BODY == "hello"$'\n' ]] - [[ $GLOBAL_JIRA == "" ]] - [[ $GLOBAL_FOOTER == "" ]] -} - -@test "structure: valid commit message with header and multiline body" { - COMMIT="plop plop - -hello - -plopplop -plopplop - -toto" - - validate_overall_structure "$COMMIT" - [[ $GLOBAL_HEADER == "plop plop" ]] - [[ $GLOBAL_BODY == "hello"$'\n'"plopplop"$'\n'"plopplop"$'\n'"toto"$'\n' ]] - [[ $GLOBAL_JIRA == "" ]] - [[ $GLOBAL_FOOTER == "" ]] -} - -@test "structure: valid commit message with header, multiline body and jira" { - COMMIT="plop plop - -hello - -plopplop -plopplop - -toto - -ABC-1234" - - validate_overall_structure "$COMMIT" - [[ $GLOBAL_HEADER == "plop plop" ]] - [[ $GLOBAL_BODY == "hello"$'\n'"plopplop"$'\n'"plopplop"$'\n'"toto"$'\n' ]] - [[ $GLOBAL_JIRA == "ABC-1234" ]] - [[ $GLOBAL_FOOTER == "" ]] -} - -@test "structure: valid commit message with header, multiline body and broken" { - COMMIT="plop plop - -hello - -plopplop -plopplop - -toto - -BROKEN: -- plop -- plop" - - validate_overall_structure "$COMMIT" - [[ $GLOBAL_HEADER == "plop plop" ]] - [[ $GLOBAL_BODY == "hello"$'\n'"plopplop"$'\n'"plopplop"$'\n'"toto"$'\n' ]] - [[ $GLOBAL_JIRA == "" ]] - [[ $GLOBAL_FOOTER == "- plop"$'\n'"- plop"$'\n' ]] -} - -@test "structure: valid commit message with header, multiline body, jira and broken" { - COMMIT="plop plop - -hello - -plopplop -plopplop - -toto - -ABC-1234 -BROKEN: -- plop -- plop" - - validate_overall_structure "$COMMIT" - [[ $GLOBAL_HEADER == "plop plop" ]] - [[ $GLOBAL_BODY == "hello"$'\n'"plopplop"$'\n'"plopplop"$'\n'"toto"$'\n' ]] - [[ $GLOBAL_JIRA == "ABC-1234" ]] - [[ $GLOBAL_FOOTER == "- plop"$'\n'"- plop"$'\n' ]] -} - -@test "header overall should not allow 'type'" { - run validate_header "type" - [ "$status" -eq $ERROR_HEADER ] -} - -@test "header overall should not allow 'type(scope)'" { - run validate_header "type(scope)" - [ "$status" -eq $ERROR_HEADER ] -} - -@test "header overall should not allow 'type(scope) message'" { - run validate_header "type(scope) message" - [ "$status" -eq $ERROR_HEADER ] -} - -@test "header overall should not allow 'type(scope) : message'" { - run validate_header "type(scope) : message" - [ "$status" -eq $ERROR_HEADER ] -} - -@test "header overall should not allow 'type(scope: message'" { - run validate_header "type(scope: message" - [ "$status" -eq $ERROR_HEADER ] -} - -@test "header overall should not allow 'type scope: message'" { - run validate_header "type scope: message" - [ "$status" -eq $ERROR_HEADER ] -} - -@test "header overall should not allow 'type(scope):message'" { - run validate_header "type(scope):message" - [ "$status" -eq $ERROR_HEADER ] -} - -@test "header overall should allow fixup if env not empty" { - COMMIT_VALIDATOR_ALLOW_TEMP=1 validate_header "fixup! plopezrr" - [[ $GLOBAL_TYPE == "temp" ]] -} - -@test "header overall should allow squash if env not empty" { - COMMIT_VALIDATOR_ALLOW_TEMP=1 validate_header "squash! plopezrr" - [[ $GLOBAL_TYPE == "temp" ]] -} - -@test "header overall should reject fixup if env empty" { - COMMIT_VALIDATOR_ALLOW_TEMP= run validate_header "fixup! plopezrr" - [[ "$status" -eq $ERROR_HEADER ]] -} - -@test "header overall should reject squash if env empty" { - COMMIT_VALIDATOR_ALLOW_TEMP= run validate_header "squash! plopezrr" - [[ "$status" -eq $ERROR_HEADER ]] -} - -@test "header overall should reject fixup if env not set" { - run validate_header "fixup! plopezrr" - [[ "$status" -eq $ERROR_HEADER ]] -} - -@test "header overall should reject squash if env not set" { - run validate_header "squash! plopezrr" - [[ "$status" -eq $ERROR_HEADER ]] -} - -@test "header overall should allow 'revert: type(scope): message'" { - validate_header "revert: type(scope): message" - [[ $GLOBAL_TYPE == "revert" ]] -} - -@test "header overall should allow 'Revert \#type(scope): message\"'" { - validate_header "Revert \"type(scope): message\"" - [[ $GLOBAL_TYPE == "revert" ]] -} - -@test "header overall should allow 'type(scope): message'" { - validate_header "type(scope): message" - [[ $GLOBAL_TYPE == "type" ]] - [[ $GLOBAL_SCOPE == "scope" ]] - [[ $GLOBAL_SUBJECT == "message" ]] -} - -@test "header overall should allow 'type 1(scope 2): message 3'" { - validate_header "type 1(scope 2): message 3" - [[ $GLOBAL_TYPE == "type 1" ]] - [[ $GLOBAL_SCOPE == "scope 2" ]] - [[ $GLOBAL_SUBJECT == "message 3" ]] -} - -@test "header length cannot be more than 100" { - run validate_header_length "01234567890123456789012345678901234567890123456789012345678901234567891234567890123456789012345678901" - [ "$status" -eq $ERROR_HEADER_LENGTH ] -} - -@test "header length cannot be more than 100 with spaces" { - run validate_header_length "012345678 012345678 012345678 012345678 012345678 012345678 012345678 123456789 0123456789 0123456789" - [ "$status" -eq $ERROR_HEADER_LENGTH ] -} - -@test "header length cannot be more than 150 with spaces. overriden" { - GLOBAL_MAX_LENGTH=150 validate_header_length "012345678 012345678 012345678 012345678 012345678 012345678 012345678 1" -} - -@test "header length can be 100" { - run validate_header_length "0123456789012345678901234567890123456789012345678901234567890123456789123456789012345678901234567890" - [ "$status" -eq 0 ] -} - -@test "header length can be 100 with spaces" { - run validate_header_length "012345678 012345678 012345678 012345678 012345678 012345678 012345678 123456789 0123456789 01234567 " - [ "$status" -eq 0 ] -} - -@test "unknown commit type 'plop' should be rejected" { - run validate_type "plop" - [ "$status" -eq $ERROR_TYPE ] -} - -@test "known commit type with trailing space should be rejected" { - run validate_type "feat " - [ "$status" -eq $ERROR_TYPE ] -} - -@test "known commit type with capitalized letter should be rejected" { - run validate_type "Feat" - [ "$status" -eq $ERROR_TYPE ] -} - -@test "commit type 'feat' should be ok" { - run validate_type "feat" - [ "$status" -eq 0 ] -} - -@test "scope cannot be empty" { - run validate_scope "" - [ "$status" -eq $ERROR_SCOPE ] -} - -@test "scope cannot be capitalized" { - run validate_scope "plopPlop" - [ "$status" -eq $ERROR_SCOPE ] -} - -@test "scope cannot have space" { - run validate_scope "plop plop" - [ "$status" -eq $ERROR_SCOPE ] -} - -@test "scope cannot have trailing space" { - run validate_scope "plop " - [ "$status" -eq $ERROR_SCOPE ] -} - -@test "scope should allow simple kebab-case" { - run validate_scope "p2" - [ "$status" -eq 0 ] -} - -@test "scope should allow kebab-case" { - run validate_scope "pl2op-plop1-plop-0001" - [ "$status" -eq 0 ] -} - -@test "subject cannot be empty" { - run validate_subject "" - [ "$status" -eq $ERROR_SUBJECT ] -} - -@test "subject cannot have trailing space" { - run validate_subject "plop " - [ "$status" -eq $ERROR_SUBJECT ] -} - -@test "subject cannot end with a point" { - run validate_subject "plop." - [ "$status" -eq $ERROR_SUBJECT ] -} - -@test "subject should allow sentences" { - run validate_subject "0002 dedezf ef zefzef zfeze fzef zf zef" - [ "$status" -eq 0 ] -} - -@test "body with 101 line length should be rejected" { - MESSAGE=' -12345678 012345678 012345678 012345678 012345678 012345678 012345678 012345678 012345678 012345678 01 - -LUM-2345' - - run validate_body_length "$MESSAGE" - [[ "$status" -eq $ERROR_BODY_LENGTH ]] -} - -@test "body with 100 line length should be valid" { - MESSAGE=' -1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 -12345678 012345678 012345678 012345678 012345678 012345678 012345678 012345678 012345678 012345678 0 - -1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 - -LUM-2345' - - run validate_body_length "$MESSAGE" - [[ "$status" -eq 0 ]] -} - -@test "body with 100+ line length should be valid if no space" { - MESSAGE=' -0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_ - -LUM-2345' - - run validate_body_length "$MESSAGE" - [[ "$status" -eq 0 ]] -} - -@test "body length cannot be more than 150 with spaces. overridden" { - GLOBAL_BODY_MAX_LENGTH=150 validate_body_length "012345678 012345678 012345678 012345678 012345678 012345678 012345678 1" -} - -@test "body with trailing space on line should not be valid" { - MESSAGE='pdzofjzf ' - - run validate_trailing_space "$MESSAGE" - [[ "$status" -eq $ERROR_TRAILING_SPACE ]] -} - -@test "body with trailing space on new line should not be valid" { - MESSAGE=' -rerer - - -LUM-2345' - - run validate_trailing_space "$MESSAGE" - [[ "$status" -eq $ERROR_TRAILING_SPACE ]] -} - -@test "body without trailing space should be valid" { - MESSAGE=' -rerer - - -LUM-2345' - - run validate_trailing_space "$MESSAGE" - [[ "$status" -eq 0 ]] -} - -@test "revert body without commit sha1 should be refused" { - MESSAGE='rerer - -LUM-2345' - - run validate_revert "$MESSAGE" - [[ "$status" -eq $ERROR_REVERT ]] - COMMIT_VALIDATOR_NO_REVERT_SHA1= run validate_revert "$MESSAGE" - [[ "$status" -eq $ERROR_REVERT ]] -} - -@test "revert body with commit sha1 should be valid" { - MESSAGE='rerer - -This reverts commit 1234567890. - -LUM-2345' - - run validate_revert "$MESSAGE" - [[ "$status" -eq 0 ]] - COMMIT_VALIDATOR_NO_REVERT_SHA1= run validate_revert "$MESSAGE" - [[ "$status" -eq 0 ]] -} - -@test "revert body without sha1 should be valid with the flag" { - MESSAGE='rerer - -LUM-2345' - - COMMIT_VALIDATOR_NO_REVERT_SHA1=1 run validate_revert "$MESSAGE" - [[ "$status" -eq 0 ]] -} - -@test "features and fixes commits need jira reference" { - need_jira "feat" - need_jira "fix" -} - -@test "features and fixes commits need jira reference if env empty" { - COMMIT_VALIDATOR_NO_JIRA= need_jira "feat" - COMMIT_VALIDATOR_NO_JIRA= need_jira "fix" -} - -@test "features and fixes commits don't need jira reference if env non empty" { - ! COMMIT_VALIDATOR_NO_JIRA=1 need_jira "feat" - ! COMMIT_VALIDATOR_NO_JIRA=1 need_jira "fix" -} - -@test "other commits don't need jira reference" { - ! need_jira "docs" - ! need_jira "test" -} - -@test "feat without jira ref should be rejected" { - run validate_jira "feat" "" - [[ "$status" -eq $ERROR_JIRA ]] -} - -@test "lint without jira ref should be validated" { - run validate_jira "lint" "" - [[ "$status" -eq 0 ]] -} - -@test "feat with jira ref should be validated" { - run validate_jira "feat" "ABC-123" - [[ "$status" -eq 0 ]] -} - -@test "feat with short jira ref should be validated" { - run validate_jira "feat" "AB-123" - [[ "$status" -eq 0 ]] -} - -@test "overall validation invalid structure" { - MESSAGE='plop -plop' - - run validate "$MESSAGE" - [[ "$status" -eq $ERROR_STRUCTURE ]] -} - -@test "overall validation invalid header" { - MESSAGE='plop' - - run validate "$MESSAGE" - [[ "$status" -eq $ERROR_HEADER ]] -} - -@test "overall validation invalid header length" { - MESSAGE='feat(plop): 012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789' - - run validate "$MESSAGE" - [[ "$status" -eq $ERROR_HEADER_LENGTH ]] -} - -@test "overall validation invalid type" { - MESSAGE='Feat(scope1): subject - -Commit about stuff\"plop \" - -LUM-2345' - - run validate "$MESSAGE" - [[ "$status" -eq $ERROR_TYPE ]] -} - -@test "overall validation invalid scope" { - MESSAGE='feat(scope 1): subject - -Commit about stuff\"plop \" - -LUM-2345' - - run validate "$MESSAGE" - [[ "$status" -eq $ERROR_SCOPE ]] -} - -@test "overall validation invalid subject" { - MESSAGE='feat(scope1): Subject - -Commit about stuff\"plop \" - -LUM-2345' - - run validate "$MESSAGE" - [[ "$status" -ne $ERROR_SUBJECT ]] -} - -@test "overall validation invalid body length" { - MESSAGE='feat(scope1): subject - -1 2 3 4 5678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901 - -LUM-2345' - - run validate "$MESSAGE" - [[ "$status" -eq $ERROR_BODY_LENGTH ]] -} - -@test "overall validation invalid body trailing space" { - MESSAGE='chore(scope1): subject - -123456789012345678901234567890123456789012 ' - - run validate "$MESSAGE" - [[ "$status" -eq $ERROR_TRAILING_SPACE ]] -} - -@test "overall validation invalid footer length" { - MESSAGE='feat(scope1): subject - -plop - -LUM-2345 -BROKEN: -- 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901' - - run validate "$MESSAGE" - [[ "$status" -eq $ERROR_BODY_LENGTH ]] -} - -@test "overall validation invalid footer trailing space" { - MESSAGE='feat(scope1): subject - -plop - -LUM-2345 -BROKEN: -- 123456 ' - - run validate "$MESSAGE" - [[ "$status" -eq $ERROR_TRAILING_SPACE ]] -} - -@test "overall validation missing jira" { - MESSAGE='feat(scope1): subject - -Commit about stuff\"plop \" - -2345' - - run validate "$MESSAGE" - [[ "$status" -eq $ERROR_JIRA ]] -} - -@test "overall validation" { - MESSAGE='feat(scope1): subject - -Commit about stuff\"plop \" dezd - -12345678901234567890123456789012345678901234567890 -12345678901234567890123456789012345678901234567890 - -LUM-2345 -BROKEN: -- plop -- plop' - - run validate "$MESSAGE" - [[ "$status" -eq 0 ]] -} - -@test "overall revert validation" { - MESSAGE='Revert "feat(scope1): subject" - -This reverts commit 12345678900. -Commit about stuff\"plop \" dezd - -12345678901234567890123456789012345678901234567890 -12345678901234567890123456789012345678901234567890 - -LUM-2345 -BROKEN: -- plop -- plop' - - run validate "$MESSAGE" - [[ "$status" -eq 0 ]] -} - -@test "overall fixup! validation" { - MESSAGE='fixup! plepozkfopezr - -Commit about stuff\"plop \" dezd - -12345678901234567890123456789012345678901234567890 -12345678901234567890123456789012345678901234567890 - -LUM-2345 -BROKEN: -- plop -- plop' - - COMMIT_VALIDATOR_ALLOW_TEMP=1 run validate "$MESSAGE" - [[ "$status" -eq 0 ]] -} - -@test "overall fixup! rejection" { - MESSAGE='fixup! plepozkfopezr - -Commit about stuff\"plop \" dezd - -12345678901234567890123456789012345678901234567890 -12345678901234567890123456789012345678901234567890 - -LUM-2345 -BROKEN: -- plop -- plop' - - run validate "$MESSAGE" - [[ "$status" -eq $ERROR_HEADER ]] -} diff --git a/validator.sh b/validator.sh deleted file mode 100644 index 56931fa..0000000 --- a/validator.sh +++ /dev/null @@ -1,295 +0,0 @@ -readonly HEADER_PATTERN="^([^\(]+)\(([^\)]+)\): (.+)$" -readonly TYPE_PATTERN="^(feat|fix|docs|gen|lint|refactor|test|chore)$" -readonly SCOPE_PATTERN="^([a-z][a-z0-9]*)(-[a-z0-9]+)*$" -readonly SUBJECT_PATTERN="^([A-Za-z0-9].*[^ ^\.])$" -readonly JIRA_PATTERN="[A-Z]{2,7}[0-9]{0,6}-[0-9]{1,6}" -readonly JIRA_FOOTER_PATTERN="^(${JIRA_PATTERN} ?)+$" -readonly JIRA_HEADER_PATTERN="^.*[^A-Z](${JIRA_PATTERN}).*$" -readonly BROKE_PATTERN="^BROKEN:$" -readonly TRAILING_SPACE_PATTERN=" +$" -readonly REVERT_HEADER_PATTERN="^[Rr](evert|eapply)[: ].*$" -readonly REVERT_COMMIT_PATTERN="^This reverts commit ([a-f0-9]+)" -readonly TEMP_HEADER_PATTERN="^(fixup!|squash!).*$" - -readonly ERROR_STRUCTURE=1 -readonly ERROR_HEADER=2 -readonly ERROR_HEADER_LENGTH=3 -readonly ERROR_TYPE=4 -readonly ERROR_SCOPE=5 -readonly ERROR_SUBJECT=6 -readonly ERROR_BODY_LENGTH=7 -readonly ERROR_TRAILING_SPACE=8 -readonly ERROR_JIRA=9 -readonly ERROR_REVERT=10 - -GLOBAL_HEADER="" -GLOBAL_BODY="" -GLOBAL_JIRA="" -GLOBAL_FOOTER="" - -# Overridable variables -GLOBAL_JIRA_TYPES="${GLOBAL_JIRA_TYPES:-feat fix}" -GLOBAL_MAX_LENGTH="${GLOBAL_MAX_LENGTH:-100}" -GLOBAL_BODY_MAX_LENGTH="${GLOBAL_BODY_MAX_LENGTH:-100}" -GLOBAL_JIRA_IN_HEADER="${GLOBAL_JIRA_IN_HEADER:-}" - -GLOBAL_TYPE="" -GLOBAL_SCOPE="" -GLOBAL_SUBJECT="" - -validate_overall_structure() { - local MESSAGE="$1" - - local WAITING_HEADER=0 - local WAITING_EMPTY=1 - local START_TEXT=2 - local READING_BODY=3 - local READING_BROKEN=4 - local READING_FOOTER=5 - - local STATE="$WAITING_HEADER" - - while IFS= read -r LINE ; do - - if [[ $STATE -eq $WAITING_HEADER ]]; then - GLOBAL_HEADER="$LINE" - STATE="$WAITING_EMPTY" - if [[ -n "${GLOBAL_JIRA_IN_HEADER:-}" ]] && [[ $LINE =~ $JIRA_HEADER_PATTERN ]]; then - GLOBAL_JIRA=${BASH_REMATCH[1]} - fi - - elif [[ $STATE -eq $WAITING_EMPTY ]]; then - if [[ $LINE != "" ]]; then - echo -e "missing empty line in commit message between header and body or body and footer" - exit $ERROR_STRUCTURE - fi - STATE="$START_TEXT" - - elif [[ $STATE -eq $START_TEXT ]]; then - if [[ $LINE = "" ]]; then - echo -e "double empty line is not allowed" - exit $ERROR_STRUCTURE - fi - - if [[ $LINE =~ $BROKE_PATTERN ]]; then - STATE="$READING_FOOTER" - elif [[ $LINE =~ $JIRA_FOOTER_PATTERN ]]; then - STATE="$READING_BROKEN" - GLOBAL_JIRA=${BASH_REMATCH[0]} - else - STATE="$READING_BODY" - GLOBAL_BODY=$GLOBAL_BODY$LINE$'\n' - fi - - elif [[ $STATE -eq $READING_BODY ]]; then - if [[ $LINE =~ $BROKE_PATTERN ]]; then - echo -e "missing empty line before broke part" - exit $ERROR_STRUCTURE - fi - - if [[ $LINE =~ $JIRA_FOOTER_PATTERN ]]; then - echo -e "missing empty line before JIRA reference" - exit $ERROR_STRUCTURE - fi - - if [[ $LINE = "" ]]; then - STATE=$START_TEXT - else - GLOBAL_BODY=$GLOBAL_BODY$LINE$'\n' - fi - - elif [[ $STATE -eq $READING_BROKEN ]]; then - if [[ $LINE =~ $BROKE_PATTERN ]]; then - STATE="$READING_FOOTER" - else - echo -e "only broken part could be after the JIRA reference" - exit $ERROR_STRUCTURE - fi - - elif [[ $STATE -eq $READING_FOOTER ]]; then - if [[ $LINE = "" ]]; then - echo -e "no empty line allowed in broken part" - exit $ERROR_STRUCTURE - fi - - GLOBAL_FOOTER=$GLOBAL_FOOTER$LINE$'\n' - - else - echo -e "unknown state in parsing machine" - exit $ERROR_STRUCTURE - fi - - done <<< "$MESSAGE" - - if [[ $STATE -eq $START_TEXT ]]; then - echo -e "new line at the end of the commit is not allowed" - exit $ERROR_STRUCTURE - fi -} - -validate_header() { - local HEADER="$1" - - if [[ ! -z "${COMMIT_VALIDATOR_ALLOW_TEMP:-}" && $HEADER =~ $TEMP_HEADER_PATTERN ]]; then - GLOBAL_TYPE="temp" - elif [[ $HEADER =~ $REVERT_HEADER_PATTERN ]]; then - GLOBAL_TYPE="revert" - elif [[ $HEADER =~ $HEADER_PATTERN ]]; then - GLOBAL_TYPE=${BASH_REMATCH[1]} - GLOBAL_SCOPE=${BASH_REMATCH[2]} - GLOBAL_SUBJECT=${BASH_REMATCH[3]} - else - echo -e "commit header doesn't match overall header pattern: 'type(scope): message'" - exit $ERROR_HEADER - fi -} - -validate_header_length() { - local HEADER="$1" - if [[ ${#HEADER} -gt ${GLOBAL_MAX_LENGTH} ]]; then - echo -e "commit header length is more than ${GLOBAL_MAX_LENGTH} characters" - exit $ERROR_HEADER_LENGTH - fi -} - -validate_type() { - local TYPE=$1 - - if [[ ! $TYPE =~ $TYPE_PATTERN ]]; then - echo -e "commit type '$TYPE' is unknown" - exit $ERROR_TYPE - fi -} - -validate_scope() { - local SCOPE=$1 - - if [[ ! $SCOPE =~ $SCOPE_PATTERN ]]; then - echo -e "commit scope '$SCOPE' is not kebab-case" - exit $ERROR_SCOPE - fi -} - -validate_subject() { - local SUBJECT=$1 - - if [[ ! $SUBJECT =~ $SUBJECT_PATTERN ]]; then - echo -e "commit subject '$SUBJECT' should not end with a '.'" - exit $ERROR_SUBJECT - fi -} - -validate_body_length() { - local BODY=$1 - local LINE="" - - while IFS= read -r LINE ; - do - # Skip lines with no spaces as they can't be split - [[ ! "$LINE" =~ [[:space:]] ]] && continue - - if [[ "${#LINE}" -gt ${GLOBAL_BODY_MAX_LENGTH} ]]; then - echo -e "body message line length is more than ${GLOBAL_BODY_MAX_LENGTH} characters" - exit $ERROR_BODY_LENGTH - fi - done <<< "$BODY" -} - -validate_trailing_space() { - local BODY=$1 - local LINE="" - - while IFS= read -r LINE ; - do - if [[ $LINE =~ $TRAILING_SPACE_PATTERN ]]; then - echo -e "body message must not have trailing spaces" - exit $ERROR_TRAILING_SPACE - fi - done <<< "$BODY" -} - -need_jira() { - local TYPE=$1 - - if [[ ! -z "${COMMIT_VALIDATOR_NO_JIRA:-}" ]]; then - return 1 - else - for type in ${GLOBAL_JIRA_TYPES}; do - if [[ "${TYPE}" == "${type}" ]]; then - return 0 - fi - done - return 1 - fi -} - -validate_jira() { - local TYPE=$1 - local JIRA=$2 - - - - if need_jira "$TYPE" && [[ -z "${JIRA:-}" ]]; then - echo -e "commits with type '${TYPE}' need to include a reference to a JIRA ticket, by adding the project prefix and the issue number to the commit message, this could be done easily with: git commit -m 'feat(widget): add a wonderful widget' -m LUM-1234" - exit $ERROR_JIRA - fi -} - -validate_revert() { - local BODY=$1 - local LINE="" - local REVERTED_COMMIT="" - - if [[ ! -z "${COMMIT_VALIDATOR_NO_REVERT_SHA1:-}" ]]; then - exit 0 - fi - - while IFS= read -r LINE ; - do - if [[ $LINE =~ $REVERT_COMMIT_PATTERN ]]; then - REVERTED_COMMIT=${BASH_REMATCH[1]} - fi - done <<< "$BODY" - - if [[ "$REVERTED_COMMIT" = "" ]]; then - echo -e "revert commit should contain the reverted sha1" - exit $ERROR_REVERT - fi -} - -validate() { - local COMMIT_MSG="$1" - - validate_overall_structure "$COMMIT_MSG" - - local HEADER="$GLOBAL_HEADER" - local BODY="$GLOBAL_BODY" - local JIRA="$GLOBAL_JIRA" - local FOOTER="$GLOBAL_FOOTER" - - validate_header "$HEADER" - - local TYPE="$GLOBAL_TYPE" - local SCOPE="$GLOBAL_SCOPE" - local SUBJECT="$GLOBAL_SUBJECT" - - if [[ $TYPE = "temp" ]]; then - echo "ignoring temporary commit" - elif [[ $TYPE = "revert" ]]; then - validate_revert "$BODY" - else - validate_header_length "$HEADER" - - validate_type "$TYPE" - validate_scope "$SCOPE" - validate_subject "$SUBJECT" - - validate_body_length "$BODY" - validate_body_length "$FOOTER" - - validate_trailing_space "$BODY" - validate_trailing_space "$FOOTER" - - validate_jira "$TYPE" "$JIRA" - fi -} From b96c6d4397ae9829b36de6767ffe7628d29bcbb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 01:26:17 +0200 Subject: [PATCH 21/22] fix(validator): correct preprocess trailing-newline and download/summary handling --- action.yml | 2 +- hooks/commit-message-validator | 13 ++++++++++--- src/preprocess.rs | 15 ++++++++++++++- tests/cli.rs | 12 ++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/action.yml b/action.yml index d5297a5..7f519bd 100644 --- a/action.yml +++ b/action.yml @@ -64,7 +64,7 @@ runs: run: | /tmp/commit-message-validator range \ ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} \ - | tee -a "$GITHUB_STEP_SUMMARY" + 2>&1 | tee -a "$GITHUB_STEP_SUMMARY" env: COMMIT_VALIDATOR_NO_JIRA: ${{ inputs.no_jira }} COMMIT_VALIDATOR_ALLOW_TEMP: ${{ inputs.allow_temp }} diff --git a/hooks/commit-message-validator b/hooks/commit-message-validator index bf5b755..b20d9e7 100755 --- a/hooks/commit-message-validator +++ b/hooks/commit-message-validator @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -VERSION="${COMMIT_VALIDATOR_VERSION:-master}" +VERSION="${COMMIT_VALIDATOR_VERSION:-}" case "$(uname -s)-$(uname -m)" in Linux-x86_64) target=x86_64-unknown-linux-musl ;; Linux-aarch64) target=aarch64-unknown-linux-musl ;; @@ -10,11 +10,18 @@ case "$(uname -s)-$(uname -m)" in *) echo "unsupported platform: $(uname -s)-$(uname -m)" >&2; exit 1 ;; esac -cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/commit-message-validator/${VERSION}" +if [ -n "$VERSION" ]; then + cache_key="$VERSION" + url="https://github.com/lumapps/commit-message-validator/releases/download/${VERSION}/commit-message-validator-${target}" +else + cache_key="latest" + url="https://github.com/lumapps/commit-message-validator/releases/latest/download/commit-message-validator-${target}" +fi + +cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/commit-message-validator/${cache_key}" bin="${cache_dir}/commit-message-validator" if [ ! -x "$bin" ]; then mkdir -p "$cache_dir" - url="https://github.com/lumapps/commit-message-validator/releases/download/${VERSION}/commit-message-validator-${target}" curl -sSfL "$url" -o "$bin" chmod +x "$bin" fi diff --git a/src/preprocess.rs b/src/preprocess.rs index adb4148..5f3f187 100644 --- a/src/preprocess.rs +++ b/src/preprocess.rs @@ -21,7 +21,8 @@ pub fn preprocess_message_file(path: &str, contents: &str) -> Preprocessed { return Preprocessed::Skip; } - Preprocessed::Message(stripped) + // Mirror command substitution's stripping of all trailing newlines. + Preprocessed::Message(stripped.trim_end_matches('\n').to_string()) } #[cfg(test)] @@ -71,4 +72,16 @@ mod tests { Preprocessed::Skip => panic!("should not skip"), } } + + #[test] + fn strips_trailing_blank_lines_before_comment_block() { + // realistic commit-msg file: blank line + comment block get stripped, no trailing newline remains + match preprocess_message_file( + "/x/COMMIT_EDITMSG", + "feat(x): y\n\nbody\n\n# Please enter the commit message\n", + ) { + Preprocessed::Message(m) => assert_eq!(m, "feat(x): y\n\nbody"), + Preprocessed::Skip => panic!("should not skip"), + } + } } diff --git a/tests/cli.rs b/tests/cli.rs index 5e44eda..98c28d1 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -89,6 +89,18 @@ fn no_jira_via_env_var() { .success(); } +#[test] +fn message_accepts_realistic_commit_editmsg_with_comment_block() { + let f = write_msg( + "feat(scope): valid subject\n\nsome body text\n\n# Please enter the commit message for your changes.\n# Lines starting with '#' will be ignored.\n", + ); + Command::cargo_bin("commit-message-validator") + .unwrap() + .args(["--no-jira", "message", f.path().to_str().unwrap()]) + .assert() + .success(); +} + #[test] fn message_missing_file_errors() { Command::cargo_bin("commit-message-validator") From f8ae410d4715178e2679ab2cfd7477aaf6fb2119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Boulle?= Date: Fri, 3 Jul 2026 01:31:59 +0200 Subject: [PATCH 22/22] refactor(config): use is_some_and for env flag resolution --- src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index ce90a89..1bdf8d2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,7 +43,7 @@ fn resolve_usize(cli: Option, env: Option, default: usize) -> usi impl Config { pub fn resolve(o: &Overrides, env: impl Fn(&str) -> Option) -> Config { - let flag = |cli: bool, name: &str| cli || env(name).map(|v| !v.is_empty()).unwrap_or(false); + let flag = |cli: bool, name: &str| cli || env(name).is_some_and(|v| !v.is_empty()); let default = Config::default(); let jira_types = o .jira_types