├── .dockerignore ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yaml └── workflows │ ├── bump.yaml │ ├── ci.yaml │ ├── lint.yaml │ └── release.yaml ├── .gitignore ├── .pre-commit-hooks.yaml ├── .vscode ├── extensions.json └── launch.json ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── cli ├── .editorconfig ├── Cargo.toml ├── e2e │ ├── cli.bats │ └── default.bats ├── examples │ └── .commitlint.yaml └── src │ ├── args.rs │ ├── config.rs │ ├── git.rs │ ├── lib.rs │ ├── main.rs │ ├── message.rs │ ├── result.rs │ ├── rule.rs │ └── rule │ ├── body_empty.rs │ ├── body_max_length.rs │ ├── description_empty.rs │ ├── description_format.rs │ ├── description_max_length.rs │ ├── footers_empty.rs │ ├── scope.rs │ ├── scope_empty.rs │ ├── scope_format.rs │ ├── scope_max_length.rs │ ├── subject_empty.rs │ ├── type.rs │ ├── type_empty.rs │ ├── type_format.rs │ └── type_max_length.rs ├── schema ├── Cargo.toml └── src │ └── main.rs └── web ├── .gitignore ├── .vscode ├── extensions.json └── launch.json ├── astro.config.mjs ├── package-lock.json ├── package.json ├── public └── favicon.svg ├── src ├── assets │ └── checker.png ├── content │ ├── config.ts │ └── docs │ │ ├── config │ │ ├── IDE.md │ │ ├── configuration.md │ │ └── default.md │ │ ├── index.mdx │ │ ├── license.md │ │ ├── rules │ │ ├── body-empty.md │ │ ├── body-max-length.md │ │ ├── description-empty.md │ │ ├── description-format.md │ │ ├── description-max-length.md │ │ ├── footers-empty.md │ │ ├── scope-empty.md │ │ ├── scope-format.md │ │ ├── scope-max-length.md │ │ ├── scope.md │ │ ├── subject-empty.md │ │ ├── type-empty.md │ │ ├── type-format.md │ │ ├── type-max-length.md │ │ └── type.md │ │ └── setup │ │ ├── debug.md │ │ ├── install.md │ │ └── motivation.md └── env.d.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | # Allowlist not to add unnecessary files to the repository 2 | 3 | * 4 | 5 | !/Cargo.lock 6 | !/Cargo.toml 7 | !/cli 8 | !/schema 9 | !/LICENSE-APACHE 10 | !/LICENSE-MIT 11 | !/README.md 12 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @KeisukeYamashita 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | ## Config 15 | 16 | If you have any config file, please paste it below: 17 | 18 | ## Error message 19 | 20 | Please paste the error message below: 21 | 22 | 23 | ## Expected behavior 24 | 25 | A clear and concise description of what you expected to happen. 26 | 27 | ## Version 28 | 29 | ```console 30 | # Run the following command 31 | $ commitlint --version 32 | PASTE_HERE 33 | ``` 34 | 35 | ## Additional context 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the feature** 11 | 12 | A clear and concise description the feature request. 13 | 14 | ## How to run 15 | 16 | If you plan to add a new command, flag or option, please describe how you would like to run it. 17 | 18 | ```console 19 | 20 | ``` 21 | 22 | ## Expected behavior 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | ## Additional context 27 | 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Why 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for Rust (Cargo) 4 | - package-ecosystem: "cargo" 5 | commit-message: 6 | include: scope 7 | prefix: bump 8 | directory: "/" 9 | schedule: 10 | interval: "monthly" 11 | open-pull-requests-limit: 3 12 | 13 | # Maintain dependencies for Docker 14 | - package-ecosystem: "docker" 15 | commit-message: 16 | include: scope 17 | prefix: bump 18 | directory: "/" 19 | schedule: 20 | interval: "monthly" 21 | open-pull-requests-limit: 3 22 | 23 | # Maintain dependencies for GitHub Action 24 | - package-ecosystem: "github-actions" 25 | directory: "/" 26 | schedule: 27 | interval: "monthly" 28 | open-pull-requests-limit: 3 29 | 30 | # Maintain dependencies for npm 31 | - package-ecosystem: "npm" 32 | commit-message: 33 | include: scope 34 | prefix: bump 35 | directory: "/web" 36 | schedule: 37 | interval: "monthly" 38 | open-pull-requests-limit: 3 39 | groups: 40 | npm-development: 41 | dependency-type: development 42 | update-types: 43 | - minor 44 | - patch 45 | npm-production: 46 | dependency-type: production 47 | update-types: 48 | - patch 49 | -------------------------------------------------------------------------------- /.github/workflows/bump.yaml: -------------------------------------------------------------------------------- 1 | name: Bump 2 | on: 3 | release: 4 | types: 5 | - published 6 | 7 | permissions: 8 | contents: write # To checkout and create PRs 9 | pull-requests: write # To comment to PRs 10 | 11 | jobs: 12 | bump: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - id: bump 18 | uses: tj-actions/cargo-bump@v3 19 | 20 | - uses: peter-evans/create-pull-request@v7 21 | with: 22 | author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 23 | branch: "bump-${{ steps.bump.outputs.new_version }}" 24 | commit-message: "bump(cli): bump version to ${{ steps.bump.outputs.new_version }}" 25 | title: "bump(cli): bump version to ${{ steps.bump.outputs.new_version }}" 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - pull_request 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions-rs/toolchain@v1 11 | with: 12 | toolchain: nightly 13 | override: true 14 | components: rustfmt, clippy 15 | 16 | - uses: actions-rs/cargo@v1 17 | with: 18 | command: build 19 | 20 | e2e: 21 | runs-on: ubuntu-latest 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | tag: [cli, default] 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: actions/cache@v4 29 | with: 30 | path: | 31 | ~/.cargo/bin/ 32 | ~/.cargo/registry/index/ 33 | ~/.cargo/registry/cache/ 34 | ~/.cargo/git/db/ 35 | target/ 36 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 37 | restore-keys: ${{ runner.os }}-cargo- 38 | - uses: actions-rs/cargo@v1 39 | with: 40 | command: build 41 | args: --release 42 | - run: | 43 | sudo chmod +x target/release/commitlint 44 | sudo mv target/release/commitlint /usr/local/bin/commitlint 45 | - uses: mig4/setup-bats@v1 46 | with: 47 | bats-version: 1.9.0 48 | - run: bats cli/e2e --filter-tags ${{ matrix.tag }} 49 | 50 | schema: 51 | runs-on: ubuntu-latest 52 | steps: 53 | - uses: actions/checkout@v4 54 | - uses: actions-rs/toolchain@v1 55 | with: 56 | profile: minimal 57 | toolchain: stable 58 | override: true 59 | - run: cargo run --package schema -- --path schema.json 60 | 61 | test: 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: actions/checkout@v4 65 | - uses: actions-rs/toolchain@v1 66 | with: 67 | toolchain: nightly 68 | override: true 69 | components: rustfmt, clippy 70 | - uses: actions/cache@v4 71 | with: 72 | path: | 73 | ~/.cargo/bin/ 74 | ~/.cargo/registry/index/ 75 | ~/.cargo/registry/cache/ 76 | ~/.cargo/git/db/ 77 | target/ 78 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 79 | restore-keys: ${{ runner.os }}-cargo- 80 | 81 | - uses: actions-rs/cargo@v1 82 | with: 83 | command: test 84 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | - edited 7 | - synchronize 8 | 9 | permissions: 10 | contents: read # To checkout 11 | pull-requests: write # To comment to PRs 12 | 13 | jobs: 14 | actionlint: 15 | name: actionlint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - uses: reviewdog/action-actionlint@v1 21 | with: 22 | reporter: github-pr-review 23 | 24 | alex: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - uses: reviewdog/action-alex@v1 30 | with: 31 | reporter: github-pr-review 32 | 33 | assign-author: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: toshimaru/auto-author-assign@v2.1.1 37 | 38 | clippy: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v4 42 | - uses: actions-rs/toolchain@v1 43 | with: 44 | toolchain: nightly 45 | override: true 46 | components: rustfmt, clippy 47 | 48 | - uses: actions-rs/cargo@v1 49 | with: 50 | command: clippy 51 | 52 | - uses: sksat/action-clippy@v1.1.0 53 | with: 54 | reporter: github-pr-review 55 | 56 | markdown: 57 | name: markdown 58 | runs-on: ubuntu-latest 59 | steps: 60 | - uses: actions/checkout@v4 61 | - uses: reviewdog/action-markdownlint@v0 62 | with: 63 | level: warning 64 | markdownlint_flags: website/docs/**/*.md 65 | reporter: github-pr-review 66 | 67 | misspell: 68 | name: misspell 69 | runs-on: ubuntu-latest 70 | steps: 71 | - uses: actions/checkout@v4 72 | 73 | - uses: reviewdog/action-misspell@v1 74 | with: 75 | level: warning 76 | reporter: github-pr-review 77 | 78 | title: 79 | runs-on: ubuntu-latest 80 | steps: 81 | - uses: amannn/action-semantic-pull-request@v5 82 | env: 83 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 84 | with: 85 | disallowScopes: | 86 | release 87 | [A-Z]+ 88 | requireScope: true 89 | subjectPattern: ^(?![A-Z]).+$ 90 | scopes: | 91 | .github 92 | cli 93 | deps 94 | other 95 | web 96 | types: | 97 | bump 98 | chore 99 | doc 100 | feat 101 | fix 102 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "v[0-9]+.[0-9]+.[0-9]+*" 6 | 7 | concurrency: 8 | group: ${{ github.workflow }} 9 | cancel-in-progress: false 10 | 11 | permissions: 12 | contents: write # To write to release 13 | id-token: write # To deploy to GitHub Pages 14 | pages: write # To deploy to GitHub Pages 15 | 16 | jobs: 17 | build: 18 | runs-on: ${{ matrix.config.os }} 19 | strategy: 20 | fail-fast: true 21 | matrix: 22 | config: 23 | # See details fore GitHub Actions runners 24 | # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners 25 | - os: ubuntu-20.04 26 | rust_target: x86_64-unknown-linux-gnu 27 | ext: "" 28 | args: "" 29 | - os: macos-13 # (Intel x86) 30 | rust_target: x86_64-apple-darwin 31 | ext: "" 32 | args: "" 33 | - os: macos-latest # (Apple Silicon) 34 | rust_target: aarch64-apple-darwin 35 | ext: "" 36 | args: "" 37 | - os: windows-latest 38 | rust_target: x86_64-pc-windows-msvc 39 | ext: ".exe" 40 | args: "" 41 | - os: windows-latest 42 | rust_target: aarch64-pc-windows-msvc 43 | ext: ".exe" 44 | args: "--no-default-features --features native-tls-vendored" 45 | steps: 46 | - uses: actions/checkout@v4 47 | 48 | - uses: actions-rs/toolchain@v1 49 | with: 50 | profile: minimal 51 | toolchain: stable 52 | override: true 53 | 54 | - run: cargo build --package commitlint-rs --release 55 | 56 | - run: tar czvf commitlint-${{ github.ref_name }}-${{ matrix.config.rust_target }}.tar.gz -C target/release commitlint${{ matrix.config.ext }} 57 | 58 | - uses: actions/upload-artifact@v4 59 | with: 60 | name: commitlint-${{ matrix.config.rust_target }} 61 | path: commitlint-${{ github.ref_name }}-${{ matrix.config.rust_target }}.tar.gz 62 | if-no-files-found: error 63 | 64 | crate: 65 | runs-on: ubuntu-latest 66 | environment: crate 67 | steps: 68 | - uses: actions/checkout@v4 69 | - uses: actions-rs/toolchain@v1 70 | with: 71 | toolchain: stable 72 | override: true 73 | - uses: actions/cache@v4 74 | with: 75 | path: | 76 | ~/.cargo/bin/ 77 | ~/.cargo/registry/index/ 78 | ~/.cargo/registry/cache/ 79 | ~/.cargo/git/db/ 80 | target/ 81 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 82 | restore-keys: ${{ runner.os }}-cargo- 83 | - run: cargo publish --package commitlint-rs 84 | env: 85 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 86 | 87 | docker: 88 | runs-on: ubuntu-latest 89 | environment: docker 90 | steps: 91 | - uses: docker/setup-qemu-action@v3 92 | 93 | - uses: docker/setup-buildx-action@v3 94 | 95 | - uses: docker/login-action@v3 96 | with: 97 | username: ${{ secrets.DOCKERHUB_USERNAME }} 98 | password: ${{ secrets.DOCKERHUB_TOKEN }} 99 | 100 | - uses: docker/metadata-action@v5 101 | id: meta 102 | with: 103 | images: 1915keke/commitlint 104 | tags: | 105 | type=raw,value=latest 106 | type=semver,pattern={{version}} 107 | type=semver,pattern={{major}}.{{minor}} 108 | type=semver,pattern={{major}} 109 | 110 | - uses: docker/build-push-action@v6 111 | with: 112 | platforms: linux/amd64,linux/arm64 113 | push: true 114 | tags: ${{ steps.meta.outputs.tags }} 115 | cache-from: type=gha 116 | cache-to: type=gha,mode=max 117 | 118 | publish: 119 | runs-on: ubuntu-latest 120 | needs: 121 | - build 122 | - crate 123 | - docker 124 | - schema 125 | - web 126 | steps: 127 | - uses: actions/download-artifact@v4 128 | with: 129 | path: commitlint 130 | pattern: commitlint-* 131 | merge-multiple: true 132 | - uses: ncipollo/release-action@v1 133 | with: 134 | artifacts: commitlint/commitlint-*.tar.gz,schema.json 135 | generateReleaseNotes: true 136 | 137 | schema: 138 | runs-on: ubuntu-latest 139 | steps: 140 | - uses: actions/checkout@v4 141 | - uses: actions-rs/toolchain@v1 142 | with: 143 | profile: minimal 144 | toolchain: stable 145 | override: true 146 | - run: cargo run --package schema -- --path schema.json 147 | - uses: actions/upload-artifact@v4 148 | with: 149 | name: schema.json 150 | path: schema.json 151 | if-no-files-found: error 152 | 153 | web: 154 | runs-on: ubuntu-latest 155 | environment: 156 | name: web 157 | url: ${{ steps.deployment.outputs.page_url }} 158 | steps: 159 | - uses: actions/checkout@v4 160 | - uses: withastro/action@v2 161 | with: 162 | path: ./web 163 | - id: deployment 164 | uses: actions/deploy-pages@v4 165 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Generated by gibo (https://github.com/simonwhitaker/gibo) 2 | ### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Rust.gitignore 3 | 4 | # Generated by Cargo 5 | # will have compiled files and executables 6 | debug/ 7 | target/ 8 | 9 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 10 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 11 | Cargo.lock 12 | 13 | # These are backup files generated by rustfmt 14 | **/*.rs.bk 15 | 16 | # MSVC Windows builds of rustc generate these, which store debugging information 17 | *.pdb 18 | 19 | # Commitlint binary 20 | commitlint 21 | 22 | # Commitlint config file 23 | .commitlintrc 24 | .commitlintrc.* 25 | 26 | # JSON schema 27 | schema.json 28 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: commitlint 2 | name: Assert Conventional Commit Messages 3 | description: 'Asserts that Conventional Commits have been used for all commit messages according to the rules for this repo.' 4 | entry: commitlint --edit 5 | language: rust 6 | stages: [prepare-commit-msg] 7 | pass_filenames: false 8 | require_serial: true 9 | verbose: true -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "DavidAnson.vscode-markdownlint", 4 | "esbenp.prettier-vscode", 5 | "rust-lang.rust-analyzer", 6 | "streetsidesoftware.code-spell-checker", 7 | "usernamehw.errorlens" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug executable 'commitlint'", 11 | "cargo": { 12 | "args": ["build", "--bin=commitlint", "--package=commitlint-rs"], 13 | "filter": { 14 | "name": "commitlint", 15 | "kind": "bin" 16 | } 17 | }, 18 | "args": [], 19 | "cwd": "${workspaceFolder}" 20 | }, 21 | { 22 | "type": "lldb", 23 | "request": "launch", 24 | "name": "Debug unit tests in executable 'commitlint'", 25 | "cargo": { 26 | "args": [ 27 | "test", 28 | "--no-run", 29 | "--bin=commitlint", 30 | "--package=commitlint-rs" 31 | ], 32 | "filter": { 33 | "name": "commitlint", 34 | "kind": "bin" 35 | } 36 | }, 37 | "args": [], 38 | "cwd": "${workspaceFolder}" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.24.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 19 | 20 | [[package]] 21 | name = "aho-corasick" 22 | version = "1.1.3" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 25 | dependencies = [ 26 | "memchr", 27 | ] 28 | 29 | [[package]] 30 | name = "anstream" 31 | version = "0.6.18" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 34 | dependencies = [ 35 | "anstyle", 36 | "anstyle-parse", 37 | "anstyle-query", 38 | "anstyle-wincon", 39 | "colorchoice", 40 | "is_terminal_polyfill", 41 | "utf8parse", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle" 46 | version = "1.0.10" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 49 | 50 | [[package]] 51 | name = "anstyle-parse" 52 | version = "0.2.6" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 55 | dependencies = [ 56 | "utf8parse", 57 | ] 58 | 59 | [[package]] 60 | name = "anstyle-query" 61 | version = "1.1.2" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 64 | dependencies = [ 65 | "windows-sys 0.59.0", 66 | ] 67 | 68 | [[package]] 69 | name = "anstyle-wincon" 70 | version = "3.0.6" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" 73 | dependencies = [ 74 | "anstyle", 75 | "windows-sys 0.59.0", 76 | ] 77 | 78 | [[package]] 79 | name = "autocfg" 80 | version = "1.4.0" 81 | source = "registry+https://github.com/rust-lang/crates.io-index" 82 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 83 | 84 | [[package]] 85 | name = "backtrace" 86 | version = "0.3.74" 87 | source = "registry+https://github.com/rust-lang/crates.io-index" 88 | checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" 89 | dependencies = [ 90 | "addr2line", 91 | "cfg-if", 92 | "libc", 93 | "miniz_oxide", 94 | "object", 95 | "rustc-demangle", 96 | "windows-targets", 97 | ] 98 | 99 | [[package]] 100 | name = "bitflags" 101 | version = "2.6.0" 102 | source = "registry+https://github.com/rust-lang/crates.io-index" 103 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 104 | 105 | [[package]] 106 | name = "bytes" 107 | version = "1.8.0" 108 | source = "registry+https://github.com/rust-lang/crates.io-index" 109 | checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" 110 | 111 | [[package]] 112 | name = "cfg-if" 113 | version = "1.0.0" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 116 | 117 | [[package]] 118 | name = "clap" 119 | version = "4.5.21" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | checksum = "fb3b4b9e5a7c7514dfa52869339ee98b3156b0bfb4e8a77c4ff4babb64b1604f" 122 | dependencies = [ 123 | "clap_builder", 124 | "clap_derive", 125 | ] 126 | 127 | [[package]] 128 | name = "clap_builder" 129 | version = "4.5.21" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "b17a95aa67cc7b5ebd32aa5370189aa0d79069ef1c64ce893bd30fb24bff20ec" 132 | dependencies = [ 133 | "anstream", 134 | "anstyle", 135 | "clap_lex", 136 | "strsim", 137 | ] 138 | 139 | [[package]] 140 | name = "clap_derive" 141 | version = "4.5.18" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" 144 | dependencies = [ 145 | "heck", 146 | "proc-macro2", 147 | "quote", 148 | "syn", 149 | ] 150 | 151 | [[package]] 152 | name = "clap_lex" 153 | version = "0.7.2" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" 156 | 157 | [[package]] 158 | name = "colorchoice" 159 | version = "1.0.3" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 162 | 163 | [[package]] 164 | name = "commitlint-rs" 165 | version = "0.2.2" 166 | dependencies = [ 167 | "clap", 168 | "futures", 169 | "regex", 170 | "schemars", 171 | "serde", 172 | "serde_json", 173 | "serde_yaml", 174 | "tokio", 175 | ] 176 | 177 | [[package]] 178 | name = "dyn-clone" 179 | version = "1.0.17" 180 | source = "registry+https://github.com/rust-lang/crates.io-index" 181 | checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" 182 | 183 | [[package]] 184 | name = "equivalent" 185 | version = "1.0.1" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 188 | 189 | [[package]] 190 | name = "futures" 191 | version = "0.3.31" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" 194 | dependencies = [ 195 | "futures-channel", 196 | "futures-core", 197 | "futures-executor", 198 | "futures-io", 199 | "futures-sink", 200 | "futures-task", 201 | "futures-util", 202 | ] 203 | 204 | [[package]] 205 | name = "futures-channel" 206 | version = "0.3.31" 207 | source = "registry+https://github.com/rust-lang/crates.io-index" 208 | checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 209 | dependencies = [ 210 | "futures-core", 211 | "futures-sink", 212 | ] 213 | 214 | [[package]] 215 | name = "futures-core" 216 | version = "0.3.31" 217 | source = "registry+https://github.com/rust-lang/crates.io-index" 218 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 219 | 220 | [[package]] 221 | name = "futures-executor" 222 | version = "0.3.31" 223 | source = "registry+https://github.com/rust-lang/crates.io-index" 224 | checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" 225 | dependencies = [ 226 | "futures-core", 227 | "futures-task", 228 | "futures-util", 229 | ] 230 | 231 | [[package]] 232 | name = "futures-io" 233 | version = "0.3.31" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 236 | 237 | [[package]] 238 | name = "futures-macro" 239 | version = "0.3.31" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 242 | dependencies = [ 243 | "proc-macro2", 244 | "quote", 245 | "syn", 246 | ] 247 | 248 | [[package]] 249 | name = "futures-sink" 250 | version = "0.3.31" 251 | source = "registry+https://github.com/rust-lang/crates.io-index" 252 | checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 253 | 254 | [[package]] 255 | name = "futures-task" 256 | version = "0.3.31" 257 | source = "registry+https://github.com/rust-lang/crates.io-index" 258 | checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 259 | 260 | [[package]] 261 | name = "futures-util" 262 | version = "0.3.31" 263 | source = "registry+https://github.com/rust-lang/crates.io-index" 264 | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 265 | dependencies = [ 266 | "futures-channel", 267 | "futures-core", 268 | "futures-io", 269 | "futures-macro", 270 | "futures-sink", 271 | "futures-task", 272 | "memchr", 273 | "pin-project-lite", 274 | "pin-utils", 275 | "slab", 276 | ] 277 | 278 | [[package]] 279 | name = "gimli" 280 | version = "0.31.1" 281 | source = "registry+https://github.com/rust-lang/crates.io-index" 282 | checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 283 | 284 | [[package]] 285 | name = "hashbrown" 286 | version = "0.15.1" 287 | source = "registry+https://github.com/rust-lang/crates.io-index" 288 | checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" 289 | 290 | [[package]] 291 | name = "heck" 292 | version = "0.5.0" 293 | source = "registry+https://github.com/rust-lang/crates.io-index" 294 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 295 | 296 | [[package]] 297 | name = "hermit-abi" 298 | version = "0.3.9" 299 | source = "registry+https://github.com/rust-lang/crates.io-index" 300 | checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" 301 | 302 | [[package]] 303 | name = "indexmap" 304 | version = "2.6.0" 305 | source = "registry+https://github.com/rust-lang/crates.io-index" 306 | checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" 307 | dependencies = [ 308 | "equivalent", 309 | "hashbrown", 310 | ] 311 | 312 | [[package]] 313 | name = "is_terminal_polyfill" 314 | version = "1.70.1" 315 | source = "registry+https://github.com/rust-lang/crates.io-index" 316 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 317 | 318 | [[package]] 319 | name = "itoa" 320 | version = "1.0.11" 321 | source = "registry+https://github.com/rust-lang/crates.io-index" 322 | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" 323 | 324 | [[package]] 325 | name = "libc" 326 | version = "0.2.171" 327 | source = "registry+https://github.com/rust-lang/crates.io-index" 328 | checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" 329 | 330 | [[package]] 331 | name = "lock_api" 332 | version = "0.4.12" 333 | source = "registry+https://github.com/rust-lang/crates.io-index" 334 | checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 335 | dependencies = [ 336 | "autocfg", 337 | "scopeguard", 338 | ] 339 | 340 | [[package]] 341 | name = "memchr" 342 | version = "2.7.4" 343 | source = "registry+https://github.com/rust-lang/crates.io-index" 344 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 345 | 346 | [[package]] 347 | name = "miniz_oxide" 348 | version = "0.8.0" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" 351 | dependencies = [ 352 | "adler2", 353 | ] 354 | 355 | [[package]] 356 | name = "mio" 357 | version = "1.0.2" 358 | source = "registry+https://github.com/rust-lang/crates.io-index" 359 | checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" 360 | dependencies = [ 361 | "hermit-abi", 362 | "libc", 363 | "wasi", 364 | "windows-sys 0.52.0", 365 | ] 366 | 367 | [[package]] 368 | name = "object" 369 | version = "0.36.5" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" 372 | dependencies = [ 373 | "memchr", 374 | ] 375 | 376 | [[package]] 377 | name = "parking_lot" 378 | version = "0.12.3" 379 | source = "registry+https://github.com/rust-lang/crates.io-index" 380 | checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" 381 | dependencies = [ 382 | "lock_api", 383 | "parking_lot_core", 384 | ] 385 | 386 | [[package]] 387 | name = "parking_lot_core" 388 | version = "0.9.10" 389 | source = "registry+https://github.com/rust-lang/crates.io-index" 390 | checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 391 | dependencies = [ 392 | "cfg-if", 393 | "libc", 394 | "redox_syscall", 395 | "smallvec", 396 | "windows-targets", 397 | ] 398 | 399 | [[package]] 400 | name = "pin-project-lite" 401 | version = "0.2.15" 402 | source = "registry+https://github.com/rust-lang/crates.io-index" 403 | checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" 404 | 405 | [[package]] 406 | name = "pin-utils" 407 | version = "0.1.0" 408 | source = "registry+https://github.com/rust-lang/crates.io-index" 409 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 410 | 411 | [[package]] 412 | name = "proc-macro2" 413 | version = "1.0.89" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" 416 | dependencies = [ 417 | "unicode-ident", 418 | ] 419 | 420 | [[package]] 421 | name = "quote" 422 | version = "1.0.37" 423 | source = "registry+https://github.com/rust-lang/crates.io-index" 424 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 425 | dependencies = [ 426 | "proc-macro2", 427 | ] 428 | 429 | [[package]] 430 | name = "redox_syscall" 431 | version = "0.5.7" 432 | source = "registry+https://github.com/rust-lang/crates.io-index" 433 | checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" 434 | dependencies = [ 435 | "bitflags", 436 | ] 437 | 438 | [[package]] 439 | name = "regex" 440 | version = "1.11.1" 441 | source = "registry+https://github.com/rust-lang/crates.io-index" 442 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 443 | dependencies = [ 444 | "aho-corasick", 445 | "memchr", 446 | "regex-automata", 447 | "regex-syntax", 448 | ] 449 | 450 | [[package]] 451 | name = "regex-automata" 452 | version = "0.4.8" 453 | source = "registry+https://github.com/rust-lang/crates.io-index" 454 | checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" 455 | dependencies = [ 456 | "aho-corasick", 457 | "memchr", 458 | "regex-syntax", 459 | ] 460 | 461 | [[package]] 462 | name = "regex-syntax" 463 | version = "0.8.5" 464 | source = "registry+https://github.com/rust-lang/crates.io-index" 465 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 466 | 467 | [[package]] 468 | name = "rustc-demangle" 469 | version = "0.1.24" 470 | source = "registry+https://github.com/rust-lang/crates.io-index" 471 | checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 472 | 473 | [[package]] 474 | name = "ryu" 475 | version = "1.0.18" 476 | source = "registry+https://github.com/rust-lang/crates.io-index" 477 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 478 | 479 | [[package]] 480 | name = "schema" 481 | version = "0.2.2" 482 | dependencies = [ 483 | "clap", 484 | "commitlint-rs", 485 | "schemars", 486 | "serde", 487 | "serde_json", 488 | ] 489 | 490 | [[package]] 491 | name = "schemars" 492 | version = "0.8.21" 493 | source = "registry+https://github.com/rust-lang/crates.io-index" 494 | checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" 495 | dependencies = [ 496 | "dyn-clone", 497 | "schemars_derive", 498 | "serde", 499 | "serde_json", 500 | ] 501 | 502 | [[package]] 503 | name = "schemars_derive" 504 | version = "0.8.21" 505 | source = "registry+https://github.com/rust-lang/crates.io-index" 506 | checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" 507 | dependencies = [ 508 | "proc-macro2", 509 | "quote", 510 | "serde_derive_internals", 511 | "syn", 512 | ] 513 | 514 | [[package]] 515 | name = "scopeguard" 516 | version = "1.2.0" 517 | source = "registry+https://github.com/rust-lang/crates.io-index" 518 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 519 | 520 | [[package]] 521 | name = "serde" 522 | version = "1.0.215" 523 | source = "registry+https://github.com/rust-lang/crates.io-index" 524 | checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" 525 | dependencies = [ 526 | "serde_derive", 527 | ] 528 | 529 | [[package]] 530 | name = "serde_derive" 531 | version = "1.0.215" 532 | source = "registry+https://github.com/rust-lang/crates.io-index" 533 | checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" 534 | dependencies = [ 535 | "proc-macro2", 536 | "quote", 537 | "syn", 538 | ] 539 | 540 | [[package]] 541 | name = "serde_derive_internals" 542 | version = "0.29.1" 543 | source = "registry+https://github.com/rust-lang/crates.io-index" 544 | checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" 545 | dependencies = [ 546 | "proc-macro2", 547 | "quote", 548 | "syn", 549 | ] 550 | 551 | [[package]] 552 | name = "serde_json" 553 | version = "1.0.140" 554 | source = "registry+https://github.com/rust-lang/crates.io-index" 555 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 556 | dependencies = [ 557 | "itoa", 558 | "memchr", 559 | "ryu", 560 | "serde", 561 | ] 562 | 563 | [[package]] 564 | name = "serde_yaml" 565 | version = "0.9.34+deprecated" 566 | source = "registry+https://github.com/rust-lang/crates.io-index" 567 | checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" 568 | dependencies = [ 569 | "indexmap", 570 | "itoa", 571 | "ryu", 572 | "serde", 573 | "unsafe-libyaml", 574 | ] 575 | 576 | [[package]] 577 | name = "signal-hook-registry" 578 | version = "1.4.2" 579 | source = "registry+https://github.com/rust-lang/crates.io-index" 580 | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 581 | dependencies = [ 582 | "libc", 583 | ] 584 | 585 | [[package]] 586 | name = "slab" 587 | version = "0.4.9" 588 | source = "registry+https://github.com/rust-lang/crates.io-index" 589 | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 590 | dependencies = [ 591 | "autocfg", 592 | ] 593 | 594 | [[package]] 595 | name = "smallvec" 596 | version = "1.13.2" 597 | source = "registry+https://github.com/rust-lang/crates.io-index" 598 | checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" 599 | 600 | [[package]] 601 | name = "socket2" 602 | version = "0.5.7" 603 | source = "registry+https://github.com/rust-lang/crates.io-index" 604 | checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" 605 | dependencies = [ 606 | "libc", 607 | "windows-sys 0.52.0", 608 | ] 609 | 610 | [[package]] 611 | name = "strsim" 612 | version = "0.11.1" 613 | source = "registry+https://github.com/rust-lang/crates.io-index" 614 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 615 | 616 | [[package]] 617 | name = "syn" 618 | version = "2.0.87" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" 621 | dependencies = [ 622 | "proc-macro2", 623 | "quote", 624 | "unicode-ident", 625 | ] 626 | 627 | [[package]] 628 | name = "tokio" 629 | version = "1.43.1" 630 | source = "registry+https://github.com/rust-lang/crates.io-index" 631 | checksum = "492a604e2fd7f814268a378409e6c92b5525d747d10db9a229723f55a417958c" 632 | dependencies = [ 633 | "backtrace", 634 | "bytes", 635 | "libc", 636 | "mio", 637 | "parking_lot", 638 | "pin-project-lite", 639 | "signal-hook-registry", 640 | "socket2", 641 | "tokio-macros", 642 | "windows-sys 0.52.0", 643 | ] 644 | 645 | [[package]] 646 | name = "tokio-macros" 647 | version = "2.5.0" 648 | source = "registry+https://github.com/rust-lang/crates.io-index" 649 | checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" 650 | dependencies = [ 651 | "proc-macro2", 652 | "quote", 653 | "syn", 654 | ] 655 | 656 | [[package]] 657 | name = "unicode-ident" 658 | version = "1.0.13" 659 | source = "registry+https://github.com/rust-lang/crates.io-index" 660 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 661 | 662 | [[package]] 663 | name = "unsafe-libyaml" 664 | version = "0.2.11" 665 | source = "registry+https://github.com/rust-lang/crates.io-index" 666 | checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" 667 | 668 | [[package]] 669 | name = "utf8parse" 670 | version = "0.2.2" 671 | source = "registry+https://github.com/rust-lang/crates.io-index" 672 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 673 | 674 | [[package]] 675 | name = "wasi" 676 | version = "0.11.0+wasi-snapshot-preview1" 677 | source = "registry+https://github.com/rust-lang/crates.io-index" 678 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 679 | 680 | [[package]] 681 | name = "windows-sys" 682 | version = "0.52.0" 683 | source = "registry+https://github.com/rust-lang/crates.io-index" 684 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 685 | dependencies = [ 686 | "windows-targets", 687 | ] 688 | 689 | [[package]] 690 | name = "windows-sys" 691 | version = "0.59.0" 692 | source = "registry+https://github.com/rust-lang/crates.io-index" 693 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 694 | dependencies = [ 695 | "windows-targets", 696 | ] 697 | 698 | [[package]] 699 | name = "windows-targets" 700 | version = "0.52.6" 701 | source = "registry+https://github.com/rust-lang/crates.io-index" 702 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 703 | dependencies = [ 704 | "windows_aarch64_gnullvm", 705 | "windows_aarch64_msvc", 706 | "windows_i686_gnu", 707 | "windows_i686_gnullvm", 708 | "windows_i686_msvc", 709 | "windows_x86_64_gnu", 710 | "windows_x86_64_gnullvm", 711 | "windows_x86_64_msvc", 712 | ] 713 | 714 | [[package]] 715 | name = "windows_aarch64_gnullvm" 716 | version = "0.52.6" 717 | source = "registry+https://github.com/rust-lang/crates.io-index" 718 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 719 | 720 | [[package]] 721 | name = "windows_aarch64_msvc" 722 | version = "0.52.6" 723 | source = "registry+https://github.com/rust-lang/crates.io-index" 724 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 725 | 726 | [[package]] 727 | name = "windows_i686_gnu" 728 | version = "0.52.6" 729 | source = "registry+https://github.com/rust-lang/crates.io-index" 730 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 731 | 732 | [[package]] 733 | name = "windows_i686_gnullvm" 734 | version = "0.52.6" 735 | source = "registry+https://github.com/rust-lang/crates.io-index" 736 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 737 | 738 | [[package]] 739 | name = "windows_i686_msvc" 740 | version = "0.52.6" 741 | source = "registry+https://github.com/rust-lang/crates.io-index" 742 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 743 | 744 | [[package]] 745 | name = "windows_x86_64_gnu" 746 | version = "0.52.6" 747 | source = "registry+https://github.com/rust-lang/crates.io-index" 748 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 749 | 750 | [[package]] 751 | name = "windows_x86_64_gnullvm" 752 | version = "0.52.6" 753 | source = "registry+https://github.com/rust-lang/crates.io-index" 754 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 755 | 756 | [[package]] 757 | name = "windows_x86_64_msvc" 758 | version = "0.52.6" 759 | source = "registry+https://github.com/rust-lang/crates.io-index" 760 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 761 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cli", "schema"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | version = "0.2.2" 7 | authors = ["KeisukeYamashita <19yamashita15@gmail.com>"] 8 | license = "MIT OR Apache-2.0" 9 | documentation = "https://keisukeyamashita.github.io/commitlint-rs" 10 | keywords = ["conventional-commits", "lint"] 11 | categories = ["command-line-utilities"] 12 | readme = "README.md" 13 | repository = "https://github.com/KeisukeYamashita/commitlint-rs" 14 | exclude = ["/web"] 15 | edition = "2021" 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM rust:1.83-alpine as builder 3 | WORKDIR /app 4 | 5 | RUN --mount=type=cache,target=/var/cache/apk,sharing=locked \ 6 | apk update \ 7 | && apk add --no-cache musl-dev 8 | 9 | COPY . . 10 | RUN cargo install --path ./cli && \ 11 | commitlint --version 12 | 13 | FROM alpine 14 | LABEL maintainer="KeisukeYamashita <19yamashita15@gmail.com>" 15 | 16 | RUN --mount=type=cache,target=/var/cache/apk,sharing=locked \ 17 | apk update \ 18 | && apk add --no-cache musl-dev 19 | 20 | COPY --from=builder /usr/local/cargo/bin/commitlint /usr/local/bin/commitlint 21 | 22 | ENTRYPOINT [ "commitlint" ] 23 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 KeisukeYamashita 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023- KeisukeYamashita 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Commitlint

4 | 5 | 6 |

🔦 Lint commit messages with conventional commit messages

7 | 8 | Documents are available [here](https://keisukeyamashita.github.io/commitlint-rs) 9 | 10 | [![](https://img.shields.io/crates/v/commitlint-rs?logo=rust)](https://crates.io/crates/commitlint-rs) 11 | [![](https://img.shields.io/crates/d/commitlint-rs?logo=rust)](https://crates.io/crates/commitlint-rs) 12 | [![](https://img.shields.io/crates/l/commitlint-rs)](https://github.com/KeisukeYamashita/commitlint-rs) 13 | [![](https://img.shields.io/badge/Docker-Supported-%232496ED?logo=docker)](https://hub.docker.com/repository/docker/1915keke/commitlint) 14 | [![](https://img.shields.io/badge/Dependency-Dependabot-%230361cd?logo=dependabot)](https://github.com/KeisukeYamashita/commitlint-rs) 15 | [![](https://img.shields.io/badge/Security-Snyk-%2321204b?logo=snyk)](https://github.com/KeisukeYamashita/commitlint-rs) 16 | [![](https://app.fossa.com/api/projects/git%2Bgithub.com%2FKeisukeYamashita%2Fcommitlint-rs.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2FKeisukeYamashita%2Fcommitlint-rs?ref=badge_shield) 17 | 18 |
19 | 20 | ## License 21 | 22 | Commitlint source code is licensed either [MIT](LICENSE-MIT) or [Apache-2.0](./LICENSE-APACHE). 23 | 24 | Chinese-checkers icons created by Freepik - Flaticon 25 | 26 |
27 | -------------------------------------------------------------------------------- /cli/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.yml] 4 | indent_size = 2 5 | -------------------------------------------------------------------------------- /cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "commitlint-rs" 3 | description = "CLI tool to lint commits by Conventional Commits" 4 | documentation.workspace = true 5 | authors.workspace = true 6 | keywords.workspace = true 7 | categories.workspace = true 8 | version.workspace = true 9 | readme.workspace = true 10 | repository.workspace = true 11 | license.workspace = true 12 | edition.workspace = true 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [[bin]] 17 | name = "commitlint" 18 | path = "src/main.rs" 19 | 20 | [dependencies] 21 | clap = { version = "4.5.4", features = ["derive", "env", "string"] } 22 | futures = "0.3.30" 23 | regex = "1.10.5" 24 | schemars = { version = "0.8.21", optional = true } 25 | serde = { version = "1.0.201", features = ["derive"] } 26 | serde_json = "1.0.140" 27 | serde_yaml = "0.9.34" 28 | tokio = { version = "1.43.1", features = ["full"] } 29 | 30 | [features] 31 | schemars = ["dep:schemars"] 32 | default = [] 33 | 34 | [package.metadata.binstall] 35 | pkg-url = "{ repo }/releases/download/v{ version }/commitlint-v{ version }-{ target }{ archive-suffix }" 36 | -------------------------------------------------------------------------------- /cli/e2e/cli.bats: -------------------------------------------------------------------------------- 1 | # bats test_tags=cli 2 | @test "not existing config file" { 3 | run bash -c 'echo "feat(cli): impl -a flag" | commitlint --config not-existing-config.js' 4 | [ "$status" -eq 1 ] 5 | } 6 | -------------------------------------------------------------------------------- /cli/e2e/default.bats: -------------------------------------------------------------------------------- 1 | # bats test_tags=default 2 | @test "empty body" { 3 | run bash -c 'echo "feat(cli): impl -a flag" | commitlint' 4 | [ "$status" -eq 0 ] 5 | } 6 | 7 | # bats test_tags=default 8 | @test "body max length" { 9 | run bash -c "echo \"feat(cli): impl -a flag 10 | 11 | Hello, I'm longer than 72 charactures. I'm very long so that it is not suitable as it's hard to read many long commit messages. 12 | Make it smart. But we should not be opinionated so the default is ignored.\" | commitlint" 13 | [ "$status" -eq 0 ] 14 | } 15 | 16 | # bats test_tags=default 17 | @test "description empty" { 18 | run bash -c 'echo "feat(cli): " | commitlint' 19 | [ "$status" -eq 1 ] 20 | } 21 | 22 | # bats test_tags=default 23 | @test "description format" { 24 | run bash -c 'echo "feat(other): add script" | commitlint' 25 | [ "$status" -eq 0 ] 26 | } 27 | 28 | # bats test_tags=default 29 | @test "description max length" { 30 | run bash -c 'echo "feat(other): add script" | commitlint' 31 | [ "$status" -eq 0 ] 32 | } 33 | 34 | @test "empty" { 35 | run bash -c 'echo "" | commitlint' 36 | [ "$status" -eq 1 ] 37 | } 38 | 39 | # bats test_tags=default 40 | @test "scope" { 41 | run bash -c 'echo "feat(other): add script" | commitlint' 42 | [ "$status" -eq 0 ] 43 | } 44 | 45 | # bats test_tags=default 46 | @test "scope format" { 47 | run bash -c 'echo "feat(other): add script" | commitlint' 48 | [ "$status" -eq 0 ] 49 | } 50 | 51 | # bats test_tags=default 52 | @test "scope max length" { 53 | run bash -c 'echo "feat(other): add script" | commitlint' 54 | [ "$status" -eq 0 ] 55 | } 56 | 57 | # bats test_tags=default 58 | @test "subject empty" { 59 | run bash -c 'echo "feat(other): add script" | commitlint' 60 | [ "$status" -eq 0 ] 61 | } 62 | 63 | # bats test_tags=default 64 | @test "type" { 65 | run bash -c 'echo "feat(other): add script" | commitlint' 66 | [ "$status" -eq 0 ] 67 | } 68 | 69 | # bats test_tags=default 70 | @test "type empty" { 71 | run bash -c 'echo "(other): add script" | commitlint' 72 | [ "$status" -eq 1 ] 73 | } 74 | 75 | # bats test_tags=default 76 | @test "type format" { 77 | run bash -c 'echo "feat(other): add script" | commitlint' 78 | [ "$status" -eq 0 ] 79 | } 80 | 81 | # bats test_tags=default 82 | @test "type max length" { 83 | run bash -c 'echo "feat(other): add script" | commitlint' 84 | [ "$status" -eq 0 ] 85 | } 86 | -------------------------------------------------------------------------------- /cli/examples/.commitlint.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | body-empty: 3 | level: warning 4 | -------------------------------------------------------------------------------- /cli/src/args.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::Error, 3 | io::{stdin, IsTerminal, Read}, 4 | path::PathBuf, 5 | }; 6 | 7 | use clap::Parser; 8 | 9 | use crate::git::{self, ReadCommitMessageOptions}; 10 | use crate::message::Message; 11 | 12 | /// Cli represents the command line arguments. 13 | /// 14 | /// Note that the arguments are following the [conventional-changelog/commitlint](https://commitlint.js.org/#/reference-cli) 15 | /// command line interface to reduce halation and ease onboarding of existing users. 16 | #[derive(Parser, Debug)] 17 | #[command(author, about = "CLI to lint with conventional commits", long_about = None, version)] 18 | pub struct Args { 19 | /// Path to the config file 20 | #[arg(short = 'g', long)] 21 | pub config: Option, 22 | 23 | /// Directory to execute in 24 | #[arg(short = 'd', long, default_value = ".")] 25 | pub cwd: String, 26 | 27 | /// Read last commit from the specified file or fallbacks to ./.git/COMMIT_EDITMSG 28 | #[arg(short = 'e', long)] 29 | pub edit: Option, 30 | 31 | /// Lower end of the commit range to lint 32 | #[arg(short = 'f', long)] 33 | pub from: Option, 34 | 35 | /// Print resolved config 36 | #[arg(long = "print-config")] 37 | pub print_config: bool, 38 | 39 | /// Upper end of the commit range to lint 40 | #[arg(short = 't', long)] 41 | pub to: Option, 42 | } 43 | 44 | impl Args { 45 | /// Check wether the commit message is from stdin or not. 46 | /// 47 | /// Inspired by https://github.com/conventional-changelog/commitlint/blob/af2f3a82d38ea0272578c8066565a0e6cf5810b0/%40commitlint/cli/src/cli.ts#L336 48 | fn has_stdin(&self) -> bool { 49 | !stdin().is_terminal() 50 | } 51 | 52 | /// Read commit messages from stdin. 53 | pub fn read(&self) -> Result, Error> { 54 | // Check first whether or not the --edit option was supplied. When running from tooling such as 55 | // `pre-commit`, stdin exists, so this needs to come first. 56 | if let Some(edit) = self.edit.as_deref() { 57 | if edit != "false" { 58 | let msg = std::fs::read_to_string(edit) 59 | .expect(format!("Failed to read commit message from {}", edit).as_str()); 60 | return Ok(vec![Message::new(msg)]); 61 | } 62 | } 63 | 64 | // Otherwise, check for stdin and use the incoming text buffer from there if so. 65 | if self.has_stdin() { 66 | let mut buffer = String::new(); 67 | stdin() 68 | .read_to_string(&mut buffer) 69 | .expect("Failed to read commit messages from stdin"); 70 | return Ok(vec![Message::new(buffer)]); 71 | } 72 | 73 | if self.from.is_some() || self.to.is_some() { 74 | // Reading directly from Git if from or to is specified. 75 | let config = ReadCommitMessageOptions { 76 | from: self.from.clone(), 77 | path: self.cwd.clone(), 78 | to: self.to.clone(), 79 | }; 80 | 81 | let messages = git::read(config) 82 | .iter() 83 | .map(|s| Message::new(s.to_string())) 84 | .collect(); 85 | 86 | return Ok(messages); 87 | } 88 | 89 | let default_path = std::path::PathBuf::from(".git").join("COMMIT_EDITMSG"); 90 | let msg = std::fs::read_to_string(&default_path).expect( 91 | format!( 92 | "Failed to read commit message from {}", 93 | default_path.display() 94 | ) 95 | .as_str(), 96 | ); 97 | Ok(vec![Message::new(msg)]) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /cli/src/config.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::fmt; 3 | use std::{fs, path::PathBuf}; 4 | 5 | use crate::rule::Rules; 6 | 7 | /// Default Root config file path to search for. 8 | const DEFAULT_CONFIG_ROOT: &str = "."; 9 | 10 | /// Default commitlintrc configuration files 11 | /// If the user didn't specify a configuration file with -c or --config argument, 12 | /// we will try to find one of these files in the current directory. 13 | const DEFAULT_CONFIG_FILE: [&str; 4] = [ 14 | ".commitlintrc", 15 | ".commitlintrc.json", 16 | ".commitlintrc.yaml", 17 | ".commitlintrc.yml", 18 | ]; 19 | 20 | /// Config represents the configuration of commitlint. 21 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 22 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 23 | pub struct Config { 24 | /// Rules represents the rules of commitlint. 25 | pub rules: Rules, 26 | } 27 | 28 | impl fmt::Display for Config { 29 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 30 | let s = serde_yaml::to_string(&self).unwrap(); 31 | write!(f, "{}", s) 32 | } 33 | } 34 | 35 | /// Load configuration from the specified path. 36 | pub async fn load(path: Option) -> Result { 37 | let config_file = match &path { 38 | Some(p) => Some(p.clone()), 39 | None => find_config_file(PathBuf::from(DEFAULT_CONFIG_ROOT)), 40 | }; 41 | 42 | match (config_file, path) { 43 | // If the file was specified and found, load it. 44 | (Some(p), _) => load_config_file(p).await, 45 | // If the file was not specified and not found, return default config. 46 | (None, None) => Ok(Config::default()), 47 | // If the was explicitly specified but not found, return an error. 48 | (None, Some(p)) => Err(format!("Configuration file not found in {}", p.display())), 49 | } 50 | } 51 | 52 | /// Find configuration file in the specified path. 53 | /// Note that the first file found will be returned. 54 | pub fn find_config_file(path: PathBuf) -> Option { 55 | let mut path = path; 56 | for file in DEFAULT_CONFIG_FILE.iter() { 57 | path.push(file); 58 | if path.exists() { 59 | return Some(path); 60 | } 61 | path.pop(); 62 | } 63 | 64 | None 65 | } 66 | 67 | /// Load config file from the specified path. 68 | pub async fn load_config_file(path: PathBuf) -> Result { 69 | if !path.exists() { 70 | return Err(format!( 71 | "Configuration file not found in {}", 72 | path.display() 73 | )); 74 | } 75 | 76 | match path.extension() { 77 | Some(ext) => match ext.to_str() { 78 | Some("json") => load_json_config_file(path).await, 79 | Some("yaml") | Some("yml") => load_yaml_config_file(path).await, 80 | _ => load_unknown_config_file(path).await, 81 | }, 82 | None => Err(format!( 83 | "Unsupported configuration file format: {}", 84 | path.display() 85 | )), 86 | } 87 | } 88 | 89 | /// Load JSON config file from the specified path. 90 | async fn load_json_config_file(path: PathBuf) -> Result { 91 | let text = fs::read_to_string(path).unwrap(); 92 | 93 | match serde_json::from_str::(&text) { 94 | Ok(config) => Ok(config), 95 | Err(err) => Err(format!("Failed to parse configuration file: {}", err)), 96 | } 97 | } 98 | 99 | /// Load YAML config file from the specified path. 100 | async fn load_yaml_config_file(path: PathBuf) -> Result { 101 | let text = fs::read_to_string(path).unwrap(); 102 | 103 | match serde_yaml::from_str::(&text) { 104 | Ok(config) => Ok(config), 105 | Err(err) => Err(format!("Failed to parse configuration file: {}", err)), 106 | } 107 | } 108 | 109 | /// Try to load configuration file from the specified path. 110 | /// First try to load it as JSON, then as YAML. 111 | /// If both fail, return an error. 112 | async fn load_unknown_config_file(path: PathBuf) -> Result { 113 | let text = fs::read_to_string(path.clone()).unwrap(); 114 | 115 | if let Ok(config) = serde_json::from_str::(&text) { 116 | return Ok(config); 117 | } 118 | 119 | if let Ok(config) = serde_yaml::from_str::(&text) { 120 | return Ok(config); 121 | } 122 | 123 | Err(format!( 124 | "Failed to parse configuration file: {}", 125 | path.display() 126 | )) 127 | } 128 | -------------------------------------------------------------------------------- /cli/src/git.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use std::{collections::HashMap, process::Command}; 3 | /// ReadCommitMessageOptions represents the options for reading commit messages. 4 | /// Transparently, it is defined to be similar to the behavior of the git log command. 5 | #[derive(Clone, Debug)] 6 | pub struct ReadCommitMessageOptions { 7 | /// From is the starting commit hash to read from. 8 | pub from: Option, 9 | 10 | /// Path is the path to read commit messages from. 11 | pub path: String, 12 | 13 | /// To is the ending commit hash to read to. 14 | pub to: Option, 15 | } 16 | 17 | /// Get commit messages from git. 18 | pub fn read(options: ReadCommitMessageOptions) -> Vec { 19 | // Configure revision range following the git spec. 20 | // 21 | // See: https://git-scm.com/docs/git-log#Documentation/git-log.txt-ltrevision-rangegt 22 | // 23 | // Make a range if both `from` and `to` are specified, then assign from..to. 24 | // If both are not specified, then assign HEAD. 25 | let range = match (options.from, options.to) { 26 | (Some(from), Some(to)) => format!("{}..{}", from, to), 27 | (Some(from), None) => format!("{}..HEAD", from), 28 | (None, Some(to)) => format!("HEAD..{}", to), 29 | (None, None) => "HEAD".to_string(), 30 | }; 31 | 32 | // See https://git-scm.com/docs/git-log 33 | let stdout = Command::new("git") 34 | .arg("log") 35 | .arg("--pretty=%B") 36 | .arg("--no-merges") 37 | .arg("--no-decorate") 38 | .arg("--reverse") 39 | .arg(range) 40 | .arg("--") // Explicitly specify the end of options as described https://git-scm.com/docs/git-log#Documentation/git-log.txt---ltpathgt82308203 41 | .arg(options.path) 42 | .output() 43 | .expect("Failed to execute git log") 44 | .stdout; 45 | 46 | let stdout = String::from_utf8_lossy(&stdout); 47 | extract_commit_messages(&stdout) 48 | } 49 | 50 | fn extract_commit_messages(input: &str) -> Vec { 51 | let commit_delimiter = Regex::new(r"(?m)^commit [0-9a-f]{40}$").unwrap(); 52 | let commits: Vec<&str> = commit_delimiter.split(input).collect(); 53 | 54 | let mut messages: Vec = Vec::new(); 55 | 56 | for commit in commits { 57 | let message_lines: Vec<&str> = commit.trim().lines().collect(); 58 | let message = message_lines.join("\n"); 59 | messages.push(message); 60 | } 61 | 62 | messages 63 | } 64 | 65 | /// Parse a commit message and return the subject, body, and footers. 66 | /// 67 | /// Please refer the official documentation for the commit message format. 68 | /// See: https://www.conventionalcommits.org/en/v1.0.0/#summary 69 | /// 70 | /// ```ignore 71 | /// [optional scope]: <-- Subject 72 | /// 73 | /// [optional body] <-- Body 74 | /// 75 | /// [optional footer(s)] <-- Footer 76 | /// ``` 77 | pub fn parse_commit_message( 78 | message: &str, 79 | ) -> (String, Option, Option>) { 80 | let lines: Vec<&str> = message.lines().collect(); 81 | let mut lines_iter = lines.iter(); 82 | 83 | let subject = lines_iter.next().unwrap_or(&"").trim().to_string(); 84 | let mut body = None; 85 | let mut footer = None; 86 | 87 | let mut in_body = false; 88 | let mut in_footer = false; 89 | 90 | for line in lines_iter { 91 | if line.trim().is_empty() { 92 | if in_body { 93 | in_body = false; 94 | in_footer = true; 95 | } 96 | } else if in_footer { 97 | let parts: Vec<&str> = line.splitn(2, ':').map(|part| part.trim()).collect(); 98 | if parts.len() == 2 { 99 | let key = parts[0].to_string(); 100 | let value = parts[1].to_string(); 101 | let footer_map = footer.get_or_insert(HashMap::new()); 102 | footer_map.insert(key, value); 103 | } 104 | } else if !in_body { 105 | in_body = true; 106 | body = Some(line.trim().to_string()); 107 | } else if let Some(b) = body.as_mut() { 108 | b.push('\n'); 109 | b.push_str(line.trim()); 110 | } 111 | } 112 | 113 | (subject, body, footer) 114 | } 115 | 116 | /// Parse a commit message subject and return the type, scope, and description. 117 | /// 118 | /// Note that exclamation mark is not respected as the existing commitlint 119 | /// does not have any rules for it. 120 | /// See: https://commitlint.js.org/reference/rules.html 121 | pub fn parse_subject(subject: &str) -> (Option, Option, Option) { 122 | let re = regex::Regex::new( 123 | r"^(?P\w+)(?:\((?P[^\)]+)\))?(?:!)?\:\s?(?P.*)$", 124 | ) 125 | .unwrap(); 126 | if let Some(captures) = re.captures(subject) { 127 | let r#type = captures.name("type").map(|m| m.as_str().to_string()); 128 | let scope = captures.name("scope").map(|m| m.as_str().to_string()); 129 | let description = captures.name("description").map(|m| m.as_str().to_string()); 130 | 131 | return (r#type, scope, description); 132 | } 133 | // Fall back to the description. 134 | (None, None, Some(subject.to_string())) 135 | } 136 | 137 | #[cfg(test)] 138 | mod tests { 139 | use super::*; 140 | 141 | #[test] 142 | fn test_single_line_parse_commit_message() { 143 | let input = "feat(cli): add dummy option"; 144 | let (subject, body, footer) = parse_commit_message(input); 145 | assert_eq!(subject, "feat(cli): add dummy option"); 146 | assert_eq!(body, None); 147 | assert_eq!(footer, None); 148 | } 149 | 150 | #[test] 151 | fn test_body_parse_commit_message() { 152 | let input = "feat(cli): add dummy option 153 | 154 | Hello, there!"; 155 | let (subject, body, footer) = parse_commit_message(input); 156 | assert_eq!(subject, "feat(cli): add dummy option"); 157 | assert_eq!(body, Some("Hello, there!".to_string())); 158 | assert_eq!(footer, None); 159 | } 160 | 161 | #[test] 162 | fn test_footer_parse_commit_message() { 163 | let input = "feat(cli): add dummy option 164 | 165 | Hello, there! 166 | 167 | Link: Hello"; 168 | let (subject, body, footer) = parse_commit_message(input); 169 | 170 | let mut f = HashMap::new(); 171 | f.insert("Link".to_string(), "Hello".to_string()); 172 | assert_eq!(subject, "feat(cli): add dummy option"); 173 | assert_eq!(body, Some("Hello, there!".to_string())); 174 | assert!(footer.is_some()); 175 | assert_eq!(f.get("Link"), Some(&"Hello".to_string())); 176 | } 177 | 178 | #[test] 179 | fn test_footer_with_multiline_body_parse_commit_message() { 180 | let input = "feat(cli): add dummy option 181 | 182 | Hello, there! 183 | I'm from Japan! 184 | 185 | Link: Hello"; 186 | let (subject, body, footer) = parse_commit_message(input); 187 | 188 | let mut f = HashMap::new(); 189 | f.insert("Link".to_string(), "Hello".to_string()); 190 | assert_eq!(subject, "feat(cli): add dummy option"); 191 | assert_eq!( 192 | body, 193 | Some( 194 | "Hello, there! 195 | I'm from Japan!" 196 | .to_string() 197 | ) 198 | ); 199 | assert!(footer.is_some()); 200 | assert_eq!(f.get("Link"), Some(&"Hello".to_string())); 201 | } 202 | 203 | #[test] 204 | fn test_multiple_footers_parse_commit_message() { 205 | let input = "feat(cli): add dummy option 206 | 207 | Hello, there! 208 | 209 | Link: Hello 210 | Name: Keke"; 211 | let (subject, body, footer) = parse_commit_message(input); 212 | 213 | assert_eq!(subject, "feat(cli): add dummy option"); 214 | assert_eq!(body, Some("Hello, there!".to_string())); 215 | assert!(footer.is_some()); 216 | assert_eq!( 217 | footer.clone().unwrap().get("Link"), 218 | Some(&"Hello".to_string()) 219 | ); 220 | assert_eq!(footer.unwrap().get("Name"), Some(&"Keke".to_string())); 221 | } 222 | 223 | #[test] 224 | fn test_parse_subject_with_scope() { 225 | let input = "feat(cli): add dummy option"; 226 | assert_eq!( 227 | parse_subject(input), 228 | ( 229 | Some("feat".to_string()), 230 | Some("cli".to_string()), 231 | Some("add dummy option".to_string()) 232 | ) 233 | ); 234 | } 235 | 236 | #[test] 237 | fn test_parse_subject_with_emphasized_type_with_scope() { 238 | let input = "feat(cli)!: add dummy option"; 239 | assert_eq!( 240 | parse_subject(input), 241 | ( 242 | Some("feat".to_string()), 243 | Some("cli".to_string()), 244 | Some("add dummy option".to_string()) 245 | ) 246 | ); 247 | } 248 | 249 | #[test] 250 | fn test_parse_subject_without_scope() { 251 | let input = "feat: add dummy option"; 252 | assert_eq!( 253 | parse_subject(input), 254 | ( 255 | Some("feat".to_string()), 256 | None, 257 | Some("add dummy option".to_string()) 258 | ) 259 | ); 260 | } 261 | 262 | #[test] 263 | fn test_parse_subject_with_emphasized_type_without_scope() { 264 | let input = "feat!: add dummy option"; 265 | assert_eq!( 266 | parse_subject(input), 267 | ( 268 | Some("feat".to_string()), 269 | None, 270 | Some("add dummy option".to_string()) 271 | ) 272 | ); 273 | } 274 | 275 | #[test] 276 | fn test_parse_subject_with_empty_description() { 277 | let input = "feat(cli): "; 278 | assert_eq!( 279 | parse_subject(input), 280 | ( 281 | Some("feat".to_string()), 282 | Some("cli".to_string()), 283 | Some("".to_string()) 284 | ) 285 | ); 286 | } 287 | 288 | #[test] 289 | fn test_parse_subject_with_empty_scope() { 290 | let input = "feat: add dummy commit"; 291 | assert_eq!( 292 | parse_subject(input), 293 | ( 294 | Some("feat".to_string()), 295 | None, 296 | Some("add dummy commit".to_string()) 297 | ) 298 | ); 299 | } 300 | 301 | #[test] 302 | fn test_parse_subject_without_message() { 303 | let input = ""; 304 | assert_eq!(parse_subject(input), (None, None, Some("".to_string()))); 305 | } 306 | 307 | #[test] 308 | fn test_parse_subject_with_error_message() { 309 | let input = "test"; 310 | assert_eq!(parse_subject(input), (None, None, Some("test".to_string()))); 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /cli/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod git; 3 | pub mod message; 4 | pub mod result; 5 | pub mod rule; 6 | -------------------------------------------------------------------------------- /cli/src/main.rs: -------------------------------------------------------------------------------- 1 | mod args; 2 | mod config; 3 | mod git; 4 | mod message; 5 | mod result; 6 | mod rule; 7 | 8 | use args::Args; 9 | use clap::Parser; 10 | use message::validate; 11 | 12 | use std::process::exit; 13 | 14 | #[tokio::main] 15 | async fn main() { 16 | let args = Args::parse(); 17 | 18 | let config = match config::load(args.config.clone()).await { 19 | Ok(c) => c, 20 | Err(err) => { 21 | eprintln!("Failed to load config: {}", err); 22 | exit(1) 23 | } 24 | }; 25 | 26 | if args.print_config { 27 | println!("{}", config); 28 | } 29 | 30 | let messages = match args.read() { 31 | Ok(messages) => messages, 32 | Err(err) => { 33 | eprintln!("Failed to read commit messages: {}", err); 34 | exit(1) 35 | } 36 | }; 37 | 38 | let threads = messages 39 | .into_iter() 40 | .map(|message| { 41 | let config = config.clone(); 42 | tokio::spawn(async move { validate(&message, &config).await }) 43 | }) 44 | .collect::>(); 45 | 46 | let results = futures::future::join_all(threads).await; 47 | 48 | let mut has_error: bool = false; 49 | for result in &results { 50 | if let Err(err) = result { 51 | eprintln!("{}", err); 52 | } 53 | 54 | if let Ok(Ok(h)) = result { 55 | if !h.violations.is_empty() { 56 | for violation in &h.violations { 57 | match violation.level { 58 | rule::Level::Error => { 59 | eprintln!("{}", violation.message); 60 | has_error = true 61 | } 62 | rule::Level::Warning => { 63 | println!("{}", violation.message); 64 | } 65 | _ => {} 66 | } 67 | } 68 | } 69 | } 70 | } 71 | 72 | if has_error { 73 | exit(1) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /cli/src/message.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config::Config, 3 | git::{parse_commit_message, parse_subject}, 4 | result::Result as LintResult, 5 | }; 6 | use std::{collections::HashMap, fmt::Error}; 7 | 8 | /// Message represents a single commit message. 9 | /// 10 | /// 11 | /// ```code 12 | /// [optional scope]: 13 | /// 14 | /// [optional body] 15 | /// 16 | /// [optional footer(s)] 17 | /// ``` 18 | /// 19 | #[derive(Clone, Debug)] 20 | pub struct Message { 21 | /// Body part of the commit message. 22 | pub body: Option, 23 | 24 | /// Description part of the commit message. 25 | pub description: Option, 26 | /// Footers part of the commit message. 27 | pub footers: Option>, 28 | 29 | #[allow(dead_code)] 30 | /// Raw commit message (or any input from stdin) including the body and footers. 31 | pub raw: String, 32 | 33 | /// Type part of the commit message. 34 | pub r#type: Option, 35 | 36 | /// Scope part of the commit message. 37 | pub scope: Option, 38 | 39 | /// Subject part of the commit message. 40 | pub subject: Option, 41 | } 42 | 43 | /// Message represents a commit message. 44 | impl Message { 45 | /// Create a new Message. 46 | pub fn new(raw: String) -> Self { 47 | let (subject, body, footers) = parse_commit_message(&raw); 48 | let (r#type, scope, description) = parse_subject(&subject); 49 | Self { 50 | body, 51 | description, 52 | footers, 53 | raw, 54 | r#type, 55 | scope, 56 | subject: Some(subject), 57 | } 58 | } 59 | } 60 | 61 | /// validate the raw commit message. 62 | pub async fn validate(msg: &Message, config: &Config) -> Result { 63 | let violations = config.rules.validate(msg); 64 | Ok(LintResult { violations }) 65 | } 66 | -------------------------------------------------------------------------------- /cli/src/result.rs: -------------------------------------------------------------------------------- 1 | use crate::rule::Level; 2 | 3 | /// Result of the check. 4 | #[derive(Clone, Debug)] 5 | pub struct Result { 6 | /// List of violations to be printed. 7 | /// If it is empty, then there is no violation. 8 | pub violations: Vec, 9 | } 10 | 11 | /// Violation is a message that will be printed. 12 | #[derive(Clone, Debug)] 13 | pub struct Violation { 14 | /// Level of the violation. 15 | pub level: Level, 16 | 17 | /// Message of the violation. 18 | pub message: String, 19 | } 20 | -------------------------------------------------------------------------------- /cli/src/rule.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use crate::{message::Message, result::Violation}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use self::{ 7 | body_empty::BodyEmpty, body_max_length::BodyMaxLength, description_empty::DescriptionEmpty, 8 | description_format::DescriptionFormat, description_max_length::DescriptionMaxLength, 9 | footers_empty::FootersEmpty, r#type::Type, scope::Scope, scope_empty::ScopeEmpty, 10 | scope_format::ScopeFormat, scope_max_length::ScopeMaxLength, subject_empty::SubjectEmpty, 11 | type_empty::TypeEmpty, type_format::TypeFormat, type_max_length::TypeMaxLength, 12 | }; 13 | 14 | pub mod body_empty; 15 | pub mod body_max_length; 16 | pub mod description_empty; 17 | pub mod description_format; 18 | pub mod description_max_length; 19 | pub mod footers_empty; 20 | pub mod scope; 21 | pub mod scope_empty; 22 | pub mod scope_format; 23 | pub mod scope_max_length; 24 | pub mod subject_empty; 25 | pub mod r#type; 26 | pub mod type_empty; 27 | pub mod type_format; 28 | pub mod type_max_length; 29 | 30 | /// Rules represents the rules of commitlint. 31 | /// See: https://commitlint.js.org/reference/rules.html 32 | #[derive(Clone, Debug, Deserialize, Serialize)] 33 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 34 | pub struct Rules { 35 | #[serde(rename = "body-empty")] 36 | #[serde(skip_serializing_if = "Option::is_none")] 37 | pub body_empty: Option, 38 | 39 | #[serde(rename = "body-max-length")] 40 | #[serde(skip_serializing_if = "Option::is_none")] 41 | pub body_max_length: Option, 42 | 43 | #[serde(rename = "description-empty")] 44 | #[serde(skip_serializing_if = "Option::is_none")] 45 | pub description_empty: Option, 46 | 47 | #[serde(rename = "description-format")] 48 | #[serde(skip_serializing_if = "Option::is_none")] 49 | pub description_format: Option, 50 | 51 | #[serde(rename = "description-max-length")] 52 | #[serde(skip_serializing_if = "Option::is_none")] 53 | pub description_max_length: Option, 54 | 55 | #[serde(rename = "footers-empty")] 56 | #[serde(skip_serializing_if = "Option::is_none")] 57 | pub footers_empty: Option, 58 | 59 | #[serde(rename = "scope")] 60 | #[serde(skip_serializing_if = "Option::is_none")] 61 | pub scope: Option, 62 | 63 | #[serde(rename = "scope-empty")] 64 | #[serde(skip_serializing_if = "Option::is_none")] 65 | pub scope_empty: Option, 66 | 67 | #[serde(rename = "scope-format")] 68 | #[serde(skip_serializing_if = "Option::is_none")] 69 | pub scope_format: Option, 70 | 71 | #[serde(rename = "scope-max-length")] 72 | #[serde(skip_serializing_if = "Option::is_none")] 73 | pub scope_max_length: Option, 74 | 75 | #[serde(rename = "subject-empty")] 76 | #[serde(skip_serializing_if = "Option::is_none")] 77 | pub subject_empty: Option, 78 | 79 | #[serde(rename = "type")] 80 | #[serde(skip_serializing_if = "Option::is_none")] 81 | pub r#type: Option, 82 | 83 | #[serde(rename = "type-empty")] 84 | #[serde(skip_serializing_if = "Option::is_none")] 85 | pub type_empty: Option, 86 | 87 | #[serde(rename = "type-format")] 88 | #[serde(skip_serializing_if = "Option::is_none")] 89 | pub type_format: Option, 90 | 91 | #[serde(rename = "type-max-length")] 92 | #[serde(skip_serializing_if = "Option::is_none")] 93 | pub type_max_length: Option, 94 | } 95 | 96 | /// Rule is a collection of rules. 97 | impl Rules { 98 | pub fn validate(&self, message: &Message) -> Vec { 99 | let mut results = Vec::new(); 100 | 101 | if let Some(rule) = &self.body_empty { 102 | if let Some(validation) = rule.validate(message) { 103 | results.push(validation); 104 | } 105 | } 106 | 107 | if let Some(rule) = &self.body_max_length { 108 | if let Some(validation) = rule.validate(message) { 109 | results.push(validation); 110 | } 111 | } 112 | 113 | if let Some(rule) = &self.description_empty { 114 | if let Some(validation) = rule.validate(message) { 115 | results.push(validation); 116 | } 117 | } 118 | 119 | if let Some(rule) = &self.description_format { 120 | if let Some(validation) = rule.validate(message) { 121 | results.push(validation); 122 | } 123 | } 124 | 125 | if let Some(rule) = &self.description_max_length { 126 | if let Some(validation) = rule.validate(message) { 127 | results.push(validation); 128 | } 129 | } 130 | 131 | if let Some(rule) = &self.scope { 132 | if let Some(validation) = rule.validate(message) { 133 | results.push(validation); 134 | } 135 | } 136 | 137 | if let Some(rule) = &self.scope_empty { 138 | if let Some(validation) = rule.validate(message) { 139 | results.push(validation); 140 | } 141 | } 142 | 143 | if let Some(rule) = &self.scope_format { 144 | if let Some(validation) = rule.validate(message) { 145 | results.push(validation); 146 | } 147 | } 148 | 149 | if let Some(rule) = &self.scope_max_length { 150 | if let Some(validation) = rule.validate(message) { 151 | results.push(validation); 152 | } 153 | } 154 | 155 | if let Some(rule) = &self.subject_empty { 156 | if let Some(validation) = rule.validate(message) { 157 | results.push(validation); 158 | } 159 | } 160 | 161 | if let Some(rule) = &self.r#type { 162 | if let Some(validation) = rule.validate(message) { 163 | results.push(validation); 164 | } 165 | } 166 | 167 | if let Some(rule) = &self.type_empty { 168 | if let Some(validation) = rule.validate(message) { 169 | results.push(validation); 170 | } 171 | } 172 | 173 | if let Some(rule) = &self.type_format { 174 | if let Some(validation) = rule.validate(message) { 175 | results.push(validation); 176 | } 177 | } 178 | 179 | if let Some(rule) = &self.type_max_length { 180 | if let Some(validation) = rule.validate(message) { 181 | results.push(validation); 182 | } 183 | } 184 | 185 | results 186 | } 187 | } 188 | 189 | /// Default implementation of Rules. 190 | /// If no config files are specified, this will be used. 191 | impl Default for Rules { 192 | fn default() -> Self { 193 | Self { 194 | body_empty: None, 195 | body_max_length: None, 196 | description_empty: DescriptionEmpty::default().into(), 197 | description_format: None, 198 | description_max_length: None, 199 | footers_empty: None, 200 | scope: None, 201 | scope_empty: None, 202 | scope_format: None, 203 | scope_max_length: None, 204 | subject_empty: SubjectEmpty::default().into(), 205 | r#type: None, 206 | type_empty: TypeEmpty::default().into(), 207 | type_format: None, 208 | type_max_length: None, 209 | } 210 | } 211 | } 212 | 213 | /// Rule trait represents a rule that can be applied to a text. 214 | pub trait Rule: Default { 215 | /// The name of the rule. 216 | /// Note that it should be unique 217 | const NAME: &'static str; 218 | 219 | /// The message to display when the rule fails. 220 | fn message(&self, message: &Message) -> String; 221 | 222 | /// The level of the rule. 223 | const LEVEL: Level; 224 | 225 | /// Validate the given text. 226 | fn validate(&self, message: &Message) -> Option; 227 | } 228 | 229 | /// Level represents the level of a rule. 230 | #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] 231 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 232 | pub enum Level { 233 | #[serde(rename = "error")] 234 | Error, 235 | 236 | #[serde(rename = "ignore")] 237 | Ignore, 238 | 239 | #[serde(rename = "warning")] 240 | Warning, 241 | } 242 | -------------------------------------------------------------------------------- /cli/src/rule/body_empty.rs: -------------------------------------------------------------------------------- 1 | use crate::{message::Message, result::Violation, rule::Rule}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Level; 5 | 6 | /// BodyEmpty represents the body-empty rule. 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 9 | pub struct BodyEmpty { 10 | /// Level represents the level of the rule. 11 | /// 12 | // Note that currently the default literal is not supported. 13 | // See: https://github.com/serde-rs/serde/issues/368 14 | level: Option, 15 | } 16 | 17 | /// BodyEmpty represents the body-empty rule. 18 | impl Rule for BodyEmpty { 19 | const NAME: &'static str = "body-empty"; 20 | const LEVEL: Level = Level::Error; 21 | 22 | fn message(&self, _message: &Message) -> String { 23 | "body is empty".to_string() 24 | } 25 | 26 | fn validate(&self, message: &Message) -> Option { 27 | if message.body.is_none() { 28 | return Some(Violation { 29 | level: self.level.unwrap_or(Self::LEVEL), 30 | message: self.message(message), 31 | }); 32 | } 33 | 34 | None 35 | } 36 | } 37 | 38 | /// Default implementation of BodyEmpty. 39 | impl Default for BodyEmpty { 40 | fn default() -> Self { 41 | Self { 42 | level: Some(Self::LEVEL), 43 | } 44 | } 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use super::*; 50 | 51 | #[test] 52 | fn test_non_empty_body() { 53 | let rule = BodyEmpty::default(); 54 | let message = Message { 55 | body: Some("Hello world".to_string()), 56 | description: Some("broadcast $destroy event on scope destruction".to_string()), 57 | footers: None, 58 | r#type: Some("feat".to_string()), 59 | raw: "feat(scope): broadcast $destroy event on scope destruction 60 | 61 | Hello world" 62 | .to_string(), 63 | scope: Some("scope".to_string()), 64 | subject: Some("feat(scope): broadcast $destroy event on scope destruction".to_string()), 65 | }; 66 | 67 | assert!(rule.validate(&message).is_none()); 68 | } 69 | 70 | #[test] 71 | fn test_empty_body() { 72 | let rule = BodyEmpty::default(); 73 | let message = Message { 74 | body: None, 75 | description: None, 76 | footers: None, 77 | r#type: Some("feat".to_string()), 78 | raw: "feat(scope): broadcast $destroy event on scope destruction".to_string(), 79 | scope: Some("scope".to_string()), 80 | subject: None, 81 | }; 82 | 83 | let violation = rule.validate(&message); 84 | assert!(violation.is_some()); 85 | assert_eq!(violation.clone().unwrap().level, Level::Error); 86 | assert_eq!(violation.unwrap().message, "body is empty".to_string()); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /cli/src/rule/body_max_length.rs: -------------------------------------------------------------------------------- 1 | use crate::{message::Message, result::Violation, rule::Rule}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Level; 5 | 6 | /// BodyMaxLength represents the body-max-length rule. 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 9 | pub struct BodyMaxLength { 10 | /// Level represents the level of the rule. 11 | /// 12 | // Note that currently the default literal is not supported. 13 | // See: https://github.com/serde-rs/serde/issues/368 14 | level: Option, 15 | 16 | /// Length represents the maximum length of the body. 17 | length: usize, 18 | } 19 | 20 | /// BodyMaxLength represents the body-max-length rule. 21 | impl Rule for BodyMaxLength { 22 | const NAME: &'static str = "body-max-length"; 23 | const LEVEL: Level = Level::Error; 24 | 25 | fn message(&self, _message: &Message) -> String { 26 | format!("body is longer than {} characters", self.length) 27 | } 28 | 29 | fn validate(&self, message: &Message) -> Option { 30 | match &message.body { 31 | Some(body) => { 32 | if body.len() >= self.length { 33 | return Some(Violation { 34 | level: self.level.unwrap_or(Self::LEVEL), 35 | message: self.message(message), 36 | }); 37 | } 38 | } 39 | None => { 40 | return Some(Violation { 41 | level: self.level.unwrap_or(Self::LEVEL), 42 | message: self.message(message), 43 | }) 44 | } 45 | } 46 | 47 | None 48 | } 49 | } 50 | 51 | /// Default implementation of BodyMaxLength. 52 | impl Default for BodyMaxLength { 53 | fn default() -> Self { 54 | Self { 55 | level: Some(Self::LEVEL), 56 | length: 72, 57 | } 58 | } 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | use super::*; 64 | 65 | #[test] 66 | fn test_long_body() { 67 | let rule = BodyMaxLength { 68 | length: usize::MAX, // Long length for testing 69 | ..Default::default() 70 | }; 71 | let message = Message { 72 | body: Some("Hello world".to_string()), 73 | description: Some("broadcast $destroy event on scope destruction".to_string()), 74 | footers: None, 75 | r#type: Some("feat".to_string()), 76 | raw: "feat(scope): broadcast $destroy event on scope destruction 77 | 78 | Hey!" 79 | .to_string(), 80 | scope: Some("scope".to_string()), 81 | subject: Some("feat(scope): broadcast $destroy event on scope destruction".to_string()), 82 | }; 83 | 84 | assert!(rule.validate(&message).is_none()); 85 | } 86 | 87 | #[test] 88 | fn test_short_body() { 89 | let rule = BodyMaxLength { 90 | length: 10, // Short length for testing 91 | ..Default::default() 92 | }; 93 | let message = Message { 94 | body: Some("Hello, I'm a long body".to_string()), 95 | description: None, 96 | footers: None, 97 | r#type: Some("feat".to_string()), 98 | raw: "feat(scope): broadcast $destroy event on scope destruction 99 | 100 | Hello, I'm a long body" 101 | .to_string(), 102 | scope: Some("scope".to_string()), 103 | subject: None, 104 | }; 105 | 106 | let violation = rule.validate(&message); 107 | assert!(violation.is_some()); 108 | assert_eq!(violation.clone().unwrap().level, Level::Error); 109 | assert_eq!( 110 | violation.unwrap().message, 111 | format!("body is longer than {} characters", rule.length) 112 | ); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /cli/src/rule/description_empty.rs: -------------------------------------------------------------------------------- 1 | use crate::{message::Message, result::Violation, rule::Rule}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Level; 5 | 6 | /// DescriptionEmpty represents the subject-empty rule. 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 9 | pub struct DescriptionEmpty { 10 | /// Level represents the level of the rule. 11 | /// 12 | // Note that currently the default literal is not supported. 13 | // See: https://github.com/serde-rs/serde/issues/368 14 | level: Option, 15 | } 16 | 17 | /// DescriptionEmpty represents the description-empty rule. 18 | impl Rule for DescriptionEmpty { 19 | const NAME: &'static str = "description-empty"; 20 | const LEVEL: Level = Level::Error; 21 | 22 | fn message(&self, _message: &Message) -> String { 23 | "description is empty or missing space in the beginning".to_string() 24 | } 25 | 26 | fn validate(&self, message: &Message) -> Option { 27 | match message.description { 28 | None => Some(Violation { 29 | level: self.level.unwrap_or(Self::LEVEL), 30 | message: self.message(message), 31 | }), 32 | Some(ref desc) if desc.is_empty() => Some(Violation { 33 | level: self.level.unwrap_or(Self::LEVEL), 34 | message: self.message(message), 35 | }), 36 | _ => None, 37 | } 38 | } 39 | } 40 | 41 | /// Default implementation of DescriptionEmpty. 42 | impl Default for DescriptionEmpty { 43 | fn default() -> Self { 44 | Self { 45 | level: Some(Self::LEVEL), 46 | } 47 | } 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | use super::*; 53 | 54 | #[test] 55 | fn test_non_empty_description() { 56 | let rule = DescriptionEmpty::default(); 57 | let message = Message { 58 | body: None, 59 | description: Some("broadcast $destroy event on scope destruction".to_string()), 60 | footers: None, 61 | r#type: Some("feat".to_string()), 62 | raw: "feat(scope): broadcast $destroy event on scope destruction".to_string(), 63 | scope: Some("scope".to_string()), 64 | subject: None, 65 | }; 66 | 67 | assert!(rule.validate(&message).is_none()); 68 | } 69 | 70 | #[test] 71 | fn test_empty_description() { 72 | let rule = DescriptionEmpty::default(); 73 | let message = Message { 74 | body: None, 75 | description: None, 76 | footers: None, 77 | r#type: Some("feat".to_string()), 78 | raw: "(scope):".to_string(), 79 | scope: Some("scope".to_string()), 80 | subject: None, 81 | }; 82 | 83 | let violation = rule.validate(&message); 84 | assert!(violation.is_some()); 85 | assert_eq!(violation.clone().unwrap().level, Level::Error); 86 | assert_eq!( 87 | violation.unwrap().message, 88 | "description is empty or missing space in the beginning".to_string() 89 | ); 90 | } 91 | 92 | #[test] 93 | fn test_blank_description() { 94 | let rule = DescriptionEmpty::default(); 95 | let message = Message { 96 | body: None, 97 | description: Some("".to_string()), 98 | footers: None, 99 | r#type: Some("feat".to_string()), 100 | raw: "(scope):".to_string(), 101 | scope: Some("scope".to_string()), 102 | subject: None, 103 | }; 104 | 105 | let violation = rule.validate(&message); 106 | assert!(violation.is_some()); 107 | assert_eq!(violation.clone().unwrap().level, Level::Error); 108 | assert_eq!( 109 | violation.unwrap().message, 110 | "description is empty or missing space in the beginning".to_string() 111 | ); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /cli/src/rule/description_format.rs: -------------------------------------------------------------------------------- 1 | use crate::{message::Message, result::Violation, rule::Rule}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Level; 5 | 6 | /// DescriptionFormat represents the description-format rule. 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 9 | pub struct DescriptionFormat { 10 | /// Level represents the level of the rule. 11 | /// 12 | // Note that currently the default literal is not supported. 13 | // See: https://github.com/serde-rs/serde/issues/368 14 | level: Option, 15 | 16 | /// Format represents the format of the description. 17 | format: Option, 18 | } 19 | 20 | /// DescriptionFormat represents the description-format rule. 21 | impl Rule for DescriptionFormat { 22 | const NAME: &'static str = "description-format"; 23 | const LEVEL: Level = Level::Error; 24 | 25 | fn message(&self, _message: &Message) -> String { 26 | format!( 27 | "description format does not match format: {}", 28 | self.format.as_ref().unwrap() 29 | ) 30 | } 31 | 32 | fn validate(&self, message: &Message) -> Option { 33 | if let Some(format) = &self.format { 34 | let regex = match regex::Regex::new(format) { 35 | Ok(regex) => regex, 36 | Err(err) => { 37 | return Some(Violation { 38 | level: self.level.unwrap_or(Self::LEVEL), 39 | message: err.to_string(), 40 | }); 41 | } 42 | }; 43 | 44 | match &message.description { 45 | None => { 46 | return Some(Violation { 47 | level: self.level.unwrap_or(Self::LEVEL), 48 | message: "found no description".to_string(), 49 | }); 50 | } 51 | Some(description) => { 52 | if !regex.is_match(description) { 53 | return Some(Violation { 54 | level: self.level.unwrap_or(Self::LEVEL), 55 | message: self.message(message), 56 | }); 57 | } 58 | } 59 | } 60 | } 61 | 62 | None 63 | } 64 | } 65 | 66 | /// Default implementation of DescriptionFormat. 67 | impl Default for DescriptionFormat { 68 | fn default() -> Self { 69 | Self { 70 | level: Some(Self::LEVEL), 71 | format: None, 72 | } 73 | } 74 | } 75 | 76 | #[cfg(test)] 77 | mod tests { 78 | use super::*; 79 | 80 | #[test] 81 | fn test_invalid_description_format() { 82 | let rule = DescriptionFormat { 83 | format: Some(r"^[a-z].*".to_string()), 84 | ..Default::default() 85 | }; 86 | 87 | let message = Message { 88 | body: None, 89 | description: Some("add new flag".to_string()), 90 | footers: None, 91 | r#type: Some("feat".to_string()), 92 | raw: "feat(scope): add new flag".to_string(), 93 | scope: Some("scope".to_string()), 94 | subject: None, 95 | }; 96 | 97 | assert!(rule.validate(&message).is_none()); 98 | } 99 | 100 | #[test] 101 | fn test_valid_description_format() { 102 | let rule = DescriptionFormat { 103 | format: Some(r"^[a-z].*".to_string()), 104 | ..Default::default() 105 | }; 106 | 107 | let message = Message { 108 | body: None, 109 | description: Some("Add new flag".to_string()), 110 | footers: None, 111 | r#type: Some("feat".to_string()), 112 | raw: "feat(scope): Add new flag".to_string(), 113 | scope: Some("scope".to_string()), 114 | subject: None, 115 | }; 116 | 117 | let violation = rule.validate(&message); 118 | assert!(violation.is_some()); 119 | assert_eq!(violation.clone().unwrap().level, Level::Error); 120 | assert_eq!( 121 | violation.unwrap().message, 122 | "description format does not match format: ^[a-z].*".to_string() 123 | ); 124 | } 125 | 126 | #[test] 127 | fn test_invalid_regex() { 128 | let rule = DescriptionFormat { 129 | format: Some(r"(".to_string()), 130 | ..Default::default() 131 | }; 132 | 133 | let message = Message { 134 | body: None, 135 | description: Some("Add regex".to_string()), 136 | footers: None, 137 | r#type: Some("feat".to_string()), 138 | raw: "feat(scope): Add regex".to_string(), 139 | scope: Some("scope".to_string()), 140 | subject: None, 141 | }; 142 | 143 | let violation = rule.validate(&message); 144 | assert!(violation.is_some()); 145 | assert_eq!(violation.clone().unwrap().level, Level::Error); 146 | assert!(violation.unwrap().message.contains("regex parse error")); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /cli/src/rule/description_max_length.rs: -------------------------------------------------------------------------------- 1 | use crate::{message::Message, result::Violation, rule::Rule}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Level; 5 | 6 | /// DescriptionMaxLength represents the description-max-length rule. 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 9 | pub struct DescriptionMaxLength { 10 | /// Level represents the level of the rule. 11 | /// 12 | // Note that currently the default literal is not supported. 13 | // See: https://github.com/serde-rs/serde/issues/368 14 | level: Option, 15 | 16 | /// Length represents the maximum length of the description. 17 | length: usize, 18 | } 19 | 20 | /// DescriptionMaxLength represents the description-max-length rule. 21 | impl Rule for DescriptionMaxLength { 22 | const NAME: &'static str = "description-max-length"; 23 | const LEVEL: Level = Level::Error; 24 | 25 | fn message(&self, _message: &Message) -> String { 26 | format!("description is longer than {} characters", self.length) 27 | } 28 | 29 | fn validate(&self, message: &Message) -> Option { 30 | if let Some(desc) = &message.description { 31 | if desc.len() >= self.length { 32 | return Some(Violation { 33 | level: self.level.unwrap_or(Self::LEVEL), 34 | message: self.message(message), 35 | }); 36 | } 37 | } 38 | 39 | None 40 | } 41 | } 42 | 43 | /// Default implementation of DescriptionMaxLength. 44 | impl Default for DescriptionMaxLength { 45 | fn default() -> Self { 46 | Self { 47 | level: Some(Self::LEVEL), 48 | length: 72, 49 | } 50 | } 51 | } 52 | 53 | #[cfg(test)] 54 | mod tests { 55 | use super::*; 56 | 57 | #[test] 58 | fn test_long_description() { 59 | let rule = DescriptionMaxLength { 60 | length: usize::MAX, // Long length for testing 61 | ..Default::default() 62 | }; 63 | let message = Message { 64 | body: None, 65 | description: Some("desc".to_string()), 66 | footers: None, 67 | r#type: Some("feat".to_string()), 68 | raw: "feat(scope): desc".to_string(), 69 | scope: Some("scope".to_string()), 70 | subject: Some("feat(scope): desc".to_string()), 71 | }; 72 | 73 | assert!(rule.validate(&message).is_none()); 74 | } 75 | 76 | #[test] 77 | fn test_empty_description() { 78 | let rule = DescriptionMaxLength { 79 | length: 10, // Short length for testing 80 | ..Default::default() 81 | }; 82 | let message = Message { 83 | body: None, 84 | description: None, 85 | footers: None, 86 | r#type: Some("feat".to_string()), 87 | raw: "feat(scope)".to_string(), 88 | scope: Some("scope".to_string()), 89 | subject: None, 90 | }; 91 | 92 | assert!(rule.validate(&message).is_none()); 93 | } 94 | 95 | #[test] 96 | fn test_short_description() { 97 | let rule = DescriptionMaxLength { 98 | length: 10, // Short length for testing 99 | ..Default::default() 100 | }; 101 | let message = Message { 102 | body: None, 103 | description: Some("feat(scope): I'm long description".to_string()), 104 | footers: None, 105 | r#type: Some("feat".to_string()), 106 | raw: "feat(scope): I'm long description".to_string(), 107 | scope: Some("scope".to_string()), 108 | subject: None, 109 | }; 110 | 111 | let violation = rule.validate(&message); 112 | assert!(violation.is_some()); 113 | assert_eq!(violation.clone().unwrap().level, Level::Error); 114 | assert_eq!( 115 | violation.unwrap().message, 116 | format!("description is longer than {} characters", rule.length) 117 | ); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /cli/src/rule/footers_empty.rs: -------------------------------------------------------------------------------- 1 | use crate::{message::Message, result::Violation, rule::Rule}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Level; 5 | 6 | /// FootersEmpty represents the footer-empty rule. 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 9 | pub struct FootersEmpty { 10 | /// Level represents the level of the rule. 11 | /// 12 | // Note that currently the default literal is not supported. 13 | // See: https://github.com/serde-rs/serde/issues/368 14 | level: Option, 15 | } 16 | 17 | /// FooterEmpty represents the footer-empty rule. 18 | impl Rule for FootersEmpty { 19 | const NAME: &'static str = "footers-empty"; 20 | const LEVEL: Level = Level::Error; 21 | 22 | fn message(&self, _message: &Message) -> String { 23 | "footers are empty".to_string() 24 | } 25 | 26 | fn validate(&self, message: &Message) -> Option { 27 | if message.footers.is_none() { 28 | return Some(Violation { 29 | level: self.level.unwrap_or(Self::LEVEL), 30 | message: self.message(message), 31 | }); 32 | } 33 | 34 | None 35 | } 36 | } 37 | 38 | /// Default implementation of FooterEmpty. 39 | impl Default for FootersEmpty { 40 | fn default() -> Self { 41 | Self { 42 | level: Some(Self::LEVEL), 43 | } 44 | } 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use std::collections::HashMap; 50 | 51 | use super::*; 52 | 53 | #[test] 54 | fn test_non_empty_footer() { 55 | let rule = FootersEmpty::default(); 56 | 57 | let mut f = HashMap::new(); 58 | f.insert("Link".to_string(), "hello".to_string()); 59 | 60 | let message = Message { 61 | body: Some("Hello world".to_string()), 62 | description: Some("broadcast $destroy event on scope destruction".to_string()), 63 | footers: Some(f), 64 | r#type: Some("feat".to_string()), 65 | raw: "feat(scope): broadcast $destroy event on scope destruction 66 | 67 | Hello world 68 | 69 | Link: hello" 70 | .to_string(), 71 | scope: Some("scope".to_string()), 72 | subject: Some("feat(scope): broadcast $destroy event on scope destruction".to_string()), 73 | }; 74 | 75 | assert!(rule.validate(&message).is_none()); 76 | } 77 | 78 | #[test] 79 | fn test_empty_footer() { 80 | let rule = FootersEmpty::default(); 81 | let message = Message { 82 | body: None, 83 | description: None, 84 | footers: None, 85 | r#type: Some("feat".to_string()), 86 | raw: "feat(scope): broadcast $destroy event on scope destruction".to_string(), 87 | scope: Some("scope".to_string()), 88 | subject: None, 89 | }; 90 | 91 | let violation = rule.validate(&message); 92 | assert!(violation.is_some()); 93 | assert_eq!(violation.clone().unwrap().level, Level::Error); 94 | assert_eq!(violation.unwrap().message, "footers are empty".to_string()); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /cli/src/rule/scope.rs: -------------------------------------------------------------------------------- 1 | use crate::{message::Message, result::Violation, rule::Rule}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Level; 5 | 6 | /// Scope represents the subject-empty rule. 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 9 | pub struct Scope { 10 | /// Level represents the level of the rule. 11 | /// 12 | // Note that currently the default literal is not supported. 13 | // See: https://github.com/serde-rs/serde/issues/368 14 | level: Option, 15 | 16 | /// Options represents the options of the rule. 17 | /// If the option is empty, it means that no scope is allowed. 18 | options: Vec, 19 | 20 | /// Optional scope. 21 | /// If true, even if the scope is not present, it is allowed. 22 | optional: bool, 23 | } 24 | 25 | /// Scope represents the scope rule. 26 | impl Rule for Scope { 27 | const NAME: &'static str = "scope"; 28 | const LEVEL: Level = Level::Error; 29 | 30 | fn message(&self, message: &Message) -> String { 31 | if self.options.is_empty() { 32 | return "scopes are not allowed".to_string(); 33 | } 34 | 35 | format!( 36 | "scope {} is not allowed. Only {:?} are allowed", 37 | message.scope.as_ref().unwrap_or(&"".to_string()), 38 | self.options 39 | ) 40 | } 41 | 42 | fn validate(&self, message: &Message) -> Option { 43 | match &message.scope { 44 | None => { 45 | if self.options.is_empty() || self.optional { 46 | return None; 47 | } 48 | } 49 | Some(scope) if scope.is_empty() => { 50 | if self.options.is_empty() { 51 | return None; 52 | } 53 | } 54 | Some(scope) if self.options.contains(scope) => { 55 | return None; 56 | } 57 | _ => {} 58 | } 59 | 60 | Some(Violation { 61 | level: self.level.unwrap_or(Self::LEVEL), 62 | message: self.message(message), 63 | }) 64 | } 65 | } 66 | 67 | /// Default implementation of Scope. 68 | impl Default for Scope { 69 | fn default() -> Self { 70 | Self { 71 | level: Some(Self::LEVEL), 72 | optional: false, 73 | options: vec![], 74 | } 75 | } 76 | } 77 | 78 | #[cfg(test)] 79 | mod tests { 80 | use super::*; 81 | 82 | mod empty_options { 83 | use super::*; 84 | 85 | #[test] 86 | fn test_empty_scope() { 87 | let rule = Scope::default(); 88 | 89 | let message = Message { 90 | body: None, 91 | description: None, 92 | footers: None, 93 | r#type: None, 94 | raw: "".to_string(), 95 | scope: Some("".to_string()), 96 | subject: None, 97 | }; 98 | 99 | let violation = rule.validate(&message); 100 | assert!(violation.is_none()); 101 | } 102 | 103 | #[test] 104 | fn test_none_scope() { 105 | let rule = Scope::default(); 106 | 107 | let message = Message { 108 | body: None, 109 | description: None, 110 | footers: None, 111 | r#type: None, 112 | raw: "".to_string(), 113 | scope: None, 114 | subject: None, 115 | }; 116 | 117 | let violation = rule.validate(&message); 118 | assert!(violation.is_none()); 119 | } 120 | 121 | #[test] 122 | fn test_scope() { 123 | let rule = Scope::default(); 124 | 125 | let message = Message { 126 | body: None, 127 | description: None, 128 | footers: None, 129 | r#type: Some("feat".to_string()), 130 | raw: "feat(web): broadcast $destroy event on scope destruction".to_string(), 131 | scope: Some("web".to_string()), 132 | subject: None, 133 | }; 134 | 135 | let violation = rule.validate(&message); 136 | assert!(violation.is_some()); 137 | assert_eq!(violation.clone().unwrap().level, Level::Error); 138 | assert_eq!( 139 | violation.unwrap().message, 140 | "scopes are not allowed".to_string() 141 | ); 142 | } 143 | } 144 | 145 | mod scopes { 146 | use super::*; 147 | #[test] 148 | fn test_empty_scope() { 149 | let rule = Scope { 150 | options: vec!["api".to_string(), "web".to_string()], 151 | ..Default::default() 152 | }; 153 | 154 | let message = Message { 155 | body: None, 156 | description: None, 157 | footers: None, 158 | r#type: None, 159 | raw: "".to_string(), 160 | scope: Some("".to_string()), 161 | subject: None, 162 | }; 163 | 164 | let violation = rule.validate(&message); 165 | assert!(violation.is_some()); 166 | assert_eq!(violation.clone().unwrap().level, Level::Error); 167 | assert_eq!( 168 | violation.unwrap().message, 169 | "scope is not allowed. Only [\"api\", \"web\"] are allowed" 170 | ); 171 | } 172 | 173 | #[test] 174 | fn test_none_scope() { 175 | let rule = Scope { 176 | options: vec!["api".to_string(), "web".to_string()], 177 | ..Default::default() 178 | }; 179 | 180 | let message = Message { 181 | body: None, 182 | description: None, 183 | footers: None, 184 | r#type: None, 185 | raw: "".to_string(), 186 | scope: None, 187 | subject: None, 188 | }; 189 | 190 | let violation = rule.validate(&message); 191 | assert!(violation.is_some()); 192 | assert_eq!(violation.clone().unwrap().level, Level::Error); 193 | assert_eq!( 194 | violation.unwrap().message, 195 | "scope is not allowed. Only [\"api\", \"web\"] are allowed".to_string() 196 | ); 197 | } 198 | 199 | #[test] 200 | fn test_valid_scope() { 201 | let rule = Scope { 202 | options: vec!["api".to_string(), "web".to_string()], 203 | ..Default::default() 204 | }; 205 | 206 | let message = Message { 207 | body: None, 208 | description: None, 209 | footers: None, 210 | r#type: Some("feat".to_string()), 211 | raw: "feat(web): broadcast $destroy event on scope destruction".to_string(), 212 | scope: Some("web".to_string()), 213 | subject: None, 214 | }; 215 | 216 | assert!(rule.validate(&message).is_none()); 217 | } 218 | 219 | #[test] 220 | fn test_invalid_scope() { 221 | let rule = Scope { 222 | options: vec!["api".to_string(), "web".to_string()], 223 | ..Default::default() 224 | }; 225 | 226 | let message = Message { 227 | body: None, 228 | description: None, 229 | footers: None, 230 | r#type: Some("feat".to_string()), 231 | raw: "feat(invalid): broadcast $destroy event on scope destruction".to_string(), 232 | scope: Some("invalid".to_string()), 233 | subject: None, 234 | }; 235 | 236 | let violation = rule.validate(&message); 237 | assert!(violation.is_some()); 238 | assert_eq!(violation.clone().unwrap().level, Level::Error); 239 | assert_eq!( 240 | violation.unwrap().message, 241 | "scope invalid is not allowed. Only [\"api\", \"web\"] are allowed".to_string() 242 | ); 243 | } 244 | 245 | #[test] 246 | fn test_optional_scope_with_non_empty_scope() { 247 | let rule = Scope { 248 | options: vec!["api".to_string(), "web".to_string()], 249 | optional: true, 250 | ..Default::default() 251 | }; 252 | 253 | let message = Message { 254 | body: None, 255 | description: None, 256 | footers: None, 257 | r#type: Some("feat".to_string()), 258 | raw: "feat(invalid): broadcast $destroy event on scope destruction".to_string(), 259 | scope: Some("invalid".to_string()), 260 | subject: None, 261 | }; 262 | 263 | let violation = rule.validate(&message); 264 | assert!(violation.is_some()); 265 | assert_eq!(violation.clone().unwrap().level, Level::Error); 266 | assert_eq!( 267 | violation.unwrap().message, 268 | "scope invalid is not allowed. Only [\"api\", \"web\"] are allowed".to_string() 269 | ); 270 | } 271 | 272 | #[test] 273 | fn test_optional_scope_with_empty_scope() { 274 | let rule = Scope { 275 | options: vec!["api".to_string(), "web".to_string()], 276 | optional: true, 277 | ..Default::default() 278 | }; 279 | 280 | let message = Message { 281 | body: None, 282 | description: None, 283 | footers: None, 284 | r#type: Some("feat".to_string()), 285 | raw: "feat: broadcast $destroy event on scope destruction".to_string(), 286 | scope: None, 287 | subject: None, 288 | }; 289 | 290 | let violation = rule.validate(&message); 291 | assert!(violation.is_none()); 292 | } 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /cli/src/rule/scope_empty.rs: -------------------------------------------------------------------------------- 1 | use crate::{message::Message, result::Violation, rule::Rule}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Level; 5 | 6 | /// ScopeEmpty represents the subject-empty rule. 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 9 | pub struct ScopeEmpty { 10 | /// Level represents the level of the rule. 11 | /// 12 | // Note that currently the default literal is not supported. 13 | // See: https://github.com/serde-rs/serde/issues/368 14 | level: Option, 15 | } 16 | 17 | /// ScopeEmpty represents the scope-empty rule. 18 | impl Rule for ScopeEmpty { 19 | const NAME: &'static str = "scope-empty"; 20 | const LEVEL: Level = Level::Error; 21 | 22 | fn message(&self, _message: &Message) -> String { 23 | "scope is empty".to_string() 24 | } 25 | 26 | fn validate(&self, message: &Message) -> Option { 27 | if message.scope.is_none() { 28 | return Some(Violation { 29 | level: self.level.unwrap_or(Self::LEVEL), 30 | message: self.message(message), 31 | }); 32 | } 33 | 34 | None 35 | } 36 | } 37 | 38 | /// Default implementation of ScopeEmpty. 39 | impl Default for ScopeEmpty { 40 | fn default() -> Self { 41 | Self { 42 | level: Some(Self::LEVEL), 43 | } 44 | } 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use super::*; 50 | 51 | #[test] 52 | fn test_non_empty_subject() { 53 | let rule = ScopeEmpty::default(); 54 | let message = Message { 55 | body: None, 56 | description: None, 57 | footers: None, 58 | r#type: Some("feat".to_string()), 59 | raw: "feat(scope): broadcast $destroy event on scope destruction".to_string(), 60 | scope: Some("scope".to_string()), 61 | subject: None, 62 | }; 63 | 64 | assert!(rule.validate(&message).is_none()); 65 | } 66 | 67 | #[test] 68 | fn test_no_subject() { 69 | let rule = ScopeEmpty::default(); 70 | let message = Message { 71 | body: None, 72 | description: None, 73 | footers: None, 74 | r#type: Some("feat".to_string()), 75 | raw: "feat: broadcast $destroy event on scope destruction".to_string(), 76 | scope: None, 77 | subject: None, 78 | }; 79 | 80 | let violation = rule.validate(&message); 81 | assert!(violation.is_some()); 82 | assert_eq!(violation.clone().unwrap().level, Level::Error); 83 | assert_eq!(violation.unwrap().message, "scope is empty".to_string()); 84 | } 85 | 86 | #[test] 87 | fn test_empty_subject() { 88 | let rule = ScopeEmpty::default(); 89 | let message = Message { 90 | body: None, 91 | description: None, 92 | footers: None, 93 | r#type: Some("feat".to_string()), 94 | raw: "feat(): broadcast $destroy event on scope destruction".to_string(), 95 | scope: None, 96 | subject: None, 97 | }; 98 | 99 | let violation = rule.validate(&message); 100 | assert!(violation.is_some()); 101 | assert_eq!(violation.clone().unwrap().level, Level::Error); 102 | assert_eq!(violation.unwrap().message, "scope is empty".to_string()); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /cli/src/rule/scope_format.rs: -------------------------------------------------------------------------------- 1 | use crate::{message::Message, result::Violation, rule::Rule}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Level; 5 | 6 | /// ScopeFormat represents the scope-format rule. 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 9 | pub struct ScopeFormat { 10 | /// Level represents the level of the rule. 11 | /// 12 | // Note that currently the default literal is not supported. 13 | // See: https://github.com/serde-rs/serde/issues/368 14 | level: Option, 15 | 16 | /// Format represents the format of the scope. 17 | format: Option, 18 | } 19 | 20 | /// ScopeFormat represents the scope-format rule. 21 | impl Rule for ScopeFormat { 22 | const NAME: &'static str = "scope-format"; 23 | const LEVEL: Level = Level::Error; 24 | 25 | fn message(&self, _message: &Message) -> String { 26 | format!( 27 | "scope format does not match format: {}", 28 | self.format.as_ref().unwrap() 29 | ) 30 | } 31 | 32 | fn validate(&self, message: &Message) -> Option { 33 | if let Some(format) = &self.format { 34 | let regex = match regex::Regex::new(format) { 35 | Ok(regex) => regex, 36 | Err(err) => { 37 | return Some(Violation { 38 | level: self.level.unwrap_or(Self::LEVEL), 39 | message: err.to_string(), 40 | }); 41 | } 42 | }; 43 | 44 | match &message.scope { 45 | None => { 46 | return Some(Violation { 47 | level: self.level.unwrap_or(Self::LEVEL), 48 | message: "found no scope".to_string(), 49 | }); 50 | } 51 | Some(description) => { 52 | if !regex.is_match(description) { 53 | return Some(Violation { 54 | level: self.level.unwrap_or(Self::LEVEL), 55 | message: self.message(message), 56 | }); 57 | } 58 | } 59 | } 60 | } 61 | 62 | None 63 | } 64 | } 65 | 66 | /// Default implementation of ScopeFormat. 67 | impl Default for ScopeFormat { 68 | fn default() -> Self { 69 | Self { 70 | level: Some(Self::LEVEL), 71 | format: None, 72 | } 73 | } 74 | } 75 | 76 | #[cfg(test)] 77 | mod tests { 78 | use super::*; 79 | 80 | #[test] 81 | fn test_invalid_description_format() { 82 | let rule = ScopeFormat { 83 | format: Some(r"^[a-z].*".to_string()), 84 | ..Default::default() 85 | }; 86 | 87 | let message = Message { 88 | body: None, 89 | description: Some("Add new flag".to_string()), 90 | footers: None, 91 | r#type: Some("feat".to_string()), 92 | raw: "feat(scope): Add new flag".to_string(), 93 | scope: Some("scope".to_string()), 94 | subject: None, 95 | }; 96 | 97 | assert!(rule.validate(&message).is_none()); 98 | } 99 | 100 | #[test] 101 | fn test_valid_description_format() { 102 | let rule = ScopeFormat { 103 | format: Some(r"^[a-z].*".to_string()), 104 | ..Default::default() 105 | }; 106 | 107 | let message = Message { 108 | body: None, 109 | description: Some("Add new flag".to_string()), 110 | footers: None, 111 | r#type: Some("feat".to_string()), 112 | raw: "feat(Scope): Add new flag".to_string(), 113 | scope: Some("Scope".to_string()), 114 | subject: None, 115 | }; 116 | 117 | let violation = rule.validate(&message); 118 | assert!(violation.is_some()); 119 | assert_eq!(violation.clone().unwrap().level, Level::Error); 120 | assert_eq!( 121 | violation.unwrap().message, 122 | "scope format does not match format: ^[a-z].*".to_string() 123 | ); 124 | } 125 | 126 | #[test] 127 | fn test_invalid_regex() { 128 | let rule = ScopeFormat { 129 | format: Some(r"(".to_string()), 130 | ..Default::default() 131 | }; 132 | 133 | let message = Message { 134 | body: None, 135 | description: Some("Add regex".to_string()), 136 | footers: None, 137 | r#type: Some("feat".to_string()), 138 | raw: "feat(scope): Add regex".to_string(), 139 | scope: Some("scope".to_string()), 140 | subject: None, 141 | }; 142 | 143 | let violation = rule.validate(&message); 144 | assert!(violation.is_some()); 145 | assert_eq!(violation.clone().unwrap().level, Level::Error); 146 | assert!(violation.unwrap().message.contains("regex parse error")); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /cli/src/rule/scope_max_length.rs: -------------------------------------------------------------------------------- 1 | use crate::{message::Message, result::Violation, rule::Rule}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Level; 5 | 6 | /// ScopeMaxLength represents the description-max-length rule. 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 9 | pub struct ScopeMaxLength { 10 | /// Level represents the level of the rule. 11 | /// 12 | // Note that currently the default literal is not supported. 13 | // See: https://github.com/serde-rs/serde/issues/368 14 | level: Option, 15 | 16 | /// Length represents the maximum length of the scope. 17 | length: usize, 18 | } 19 | 20 | /// ScopeMaxLength represents the scope-max-length rule. 21 | impl Rule for ScopeMaxLength { 22 | const NAME: &'static str = "scope-max-length"; 23 | const LEVEL: Level = Level::Error; 24 | 25 | fn message(&self, _message: &Message) -> String { 26 | format!("scope is longer than {} characters", self.length) 27 | } 28 | 29 | fn validate(&self, message: &Message) -> Option { 30 | match &message.scope { 31 | Some(scope) => { 32 | if scope.len() >= self.length { 33 | return Some(Violation { 34 | level: self.level.unwrap_or(Self::LEVEL), 35 | message: self.message(message), 36 | }); 37 | } 38 | } 39 | None => { 40 | return Some(Violation { 41 | level: self.level.unwrap_or(Self::LEVEL), 42 | message: self.message(message), 43 | }) 44 | } 45 | } 46 | 47 | None 48 | } 49 | } 50 | 51 | /// Default implementation of ScopeMaxLength. 52 | impl Default for ScopeMaxLength { 53 | fn default() -> Self { 54 | Self { 55 | level: Some(Self::LEVEL), 56 | length: 72, 57 | } 58 | } 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | use super::*; 64 | 65 | #[test] 66 | fn test_long_scope() { 67 | let rule = ScopeMaxLength { 68 | length: usize::MAX, // Long length for testing 69 | ..Default::default() 70 | }; 71 | let message = Message { 72 | body: None, 73 | description: Some("desc".to_string()), 74 | footers: None, 75 | r#type: Some("feat".to_string()), 76 | raw: "feat(scope): desc".to_string(), 77 | scope: Some("scope".to_string()), 78 | subject: Some("feat(scope): desc".to_string()), 79 | }; 80 | 81 | assert!(rule.validate(&message).is_none()); 82 | } 83 | 84 | #[test] 85 | fn test_short_scope() { 86 | let rule = ScopeMaxLength { 87 | length: 3, // Short length for testing 88 | ..Default::default() 89 | }; 90 | let message = Message { 91 | body: None, 92 | description: Some("feat(scope): I'm long description".to_string()), 93 | footers: None, 94 | r#type: Some("feat".to_string()), 95 | raw: "feat(scope): I'm long description".to_string(), 96 | scope: Some("scope".to_string()), 97 | subject: None, 98 | }; 99 | 100 | let violation = rule.validate(&message); 101 | assert!(violation.is_some()); 102 | assert_eq!(violation.clone().unwrap().level, Level::Error); 103 | assert_eq!( 104 | violation.unwrap().message, 105 | format!("scope is longer than {} characters", rule.length) 106 | ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /cli/src/rule/subject_empty.rs: -------------------------------------------------------------------------------- 1 | use crate::{message::Message, result::Violation, rule::Rule}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Level; 5 | 6 | /// SubjectEmpty represents the subject-empty rule. 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 9 | pub struct SubjectEmpty { 10 | /// Level represents the level of the rule. 11 | /// 12 | // Note that currently the default literal is not supported. 13 | // See: https://github.com/serde-rs/serde/issues/368 14 | level: Option, 15 | } 16 | 17 | /// SubjectEmpty represents the subject-empty rule. 18 | impl Rule for SubjectEmpty { 19 | const NAME: &'static str = "subject-empty"; 20 | const LEVEL: Level = Level::Error; 21 | 22 | fn message(&self, _message: &Message) -> String { 23 | "subject is empty".to_string() 24 | } 25 | 26 | fn validate(&self, message: &Message) -> Option { 27 | if message.subject.is_none() { 28 | return Some(Violation { 29 | level: self.level.unwrap_or(Self::LEVEL), 30 | message: self.message(message), 31 | }); 32 | } 33 | 34 | None 35 | } 36 | } 37 | 38 | /// Default implementation of SubjectEmpty. 39 | impl Default for SubjectEmpty { 40 | fn default() -> Self { 41 | Self { 42 | level: Some(Self::LEVEL), 43 | } 44 | } 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use super::*; 50 | 51 | #[test] 52 | fn test_non_empty_subject() { 53 | let rule = SubjectEmpty::default(); 54 | let message = Message { 55 | body: None, 56 | description: Some("broadcast $destroy event on scope destruction".to_string()), 57 | footers: None, 58 | r#type: Some("feat".to_string()), 59 | raw: "feat(scope): broadcast $destroy event on scope destruction 60 | 61 | Hello world" 62 | .to_string(), 63 | scope: Some("scope".to_string()), 64 | subject: Some("feat(scope): broadcast $destroy event on scope destruction".to_string()), 65 | }; 66 | 67 | assert!(rule.validate(&message).is_none()); 68 | } 69 | 70 | #[test] 71 | fn test_empty_description() { 72 | let rule = SubjectEmpty::default(); 73 | let message = Message { 74 | body: None, 75 | description: None, 76 | footers: None, 77 | r#type: Some("feat".to_string()), 78 | raw: " 79 | 80 | Hello world" 81 | .to_string(), 82 | scope: Some("scope".to_string()), 83 | subject: None, 84 | }; 85 | 86 | let violation = rule.validate(&message); 87 | assert!(violation.is_some()); 88 | assert_eq!(violation.clone().unwrap().level, Level::Error); 89 | assert_eq!(violation.unwrap().message, "subject is empty".to_string()); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /cli/src/rule/type.rs: -------------------------------------------------------------------------------- 1 | use crate::{message::Message, result::Violation, rule::Rule}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Level; 5 | 6 | /// Type represents the subject-empty rule. 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 9 | pub struct Type { 10 | /// Level represents the level of the rule. 11 | /// 12 | // Note that currently the default literal is not supported. 13 | // See: https://github.com/serde-rs/serde/issues/368 14 | level: Option, 15 | 16 | /// Options represents the options of the rule. 17 | /// If the option is empty, it means that no Type is allowed. 18 | options: Vec, 19 | } 20 | 21 | /// Type represents the type rule. 22 | impl Rule for Type { 23 | const NAME: &'static str = "type"; 24 | const LEVEL: Level = Level::Error; 25 | fn message(&self, message: &Message) -> String { 26 | if self.options.is_empty() { 27 | return "types are not allowed".to_string(); 28 | } 29 | 30 | format!( 31 | "type {} is not allowed. Only {:?} are allowed", 32 | message.r#type.as_ref().unwrap_or(&"".to_string()), 33 | self.options 34 | ) 35 | } 36 | 37 | fn validate(&self, message: &Message) -> Option { 38 | match &message.r#type { 39 | None => { 40 | if self.options.is_empty() { 41 | return None; 42 | } 43 | } 44 | Some(r#type) if r#type.is_empty() => { 45 | if self.options.is_empty() { 46 | return None; 47 | } 48 | } 49 | Some(r#type) if self.options.contains(r#type) => { 50 | return None; 51 | } 52 | _ => {} 53 | } 54 | 55 | Some(Violation { 56 | level: self.level.unwrap_or(Self::LEVEL), 57 | message: self.message(message), 58 | }) 59 | } 60 | } 61 | 62 | /// Default implementation of Type. 63 | impl Default for Type { 64 | fn default() -> Self { 65 | Self { 66 | level: Some(Self::LEVEL), 67 | options: vec![], 68 | } 69 | } 70 | } 71 | 72 | #[cfg(test)] 73 | mod tests { 74 | use super::*; 75 | 76 | mod empty_options { 77 | use super::*; 78 | 79 | #[test] 80 | fn test_empty_type() { 81 | let rule = Type::default(); 82 | 83 | let message = Message { 84 | body: None, 85 | description: None, 86 | footers: None, 87 | r#type: None, 88 | raw: "".to_string(), 89 | scope: Some("".to_string()), 90 | subject: None, 91 | }; 92 | 93 | let violation = rule.validate(&message); 94 | assert!(violation.is_none()); 95 | } 96 | 97 | #[test] 98 | fn test_none_type() { 99 | let rule = Type::default(); 100 | 101 | let message = Message { 102 | body: None, 103 | description: None, 104 | footers: None, 105 | r#type: None, 106 | raw: "".to_string(), 107 | scope: None, 108 | subject: None, 109 | }; 110 | 111 | let violation = rule.validate(&message); 112 | assert!(violation.is_none()); 113 | } 114 | 115 | #[test] 116 | fn test_type() { 117 | let rule = Type::default(); 118 | 119 | let message = Message { 120 | body: None, 121 | description: None, 122 | footers: None, 123 | r#type: Some("feat".to_string()), 124 | raw: "feat(web): broadcast $destroy event on scope destruction".to_string(), 125 | scope: Some("web".to_string()), 126 | subject: None, 127 | }; 128 | 129 | let violation = rule.validate(&message); 130 | assert!(violation.is_some()); 131 | assert_eq!(violation.clone().unwrap().level, Level::Error); 132 | assert_eq!( 133 | violation.unwrap().message, 134 | "types are not allowed".to_string() 135 | ); 136 | } 137 | } 138 | 139 | mod scopes { 140 | use super::*; 141 | #[test] 142 | fn test_empty_type() { 143 | let rule = Type { 144 | options: vec!["feat".to_string(), "chore".to_string()], 145 | ..Default::default() 146 | }; 147 | 148 | let message = Message { 149 | body: None, 150 | description: None, 151 | footers: None, 152 | r#type: None, 153 | raw: "".to_string(), 154 | scope: Some("".to_string()), 155 | subject: None, 156 | }; 157 | 158 | let violation = rule.validate(&message); 159 | assert!(violation.is_some()); 160 | assert_eq!(violation.clone().unwrap().level, Level::Error); 161 | assert_eq!( 162 | violation.unwrap().message, 163 | "type is not allowed. Only [\"feat\", \"chore\"] are allowed" 164 | ); 165 | } 166 | 167 | #[test] 168 | fn test_none_type() { 169 | let rule = Type { 170 | options: vec!["feat".to_string(), "chore".to_string()], 171 | ..Default::default() 172 | }; 173 | 174 | let message = Message { 175 | body: None, 176 | description: None, 177 | footers: None, 178 | r#type: None, 179 | raw: "".to_string(), 180 | scope: None, 181 | subject: None, 182 | }; 183 | 184 | let violation = rule.validate(&message); 185 | assert!(violation.is_some()); 186 | assert_eq!(violation.clone().unwrap().level, Level::Error); 187 | assert_eq!( 188 | violation.unwrap().message, 189 | "type is not allowed. Only [\"feat\", \"chore\"] are allowed".to_string() 190 | ); 191 | } 192 | 193 | #[test] 194 | fn test_valid_type() { 195 | let rule = Type { 196 | options: vec!["feat".to_string(), "chore".to_string()], 197 | ..Default::default() 198 | }; 199 | 200 | let message = Message { 201 | body: None, 202 | description: None, 203 | footers: None, 204 | r#type: Some("feat".to_string()), 205 | raw: "feat(web): broadcast $destroy event on scope destruction".to_string(), 206 | scope: Some("web".to_string()), 207 | subject: None, 208 | }; 209 | 210 | assert!(rule.validate(&message).is_none()); 211 | } 212 | 213 | #[test] 214 | fn test_invalid_type() { 215 | let rule = Type { 216 | options: vec!["feat".to_string(), "chore".to_string()], 217 | ..Default::default() 218 | }; 219 | 220 | let message = Message { 221 | body: None, 222 | description: None, 223 | footers: None, 224 | r#type: Some("invalid".to_string()), 225 | raw: "invalid(web): broadcast $destroy event on scope destruction".to_string(), 226 | scope: Some("web".to_string()), 227 | subject: None, 228 | }; 229 | 230 | let violation = rule.validate(&message); 231 | assert!(violation.is_some()); 232 | assert_eq!(violation.clone().unwrap().level, Level::Error); 233 | assert_eq!( 234 | violation.unwrap().message, 235 | "type invalid is not allowed. Only [\"feat\", \"chore\"] are allowed".to_string() 236 | ); 237 | } 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /cli/src/rule/type_empty.rs: -------------------------------------------------------------------------------- 1 | use crate::{message::Message, result::Violation, rule::Rule}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Level; 5 | 6 | /// TypeEmpty represents the type-empty rule. 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 9 | pub struct TypeEmpty { 10 | /// Level represents the level of the rule. 11 | /// 12 | // Note that currently the default literal is not supported. 13 | // See: https://github.com/serde-rs/serde/issues/368 14 | level: Option, 15 | } 16 | 17 | /// TypeEmpty represents the type-empty rule. 18 | impl Rule for TypeEmpty { 19 | const NAME: &'static str = "type-empty"; 20 | const LEVEL: Level = Level::Error; 21 | 22 | fn message(&self, _message: &Message) -> String { 23 | "type is empty".to_string() 24 | } 25 | 26 | fn validate(&self, message: &Message) -> Option { 27 | if message.r#type.is_none() || message.r#type.as_ref().unwrap().is_empty() { 28 | return Some(Violation { 29 | level: self.level.unwrap_or(Self::LEVEL), 30 | message: self.message(message), 31 | }); 32 | } 33 | 34 | None 35 | } 36 | } 37 | 38 | /// Default implementation of TypeEmpty. 39 | impl Default for TypeEmpty { 40 | fn default() -> Self { 41 | Self { 42 | level: Some(Self::LEVEL), 43 | } 44 | } 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use super::*; 50 | 51 | #[test] 52 | fn test_non_empty_type() { 53 | let rule = TypeEmpty::default(); 54 | let message = Message { 55 | body: None, 56 | description: None, 57 | footers: None, 58 | r#type: Some("feat".to_string()), 59 | raw: "feat(scope): broadcast $destroy event on scope destruction".to_string(), 60 | scope: None, 61 | subject: None, 62 | }; 63 | 64 | assert!(rule.validate(&message).is_none()); 65 | } 66 | 67 | #[test] 68 | fn test_empty_type() { 69 | let rule = TypeEmpty::default(); 70 | let message = Message { 71 | body: None, 72 | description: None, 73 | footers: None, 74 | r#type: None, 75 | raw: "(scope): broadcast $destroy event on scope destruction".to_string(), 76 | scope: None, 77 | subject: None, 78 | }; 79 | 80 | let violation = rule.validate(&message); 81 | assert!(violation.is_some()); 82 | assert_eq!(violation.clone().unwrap().level, Level::Error); 83 | assert_eq!(violation.unwrap().message, "type is empty".to_string()); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /cli/src/rule/type_format.rs: -------------------------------------------------------------------------------- 1 | use crate::{message::Message, result::Violation, rule::Rule}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Level; 5 | 6 | /// TypeFormat represents the type-format rule. 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 9 | pub struct TypeFormat { 10 | /// Level represents the level of the rule. 11 | /// 12 | // Note that currently the default literal is not supported. 13 | // See: https://github.com/serde-rs/serde/issues/368 14 | level: Option, 15 | 16 | /// Format represents the format of the type. 17 | format: Option, 18 | } 19 | 20 | /// TypeFormat represents the type-format rule. 21 | impl Rule for TypeFormat { 22 | const NAME: &'static str = "type-format"; 23 | const LEVEL: Level = Level::Error; 24 | 25 | fn message(&self, _message: &Message) -> String { 26 | format!( 27 | "type format does not match format: {}", 28 | self.format.as_ref().unwrap() 29 | ) 30 | } 31 | 32 | fn validate(&self, message: &Message) -> Option { 33 | if let Some(format) = &self.format { 34 | let regex = match regex::Regex::new(format) { 35 | Ok(regex) => regex, 36 | Err(err) => { 37 | return Some(Violation { 38 | level: self.level.unwrap_or(Self::LEVEL), 39 | message: err.to_string(), 40 | }); 41 | } 42 | }; 43 | 44 | match &message.r#type { 45 | None => { 46 | return Some(Violation { 47 | level: self.level.unwrap_or(Self::LEVEL), 48 | message: "found no type".to_string(), 49 | }); 50 | } 51 | Some(description) => { 52 | if !regex.is_match(description) { 53 | return Some(Violation { 54 | level: self.level.unwrap_or(Self::LEVEL), 55 | message: self.message(message), 56 | }); 57 | } 58 | } 59 | } 60 | } 61 | 62 | None 63 | } 64 | } 65 | 66 | /// Default implementation of TypeFormat. 67 | impl Default for TypeFormat { 68 | fn default() -> Self { 69 | Self { 70 | level: Some(Self::LEVEL), 71 | format: None, 72 | } 73 | } 74 | } 75 | 76 | #[cfg(test)] 77 | mod tests { 78 | use super::*; 79 | 80 | #[test] 81 | fn test_invalid_description_format() { 82 | let rule = TypeFormat { 83 | format: Some(r"^[a-z].*".to_string()), 84 | ..Default::default() 85 | }; 86 | 87 | let message = Message { 88 | body: None, 89 | description: Some("Add new flag".to_string()), 90 | footers: None, 91 | r#type: Some("feat".to_string()), 92 | raw: "feat(scope): Add new flag".to_string(), 93 | scope: Some("scope".to_string()), 94 | subject: None, 95 | }; 96 | 97 | assert!(rule.validate(&message).is_none()); 98 | } 99 | 100 | #[test] 101 | fn test_valid_description_format() { 102 | let rule = TypeFormat { 103 | format: Some(r"^[a-z].*".to_string()), 104 | ..Default::default() 105 | }; 106 | 107 | let message = Message { 108 | body: None, 109 | description: Some("Add new flag".to_string()), 110 | footers: None, 111 | r#type: Some("Feat".to_string()), 112 | raw: "Feat(scope): Add new flag".to_string(), 113 | scope: Some("Scope".to_string()), 114 | subject: None, 115 | }; 116 | 117 | let violation = rule.validate(&message); 118 | assert!(violation.is_some()); 119 | assert_eq!(violation.clone().unwrap().level, Level::Error); 120 | assert_eq!( 121 | violation.unwrap().message, 122 | "type format does not match format: ^[a-z].*".to_string() 123 | ); 124 | } 125 | 126 | #[test] 127 | fn test_invalid_regex() { 128 | let rule = TypeFormat { 129 | format: Some(r"(".to_string()), 130 | ..Default::default() 131 | }; 132 | 133 | let message = Message { 134 | body: None, 135 | description: Some("Invalid regex".to_string()), 136 | footers: None, 137 | r#type: Some("feat".to_string()), 138 | raw: "feat(scope): Invalid regex".to_string(), 139 | scope: Some("scope".to_string()), 140 | subject: None, 141 | }; 142 | 143 | let violation = rule.validate(&message); 144 | assert!(violation.is_some()); 145 | assert_eq!(violation.clone().unwrap().level, Level::Error); 146 | assert!(violation.unwrap().message.contains("regex parse error")); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /cli/src/rule/type_max_length.rs: -------------------------------------------------------------------------------- 1 | use crate::{message::Message, result::Violation, rule::Rule}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use super::Level; 5 | 6 | /// TypeMaxLength represents the description-max-length rule. 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] 9 | pub struct TypeMaxLength { 10 | /// Level represents the level of the rule. 11 | /// 12 | // Note that currently the default literal is not supported. 13 | // See: https://github.com/serde-rs/serde/issues/368 14 | level: Option, 15 | 16 | /// Length represents the maximum length of the type. 17 | length: usize, 18 | } 19 | 20 | /// TypeMaxLength represents the type-max-length rule. 21 | impl Rule for TypeMaxLength { 22 | const NAME: &'static str = "type-max-length"; 23 | const LEVEL: Level = Level::Error; 24 | 25 | fn message(&self, _message: &Message) -> String { 26 | format!("type is longer than {} characters", self.length) 27 | } 28 | 29 | fn validate(&self, message: &Message) -> Option { 30 | match &message.r#type { 31 | Some(t) => { 32 | if t.len() >= self.length { 33 | return Some(Violation { 34 | level: self.level.unwrap_or(Self::LEVEL), 35 | message: self.message(message), 36 | }); 37 | } 38 | } 39 | None => { 40 | return Some(Violation { 41 | level: self.level.unwrap_or(Self::LEVEL), 42 | message: self.message(message), 43 | }) 44 | } 45 | } 46 | 47 | None 48 | } 49 | } 50 | 51 | /// Default implementation of TypeMaxLength. 52 | impl Default for TypeMaxLength { 53 | fn default() -> Self { 54 | Self { 55 | level: Some(Self::LEVEL), 56 | length: 72, 57 | } 58 | } 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | use super::*; 64 | 65 | #[test] 66 | fn test_long_type() { 67 | let rule = TypeMaxLength { 68 | length: usize::MAX, // Long length for testing 69 | ..Default::default() 70 | }; 71 | let message = Message { 72 | body: None, 73 | description: Some("desc".to_string()), 74 | footers: None, 75 | r#type: Some("feat".to_string()), 76 | raw: "feat(scope): desc".to_string(), 77 | scope: Some("scope".to_string()), 78 | subject: Some("feat(scope): desc".to_string()), 79 | }; 80 | 81 | assert!(rule.validate(&message).is_none()); 82 | } 83 | 84 | #[test] 85 | fn test_short_type() { 86 | let rule = TypeMaxLength { 87 | length: 3, // Short length for testing 88 | ..Default::default() 89 | }; 90 | let message = Message { 91 | body: None, 92 | description: Some("feat(scope): I'm long description".to_string()), 93 | footers: None, 94 | r#type: Some("feat".to_string()), 95 | raw: "feat(scope): I'm long description".to_string(), 96 | scope: Some("scope".to_string()), 97 | subject: None, 98 | }; 99 | 100 | let violation = rule.validate(&message); 101 | assert!(violation.is_some()); 102 | assert_eq!(violation.clone().unwrap().level, Level::Error); 103 | assert_eq!( 104 | violation.unwrap().message, 105 | format!("type is longer than {} characters", rule.length) 106 | ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /schema/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "schema" 3 | version.workspace = true 4 | authors.workspace = true 5 | license.workspace = true 6 | documentation.workspace = true 7 | keywords.workspace = true 8 | categories.workspace = true 9 | readme.workspace = true 10 | repository.workspace = true 11 | exclude.workspace = true 12 | edition.workspace = true 13 | 14 | [dependencies] 15 | clap = { version = "4.5.21", features = ["derive"] } 16 | cli = { path = "../cli", features = ["schemars"], package = "commitlint-rs" } 17 | schemars = { version = "0.8.21" } 18 | serde = { version = "1.0.215", features = ["derive"] } 19 | serde_json = "1.0.140" 20 | -------------------------------------------------------------------------------- /schema/src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use cli::config::Config; 3 | use std::fs; 4 | 5 | /// CLI Arguments 6 | #[derive(Parser, Debug)] 7 | #[command(author, version, about, long_about = None)] 8 | struct Args { 9 | /// Path to save the JSON schema 10 | #[arg(long, short)] 11 | path: String, 12 | } 13 | 14 | fn main() { 15 | let args = Args::parse(); 16 | 17 | let config_schema = schemars::schema_for!(Config); 18 | let config_schema_json = serde_json::to_string_pretty(&config_schema).unwrap(); 19 | fs::write(&args.path, config_schema_json).unwrap(); 20 | } 21 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /web/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /web/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /web/astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config'; 2 | import starlight from '@astrojs/starlight'; 3 | 4 | // https://astro.build/config 5 | export default defineConfig({ 6 | site: 'https://keisukeyamashita.github.io', 7 | base: '/commitlint-rs', 8 | integrations: [ 9 | starlight({ 10 | title: 'Commitlint', 11 | logo: { 12 | src: '/src/assets/checker.png', 13 | }, 14 | social: { 15 | github: 'https://github.com/KeisukeYamashita/commitlint-rs', 16 | }, 17 | sidebar: [ 18 | { 19 | label: '🚀 Get Started', 20 | autogenerate: { directory: 'setup' }, 21 | }, 22 | { 23 | label: '🔧 Configuration', 24 | autogenerate: { directory: 'config' }, 25 | }, 26 | { 27 | label: '✅ Rule', 28 | autogenerate: { directory: 'rules' }, 29 | }, 30 | { 31 | label: '🔐 License', 32 | items: [ 33 | { label: "License", link: "/license" }, 34 | ] 35 | }, 36 | ], 37 | }), 38 | ], 39 | 40 | // Process images with sharp: https://docs.astro.build/en/guides/assets/#using-sharp 41 | image: { service: { entrypoint: 'astro/assets/services/sharp' } }, 42 | }); 43 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "type": "module", 4 | "version": "0.1.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro build", 9 | "preview": "astro preview", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "@astrojs/starlight": "^0.25.2", 14 | "astro": "^4.16.18", 15 | "sharp": "^0.33.4" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /web/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 21 | 35 | 37 | 39 | 41 | 44 | 47 | 51 | 54 | 58 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /web/src/assets/checker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KeisukeYamashita/commitlint-rs/efaf9035ae78ef0fefe82ea3ffd3b40c1b508cd1/web/src/assets/checker.png -------------------------------------------------------------------------------- /web/src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection } from 'astro:content'; 2 | import { docsSchema, i18nSchema } from '@astrojs/starlight/schema'; 3 | 4 | export const collections = { 5 | docs: defineCollection({ schema: docsSchema() }), 6 | i18n: defineCollection({ type: 'data', schema: i18nSchema() }), 7 | }; 8 | -------------------------------------------------------------------------------- /web/src/content/docs/config/IDE.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: IDE 3 | description: Guide how to setup your IDE to work with commitlint 4 | --- 5 | 6 | Commitlint offers schema by supporting [JSON schema](https://json-schema.org/) so that you can configure your IDE to work with Commitlint and have better developer experience. 7 | 8 | :::tip 9 | 10 | If you want to pin the schema to a specific version, you can configure the version in the URL. 11 | 12 | ```console 13 | https://github.com/KeisukeYamashita/commitlint-rs/releases/download/v0.2.0/schema.json 14 | ``` 15 | 16 | In this case, the schema is pinned to version `0.2.0`. 17 | 18 | ::: 19 | 20 | ## Visual Studio Code 21 | 22 | Configure your [Visual Studio Code](https://code.visualstudio.com/) to work with Commitlint. 23 | 24 | ### Edit in `settings.json` 25 | 26 | Update your user `settings.json` or workspace settings (`/.vscode/settings.json`) to configure the schema. 27 | 28 | #### JSON 29 | 30 | ```json 31 | "json.schemas": [ 32 | { 33 | "fileMatch": [ 34 | ".commitlintrc", 35 | ".commitlintrc.json" 36 | ], 37 | "url": "https://github.com/KeisukeYamashita/commitlint-rs/releases/latest/download/schema.json" 38 | } 39 | ] 40 | ``` 41 | 42 | #### YAML 43 | 44 | Associating schemas with YAMLs are supported by the [YAML language server](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml). 45 | 46 | ```json 47 | "yaml.schemas": { 48 | "https://github.com/KeisukeYamashita/commitlint-rs/releases/latest/download/schema.json": [ 49 | ".commitlintrc", 50 | ".commitlintrc.yaml", 51 | ".commitlint.yml" 52 | ] 53 | } 54 | ``` 55 | 56 | ### Specify schema in the configuration file 57 | 58 | You can also specify the schema in the configuration file directly. 59 | 60 | #### JSON 61 | 62 | Add the following comment in your `.commitlintrc` or `.commitlintrc.json` file. 63 | 64 | ```json 65 | { 66 | "$schema": "https://github.com/KeisukeYamashita/commitlint-rs/releases/latest/download/schema.json", 67 | "rules": {} 68 | } 69 | ``` 70 | 71 | #### YAML 72 | 73 | Associating schemas with YAMLs are supported by the [YAML language server](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml). Add the following comment in your `.commitlintrc`, `.commitlintrc.yaml` or `.commitlintrc.yml` file. 74 | 75 | ```yaml 76 | # yaml-language-server: $schema=https://github.com/KeisukeYamashita/commitlint-rs/releases/latest/download/schema.json 77 | rules: 78 | ``` 79 | -------------------------------------------------------------------------------- /web/src/content/docs/config/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Configuration 3 | description: Guide how to configure commitlint 4 | --- 5 | 6 | Commitlint can be configured in many ways. You can use the default configuration or you can specify your own configuration file. 7 | 8 | ## Default configuration 9 | 10 | If you don't specify any configuration file and you don't have any commitlint configuration files in your environment, the CLI will use the default configurations of each rules. 11 | 12 | See the [default rules](/commitlint-rs/config/default) page for details. 13 | 14 | ## Location 15 | 16 | Commitlint will look for configuration in the following places. 17 | 18 | ### Default 19 | 20 | If no flag (see next section) is specified, the Commitlint will look for configuration in the following places **in the current working directory**: 21 | 22 | * `.commitlintrc` (JSON or YAML file) 23 | * `.commitlintrc.json` (JSON file) 24 | * `.commitlintrc.yml` (YAML file) 25 | * `.commitlintrc.yaml` (YAML file) 26 | 27 | :::tip 28 | 29 | Note that it is searched in the order written above and the first one found is loaded. Therefore, if you have `.commitlintrc` and `.commitlintrc.yml` in the same directory, the `.commitlintrc` will be loaded and the second one will be ignored. 30 | 31 | ::: 32 | 33 | ### Using the flag 34 | 35 | Configuration file can be specified by using the `--config` flag or the short `-c` flag. 36 | 37 | ```console 38 | # Using --config flag 39 | $ commitlint --config path/to/.commitlintrc.yml 40 | 41 | # Using -c flag 42 | $ commitlint -c path/to/.commitlintrc.yml 43 | ``` 44 | 45 | If you specify a file and the file is not found, Commitlint will throw an error. 46 | 47 | ## Debug configuration 48 | 49 | You can use the `--print-config` flag to print the configuration that will be used by Commitlint. 50 | 51 | ```console 52 | $ commitlint --print-config 53 | rules: 54 | description-empty: # Description must not be empty 55 | level: warning 56 | subject-empty: # Subject line must not be empty 57 | level: error 58 | type-empty: # Type must not be empty 59 | level: error 60 | ``` 61 | -------------------------------------------------------------------------------- /web/src/content/docs/config/default.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Default Rules 3 | description: List of the default rules 4 | --- 5 | 6 | If you don't specify any configuration file and you don't have any commitlint configuration files in your environment, the CLI will use the default configurations of each rules describes in this page. 7 | 8 | :::tip 9 | 10 | You can also check the default values on the page of each rule. 11 | 12 | ::: 13 | 14 | ```yaml 15 | rules: 16 | description-empty: # Description shouldn't be empty 17 | level: warning 18 | subject-empty: # Subject line should exist 19 | level: error 20 | type-empty: # Type must not be empty 21 | level: error 22 | ``` 23 | -------------------------------------------------------------------------------- /web/src/content/docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Welcome to Commitlint 3 | description: Lint commit messages with conventional commits 4 | template: splash 5 | hero: 6 | tagline: Guard rail for your commit messages for sustainable development 7 | image: 8 | file: ../../assets/checker.png 9 | actions: 10 | - text: Install to your project 11 | link: /commitlint-rs/setup/install 12 | icon: right-arrow 13 | variant: primary 14 | - text: Read the rules 15 | link: /commitlint-rs/rules/body-empty 16 | icon: external 17 | --- 18 | 19 | import { Card, CardGrid } from "@astrojs/starlight/components"; 20 | 21 | ## Next steps 22 | 23 | 24 | 25 | Learn more in the [Conventional 26 | Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. 27 | 28 | 29 | Run `commitlint` to lint your commit messages. See [the 30 | guides](/commitlint-rs/setup/installation) about the CLI. 31 | 32 | 33 | Check each rules to see what is allowed and what is not. See [the 34 | rules](/commitlint-rs/rules/body-empty) for more information. 35 | 36 | 37 | Edit commitlint configurations to customize your rules. See [the 38 | rules](/commitlint-rs/rules/body-empty) for more information. 39 | 40 | 41 | Report issues to the [GitHub 42 | repository](https://github.com/KeisukeYamashita/commitlint-rs/issues). 43 | 44 | 45 | -------------------------------------------------------------------------------- /web/src/content/docs/license.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: License 3 | description: About the license of this project 4 | --- 5 | 6 | ## Assets 7 | 8 | * Logo: [Chinese-checkers icons created by Freepik - Flaticon](https://www.flaticon.com/free-icons/chinese-checkers) 9 | -------------------------------------------------------------------------------- /web/src/content/docs/rules/body-empty.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Body Empty 3 | description: Check if the body exists 4 | --- 5 | 6 | * Default: `ignore` 7 | 8 | ## ❌ Bad 9 | 10 | ```console 11 | feat(cli): add new flag 12 | ``` 13 | 14 | ## ✅ Good 15 | 16 | ```console 17 | feat(cli): add new flag 18 | 19 | Add new flag --help for https://github.com/KeisukeYamashita/commitlint-rs/issues/20 20 | ``` 21 | 22 | ## Example 23 | 24 | ```yaml 25 | rules: 26 | body-empty: 27 | level: error 28 | ``` 29 | -------------------------------------------------------------------------------- /web/src/content/docs/rules/body-max-length.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Body Max Length 3 | description: Check if the body length is less than or equal to the specified length 4 | --- 5 | 6 | * Default: 7 | * Level: `ignore` 8 | 9 | In this page, we will use the following commit message as an example. 10 | 11 | ```yaml 12 | rules: 13 | body-max-length: 14 | level: error 15 | length: 4 16 | ``` 17 | 18 | ## ❌ Bad 19 | 20 | ```console 21 | feat(cli): add new flag 22 | 23 | Hello, I'm a body of the commit message. 24 | ``` 25 | 26 | ## ✅ Good 27 | 28 | ```console 29 | feat(cli): add new flag 30 | 31 | Hey. 32 | ``` 33 | 34 | ## Example 35 | 36 | ### Body length should be less than or equal to 72 37 | 38 | ```yaml 39 | rules: 40 | body-max-length: 41 | level: error 42 | length: 72 43 | ``` 44 | -------------------------------------------------------------------------------- /web/src/content/docs/rules/description-empty.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Description Empty 3 | description: Check if the description exists 4 | --- 5 | 6 | * Default: `error` 7 | 8 | ## ❌ Bad 9 | 10 | ```console 11 | feat(cli): 12 | ``` 13 | 14 | ## ✅ Good 15 | 16 | ```console 17 | feat(cli): add new flag 18 | ``` 19 | 20 | ## Example 21 | 22 | ### Description must exist 23 | 24 | ```yaml 25 | rules: 26 | description-empty: 27 | level: error 28 | ``` 29 | -------------------------------------------------------------------------------- /web/src/content/docs/rules/description-format.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Description format 3 | description: Check if the description format is valid 4 | --- 5 | 6 | * Default:`ignore` 7 | 8 | In this page, we will use the following commit message as an example. 9 | 10 | ```yaml 11 | rules: 12 | description-format: 13 | level: error 14 | format: ^[A-Z].*$ 15 | ``` 16 | 17 | ## ❌ Bad 18 | 19 | ```console 20 | feat(cli): added a new flag 21 | ``` 22 | 23 | ## ✅ Good 24 | 25 | ```console 26 | feat(cli): Added a new flag 27 | ``` 28 | 29 | ## Example 30 | 31 | ### Description must start with a capital letter 32 | 33 | ```yaml 34 | rules: 35 | description-format: 36 | level: error 37 | format: ^[A-Z].*$ 38 | ``` 39 | -------------------------------------------------------------------------------- /web/src/content/docs/rules/description-max-length.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Description Max Length 3 | description: Check if the description length is less than or equal to the specified length 4 | --- 5 | 6 | * Default: 7 | * Level: `ignore` 8 | 9 | In this page, we will use the following commit message as an example. 10 | 11 | ```yaml 12 | rules: 13 | description-max-length: 14 | level: error 15 | length: 12 16 | ``` 17 | 18 | ## ❌ Bad 19 | 20 | ```console 21 | feat(cli): add new flag for brand new feature 22 | ``` 23 | 24 | ## ✅ Good 25 | 26 | ```console 27 | feat(cli): add help flag 28 | ``` 29 | 30 | ## Example 31 | 32 | ### Description length should be less than or equal to 72 33 | 34 | ```yaml 35 | rules: 36 | description-max-length: 37 | level: error 38 | length: 72 39 | ``` 40 | -------------------------------------------------------------------------------- /web/src/content/docs/rules/footers-empty.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Footers Empty 3 | description: Check if the footers exists 4 | --- 5 | 6 | * Default: `error` 7 | 8 | ## ❌ Bad 9 | 10 | ```console 11 | feat(cli): user logout handler 12 | ``` 13 | 14 | ## ✅ Good 15 | 16 | ```console 17 | feat(cli): add new flag 18 | 19 | Link: https://keisukeyamashita.github.io/commitlint-rs/ 20 | ``` 21 | 22 | ## Example 23 | 24 | ### Footers must exist 25 | 26 | ```yaml 27 | rules: 28 | footers-empty: 29 | level: error 30 | ``` 31 | -------------------------------------------------------------------------------- /web/src/content/docs/rules/scope-empty.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Scope Empty 3 | description: Check if the scope exists 4 | --- 5 | 6 | * Default: `ignore` 7 | 8 | ## ❌ Bad 9 | 10 | ```console 11 | (cli): fix typo 12 | ``` 13 | 14 | ## ✅ Good 15 | 16 | ```console 17 | docs(cli): fix typo 18 | ``` 19 | 20 | ## Example 21 | 22 | ### Scope must exist 23 | 24 | ```yaml 25 | rules: 26 | scope-empty: 27 | level: error 28 | ``` 29 | -------------------------------------------------------------------------------- /web/src/content/docs/rules/scope-format.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Scope format 3 | description: Check if the scope format is valid 4 | --- 5 | 6 | * Default:`ignore` 7 | 8 | In this page, we will use the following commit message as an example. 9 | 10 | ```yaml 11 | rules: 12 | scope-format: 13 | level: error 14 | format: ^[a-z]*$ 15 | ``` 16 | 17 | ## ❌ Bad 18 | 19 | ```console 20 | feat(Cli): added a new flag 21 | ``` 22 | 23 | ## ✅ Good 24 | 25 | ```console 26 | feat(cli): added a new flag 27 | ``` 28 | 29 | ## Example 30 | 31 | ### Scope must start with a lower letter 32 | 33 | ```yaml 34 | rules: 35 | scope-format: 36 | level: error 37 | format: ^[a-z]*$ 38 | ``` 39 | -------------------------------------------------------------------------------- /web/src/content/docs/rules/scope-max-length.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Scope Max Length 3 | description: Check if the scope length is less than or equal to the specified length 4 | --- 5 | 6 | * Default: 7 | * Level: `ignore` 8 | 9 | In this page, we will use the following commit message as an example. 10 | 11 | ```yaml 12 | rules: 13 | scope-max-length: 14 | level: error 15 | length: 5 16 | ``` 17 | 18 | ## ❌ Bad 19 | 20 | ```console 21 | feat(super important product): add new flag 22 | ``` 23 | 24 | ## ✅ Good 25 | 26 | ```console 27 | feat(cli): add new flag 28 | ``` 29 | 30 | ## Example 31 | 32 | ### Description length should be less than or equal to 5 33 | 34 | ```yaml 35 | rules: 36 | scope-max-length: 37 | level: error 38 | length: 5 39 | ``` 40 | -------------------------------------------------------------------------------- /web/src/content/docs/rules/scope.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Scope 3 | description: Allowlist for scopes 4 | --- 5 | 6 | * Default: 7 | * Level: `ignore` 8 | * Optional: `false` 9 | 10 | In this example, we assumed that you have a project with the following scopes: 11 | 12 | ```yaml 13 | rules: 14 | scope: 15 | level: error 16 | options: 17 | - api 18 | - web 19 | ``` 20 | 21 | ## ❌ Bad 22 | 23 | ```console 24 | chore(cli): fix typo 25 | => scope cli is not allowed. Only ["api", "web"] are allowed 26 | ``` 27 | 28 | ## ✅ Good 29 | 30 | ```console 31 | chore(api): fix typo 32 | ``` 33 | 34 | ## Example 35 | 36 | ### Only allow scopes `api` and `web` 37 | 38 | ```yaml 39 | rules: 40 | scope: 41 | level: error 42 | options: 43 | - api 44 | - web 45 | ``` 46 | 47 | ### Optional scopes `api` and `web` 48 | 49 | ```yaml 50 | rules: 51 | scope: 52 | level: error 53 | optional: true 54 | options: 55 | - api 56 | ``` 57 | 58 | With this configuration, `feat(api): xxx` and `feat: xxx` are valid commits. 59 | 60 | ### Disallow all scopes 61 | 62 | ```yaml 63 | rules: 64 | scope: 65 | level: error 66 | options: [] # or [""] 67 | scope-empty: 68 | level: ignore 69 | ``` 70 | -------------------------------------------------------------------------------- /web/src/content/docs/rules/subject-empty.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Subject Empty 3 | description: Check if the subject exists 4 | --- 5 | 6 | * Default: `error` 7 | 8 | ## ❌ Bad 9 | 10 | ```console 11 | 12 | 13 | Body of the commit 14 | ``` 15 | 16 | ## ✅ Good 17 | 18 | ```console 19 | docs(cli): fix typo 20 | 21 | Body of the commit 22 | ``` 23 | 24 | ## Example 25 | 26 | ### Subject must exist 27 | 28 | ```yaml 29 | rules: 30 | subject-empty: 31 | level: error 32 | ``` 33 | -------------------------------------------------------------------------------- /web/src/content/docs/rules/type-empty.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Type Empty 3 | description: Check if the type exists 4 | --- 5 | 6 | * Default: `error` 7 | 8 | ## ❌ Bad 9 | 10 | ```console 11 | docs: fix typo 12 | ``` 13 | 14 | ```console 15 | (web): fix typo 16 | ``` 17 | 18 | ## ✅ Good 19 | 20 | ```console 21 | docs(web): fix typo 22 | ``` 23 | 24 | ## Example 25 | 26 | ### Type must exist 27 | 28 | ```yaml 29 | rules: 30 | type-empty: 31 | level: error 32 | ``` 33 | -------------------------------------------------------------------------------- /web/src/content/docs/rules/type-format.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Type format 3 | description: Check if the type format is valid 4 | --- 5 | 6 | * Default:`ignore` 7 | 8 | In this page, we will use the following commit message as an example. 9 | 10 | ```yaml 11 | rules: 12 | type-format: 13 | level: error 14 | format: ^[a-z].*$ 15 | ``` 16 | 17 | ## ❌ Bad 18 | 19 | ```console 20 | Feat(cli): added a new flag 21 | ``` 22 | 23 | ## ✅ Good 24 | 25 | ```console 26 | feat(cli): Added a new flag 27 | ``` 28 | 29 | ## Example 30 | 31 | ### Type must start with a capital letter 32 | 33 | ```yaml 34 | rules: 35 | type-format: 36 | level: error 37 | format: ^[a-z].*$ 38 | ``` 39 | -------------------------------------------------------------------------------- /web/src/content/docs/rules/type-max-length.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Type Max Length 3 | description: Check if the type length is less than or equal to the specified length 4 | --- 5 | 6 | * Default: 7 | * Level: `ignore` 8 | 9 | In this page, we will use the following commit message as an example. 10 | 11 | ```yaml 12 | rules: 13 | type-max-length: 14 | level: error 15 | length: 6 16 | ``` 17 | 18 | ## ❌ Bad 19 | 20 | ```console 21 | feature-for-future(cli): add new flag 22 | ``` 23 | 24 | ## ✅ Good 25 | 26 | ```console 27 | feat(cli): add new flag 28 | ``` 29 | 30 | ## Example 31 | 32 | ### Type length should be less than or equal to 72 33 | 34 | ```yaml 35 | rules: 36 | type-max-length: 37 | level: error 38 | length: 72 39 | ``` 40 | -------------------------------------------------------------------------------- /web/src/content/docs/rules/type.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Type 3 | description: Allowlist for types 4 | --- 5 | 6 | * Default: `ignore` 7 | 8 | In this example, we assumed that you have a project with the following types: 9 | 10 | ```yaml 11 | rules: 12 | type: 13 | level: error 14 | options: 15 | - feat 16 | - fix 17 | ``` 18 | 19 | ## ❌ Bad 20 | 21 | ```console 22 | chore(cli): fix typo 23 | => type chore is not allowed. Only ["feat", "fix"] are allowed 24 | ``` 25 | 26 | ## ✅ Good 27 | 28 | ```console 29 | fix(api): fix typo 30 | ``` 31 | 32 | ## Example 33 | 34 | ### Only allow types `feat` and `fix` 35 | 36 | ```yaml 37 | rules: 38 | type: 39 | level: error 40 | options: 41 | - api 42 | - web 43 | ``` 44 | 45 | ### Disallow all types 46 | 47 | ```yaml 48 | rules: 49 | type: 50 | level: error 51 | options: [] # or [""] 52 | type-empty: 53 | level: ignore 54 | ``` 55 | -------------------------------------------------------------------------------- /web/src/content/docs/setup/debug.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Debug 3 | description: Debug your configurations 4 | --- 5 | 6 | You maybe wondering how you can debug your configurations. This is a common question and we have a few options for you to consider. 7 | 8 | ## Debugging how a rule works 9 | 10 | Debug your configuration using stdin and below: 11 | 12 | ```console 13 | echo "feat(other): debug" | commitlint 14 | ``` 15 | 16 | ## Debugging your configuration 17 | 18 | You can debug how your configuration is being loaded and what rules are being used by running the following command: 19 | 20 | ```console 21 | commitlint --print-config 22 | ``` 23 | -------------------------------------------------------------------------------- /web/src/content/docs/setup/install.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | description: Guide how to install commitlint to your project 4 | --- 5 | 6 | ## Using `cargo` CLI 7 | 8 | Commitlint is written in Rust so you can install it using `cargo` CLI: 9 | 10 | ```console 11 | cargo install commitlint-rs 12 | ``` 13 | 14 | After that, you will be able to run the `commitlint` command. 15 | 16 | ```console 17 | commitlint --help 18 | ``` 19 | 20 | ## Using Cargo Binary Install 21 | 22 | You can also use Binstall ([cargo-bins/cargo-binstall](https://github.com/cargo-bins/cargo-binstall)) to install the CLI 23 | 24 | ```console 25 | cargo binstall commitlint-rs 26 | ``` 27 | 28 | ## Using Docker 29 | 30 | Commitlint is also available as a Docker image. 31 | You can pull it from [Docker Hub](https://hub.docker.com/repository/docker/1915keke/commitlint). 32 | 33 | ```console 34 | docker run 1915keke/commitlint 35 | ``` 36 | 37 | See all available tags [here](https://hub.docker.com/repository/docker/1915keke/commitlint/tags?page=1&ordering=last_updated). 38 | -------------------------------------------------------------------------------- /web/src/content/docs/setup/motivation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Motivation 3 | description: Why commitlint in Rust? 4 | --- 5 | 6 | Why commitlint in Rust? 7 | 8 | It's very simple, we wanted to complete it in the Rust ecosystem 🚀 9 | 10 | It is hard to prepare a `package.json` for a Rust project to use an existing commitlint, or to prepare a Node.js runtime with CI. 11 | Therefore, we have re-implemented a commitlint that can be easily used with cargo install, etc. 12 | -------------------------------------------------------------------------------- /web/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict" 3 | } --------------------------------------------------------------------------------