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/.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 }}
diff --git a/.gitignore b/.gitignore
index 5ceb386..c031de5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
venv
+/target
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/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/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/action.yml b/action.yml
index 984aef6..7f519bd 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 }} \
+ 2>&1 | 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
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/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%.
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..9c97271
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-03-rust-migration-design.md
@@ -0,0 +1,178 @@
+# 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
+
+- **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: 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.
+- **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).
+
+## 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).
+- **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
+
+- **CI workflow (GitHub Actions, on push/PR):** `cargo fmt --check`,
+ `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
+ 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. 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`.
+
+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.
diff --git a/hooks/commit-message-validator b/hooks/commit-message-validator
new file mode 100755
index 0000000..b20d9e7
--- /dev/null
+++ b/hooks/commit-message-validator
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+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 ;;
+ 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
+
+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"
+ 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
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/config.rs b/src/config.rs
new file mode 100644
index 0000000..1bdf8d2
--- /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).is_some_and(|v| !v.is_empty());
+ 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/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
new file mode 100644
index 0000000..b214da2
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,142 @@
+pub mod config;
+pub mod error;
+mod parser;
+mod patterns;
+pub mod preprocess;
+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);
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..9761ad0
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,145 @@
+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 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 } => run_range(range, &config),
+ };
+ ExitCode::from(code)
+}
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);
+ }
+}
diff --git a/src/patterns.rs b/src/patterns.rs
new file mode 100644
index 0000000..53ff0b0
--- /dev/null
+++ b/src/patterns.rs
@@ -0,0 +1,94 @@
+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());
+
+#[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"));
+ }
+}
diff --git a/src/preprocess.rs b/src/preprocess.rs
new file mode 100644
index 0000000..5f3f187
--- /dev/null
+++ b/src/preprocess.rs
@@ -0,0 +1,87 @@
+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;
+ }
+
+ // Mirror command substitution's stripping of all trailing newlines.
+ Preprocessed::Message(stripped.trim_end_matches('\n').to_string())
+}
+
+#[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"),
+ }
+ }
+
+ #[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/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());
+ }
+}
diff --git a/tests/cli.rs b/tests/cli.rs
new file mode 100644
index 0000000..98c28d1
--- /dev/null
+++ b/tests/cli.rs
@@ -0,0 +1,201 @@
+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_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")
+ .unwrap()
+ .args(["message", "/no/such/file/xyz"])
+ .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();
+}
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
-}