├── .cargo └── config.toml ├── .editorconfig ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .releaserc ├── .vscode └── settings.json ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── Makefile.toml ├── README.md ├── README.zh-CN.md └── src ├── config ├── constants.rs ├── error.rs ├── mod.rs ├── schema.rs ├── store.rs └── types.rs ├── i18n ├── messages │ ├── en.rs │ └── zh.rs └── mod.rs ├── main.rs └── utils ├── check_git.rs ├── filter_logs.rs ├── format_commit.rs ├── format_log.rs ├── get_repo_logs.rs ├── get_repo_name.rs ├── keypress.rs ├── mod.rs └── save_report.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | # On Windows MSVC, statically link the C runtime so that the resulting EXE does 2 | # not depend on the vcruntime DLL. 3 | [target.x86_64-pc-windows-msvc] 4 | rustflags = ["-C", "target-feature=+crt-static"] 5 | [target.i686-pc-windows-msvc] 6 | rustflags = ["-C", "target-feature=+crt-static"] 7 | [target.aarch64-pc-windows-msvc] 8 | rustflags = ["-C", "target-feature=+crt-static"] 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 80 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | max_line_length = 0 15 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | types: [closed] 7 | 8 | jobs: 9 | build-windows: 10 | runs-on: windows-latest 11 | outputs: 12 | version: ${{ steps.get-version.outputs.version }} 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: Setup Rust 17 | uses: actions-rs/toolchain@v1 18 | with: 19 | toolchain: stable 20 | override: true 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | - name: Install cargo-make 26 | run: cargo install cargo-make 27 | - name: Build Windows 28 | run: cargo make build 29 | - name: Compress Windows Artifact 30 | run: | 31 | Compress-Archive -Path target/release/git-commit-analytics.exe -DestinationPath git-commit-analytics_win.zip 32 | - name: Upload Windows Artifact 33 | uses: actions/upload-artifact@v4 34 | with: 35 | name: windows-build 36 | path: git-commit-analytics_win.zip 37 | 38 | build-mac: 39 | runs-on: macos-latest 40 | outputs: 41 | version: ${{ steps.get-version.outputs.version }} 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v4 45 | - name: Setup Rust 46 | uses: actions-rs/toolchain@v1 47 | with: 48 | toolchain: stable 49 | override: true 50 | - name: Setup Node.js 51 | uses: actions/setup-node@v4 52 | with: 53 | node-version: 20 54 | - name: Install cargo-make 55 | run: cargo install cargo-make 56 | - name: Build macOS 57 | run: cargo make build 58 | - name: Compress macOS Artifact 59 | run: | 60 | zip --junk-paths git-commit-analytics_mac.zip target/release/git-commit-analytics 61 | - name: Upload macOS Artifact 62 | uses: actions/upload-artifact@v4 63 | with: 64 | name: mac-build 65 | path: git-commit-analytics_mac.zip 66 | 67 | release: 68 | runs-on: ubuntu-latest 69 | needs: [build-windows, build-mac] 70 | steps: 71 | - name: Checkout 72 | uses: actions/checkout@v4 73 | - name: Setup Node.js 74 | uses: actions/setup-node@v4 75 | with: 76 | node-version: 20 77 | - name: Install semantic-release and plugins 78 | run: | 79 | npm install -g semantic-release @semantic-release/changelog @semantic-release/git @semantic-release/github 80 | - name: Download Windows Artifact 81 | uses: actions/download-artifact@v4 82 | with: 83 | name: windows-build 84 | path: artifacts/ 85 | - name: Download macOS Artifact 86 | uses: actions/download-artifact@v4 87 | with: 88 | name: mac-build 89 | path: artifacts/ 90 | - name: Prepare Assets 91 | run: | 92 | ls -lah artifacts/ 93 | - name: Rename Artifacts with Version 94 | env: 95 | GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} 96 | run: | 97 | VERSION=$(npx semantic-release --dry-run | grep -oP 'next release version is \K[0-9]+\.[0-9]+\.[0-9]+') 98 | echo "Version detected: $VERSION" 99 | mv artifacts/git-commit-analytics_win.zip artifacts/git-commit-analytics_v${VERSION}_win.zip 100 | mv artifacts/git-commit-analytics_mac.zip artifacts/git-commit-analytics_v${VERSION}_mac.zip 101 | - name: Release 102 | env: 103 | GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} 104 | run: | 105 | semantic-release 106 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | *.exe 4 | *.rar 5 | *.zip 6 | config.json 7 | report.txt 8 | dist 9 | temp 10 | sea-prep.blob 11 | target/** -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": "main", 3 | "ci": false, 4 | "plugins": [ 5 | "@semantic-release/commit-analyzer", 6 | "@semantic-release/release-notes-generator", 7 | "@semantic-release/changelog", 8 | [ 9 | "@semantic-release/git", 10 | { 11 | "assets": [ 12 | "Cargo.toml", 13 | "CHANGELOG.md" 14 | ], 15 | "message": "release: v${nextRelease.version}\n\n${nextRelease.notes}" 16 | } 17 | ], 18 | [ 19 | "@semantic-release/github", 20 | { 21 | "assets": [ 22 | { 23 | "path": "artifacts/git-commit-analytics_v*_win.zip", 24 | "label": "Windows Build" 25 | }, 26 | { 27 | "path": "artifacts/git-commit-analytics_v*_mac.zip", 28 | "label": "macOS Build" 29 | } 30 | ] 31 | } 32 | ] 33 | ] 34 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "eslint.useFlatConfig": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "always", 6 | "source.fixAll.prettier": "always" 7 | }, 8 | "cSpell.words": [ 9 | "analyticsjs", 10 | "chrono", 11 | "codesign", 12 | "postject", 13 | "reloc", 14 | "signtool", 15 | "taze" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.0.3](https://github.com/analyticsjs/git-commit-analytics/compare/v2.0.2...v2.0.3) (2025-05-01) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * add git availability check with download link ([e2f2ebd](https://github.com/analyticsjs/git-commit-analytics/commit/e2f2ebd43f5df619260dc4935f8b2db2152628ea)) 7 | * **config:** allow optional config fields with default values ([30fac51](https://github.com/analyticsjs/git-commit-analytics/commit/30fac513f72902e12ec575ba46ae3e974ce3cf0f)) 8 | * prevent window from closing immediately on crash by waiting for keypress ([699b999](https://github.com/analyticsjs/git-commit-analytics/commit/699b999a324b6a563636466ed705894c135d0e7b)) 9 | 10 | ## [2.0.2](https://github.com/analyticsjs/git-commit-analytics/compare/v2.0.1...v2.0.2) (2025-04-28) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * **report:** deduplicate commit messages within each group in the report ([e669454](https://github.com/analyticsjs/git-commit-analytics/commit/e669454aaedbf02a2b2db495a9da0b3de3a5908c)) 16 | * **windows:** statically link MSVC CRT to fix missing vcruntime140.dll issue ([097997e](https://github.com/analyticsjs/git-commit-analytics/commit/097997e740a1a333ebd51e16f7a65fd3e9e69c96)) 17 | 18 | ## [2.0.1](https://github.com/analyticsjs/git-commit-analytics/compare/v2.0.0...v2.0.1) (2025-04-28) 19 | 20 | 21 | ### Bug Fixes 22 | 23 | * **release:** upload built artifacts with versioned filenames to GitHub assets ([98616fc](https://github.com/analyticsjs/git-commit-analytics/commit/98616fcdb6eff5cb75b4dbc1ae4f2143fe86692d)) 24 | 25 | # [2.0.0](https://github.com/analyticsjs/git-commit-analytics/compare/v1.5.1...v2.0.0) (2025-04-27) 26 | 27 | 28 | * feat!: rewrite project in Rust ([3540027](https://github.com/analyticsjs/git-commit-analytics/commit/3540027a75cef19fa4a804391fd34bdb305a9d5f)) 29 | 30 | 31 | ### Features 32 | 33 | * **config:** support config file reading and global access ([7f8d2ad](https://github.com/analyticsjs/git-commit-analytics/commit/7f8d2adc7749329905230f7e443f515213c5d8b0)) 34 | * env-based root path selection & extract config constants ([f08b360](https://github.com/analyticsjs/git-commit-analytics/commit/f08b3607beb232277f427654fec310a8897b2df7)) 35 | * **i18n:** add basic internationalization support ([1c2c14c](https://github.com/analyticsjs/git-commit-analytics/commit/1c2c14cdceb3d6d4d86f9b9d89f9acc8ab9bd1e8)) 36 | * **main:** implement main process with global config, error handling, and report generation ([8d9f7a8](https://github.com/analyticsjs/git-commit-analytics/commit/8d9f7a8f31e9a56a8726beed8c6c41e8c92e5963)) 37 | * **utils:** add get_repo_name function with robust path handling and tests ([8c177a6](https://github.com/analyticsjs/git-commit-analytics/commit/8c177a6e699110eecdc5287d060da3a6fb23652e)) 38 | * **utils:** add keyboard interaction utilities ([c5b8b2c](https://github.com/analyticsjs/git-commit-analytics/commit/c5b8b2c1ba4a4ecc1e8f2862f6d894bd84eefc38)) 39 | * **utils:** add save_report_markdown for generating i18n-friendly Markdown reports ([7a3b551](https://github.com/analyticsjs/git-commit-analytics/commit/7a3b55107f2890425c6d83c8d8a4a67d924be026)) 40 | * **utils:** implement filter_logs to filter submission records of specified rules ([75e874b](https://github.com/analyticsjs/git-commit-analytics/commit/75e874b87a7578cf9941a350ebf8e97a1f4380d7)) 41 | * **utils:** implement format_commit function with unit tests ([4cd4fc5](https://github.com/analyticsjs/git-commit-analytics/commit/4cd4fc5b4d04d61655310784f8eb1543706a4b26)) 42 | * **utils:** implement format_log function for parsing and structuring git log lines ([f3a7379](https://github.com/analyticsjs/git-commit-analytics/commit/f3a737954fc65a3e1d45f48cea1085dcb7cb7bae)) 43 | * **utils:** implement get_repo_logs for cross-platform git log retrieval ([d2178d0](https://github.com/analyticsjs/git-commit-analytics/commit/d2178d026f85ed050c596b5e7000a73ea5bbd603)) 44 | 45 | 46 | ### BREAKING CHANGES 47 | 48 | * The entire project has been refactored and rewritten in Rust. 49 | Previous JavaScript implementation and related files have been removed. 50 | All usage, configuration, and build processes are now based on the Rust version. 51 | -------------------------------------------------------------------------------- /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 = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "android-tzdata" 16 | version = "0.1.1" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 19 | 20 | [[package]] 21 | name = "android_system_properties" 22 | version = "0.1.5" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 25 | dependencies = [ 26 | "libc", 27 | ] 28 | 29 | [[package]] 30 | name = "anyhow" 31 | version = "1.0.98" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" 34 | 35 | [[package]] 36 | name = "autocfg" 37 | version = "1.4.0" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 40 | 41 | [[package]] 42 | name = "bumpalo" 43 | version = "3.17.0" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 46 | 47 | [[package]] 48 | name = "cc" 49 | version = "1.2.19" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" 52 | dependencies = [ 53 | "shlex", 54 | ] 55 | 56 | [[package]] 57 | name = "cfg-if" 58 | version = "1.0.0" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 61 | 62 | [[package]] 63 | name = "chrono" 64 | version = "0.4.40" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" 67 | dependencies = [ 68 | "android-tzdata", 69 | "iana-time-zone", 70 | "js-sys", 71 | "num-traits", 72 | "wasm-bindgen", 73 | "windows-link", 74 | ] 75 | 76 | [[package]] 77 | name = "core-foundation-sys" 78 | version = "0.8.7" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 81 | 82 | [[package]] 83 | name = "git-commit-analytics" 84 | version = "2.0.0" 85 | dependencies = [ 86 | "anyhow", 87 | "chrono", 88 | "phf", 89 | "regex", 90 | "serde", 91 | "serde_json", 92 | ] 93 | 94 | [[package]] 95 | name = "iana-time-zone" 96 | version = "0.1.63" 97 | source = "registry+https://github.com/rust-lang/crates.io-index" 98 | checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" 99 | dependencies = [ 100 | "android_system_properties", 101 | "core-foundation-sys", 102 | "iana-time-zone-haiku", 103 | "js-sys", 104 | "log", 105 | "wasm-bindgen", 106 | "windows-core", 107 | ] 108 | 109 | [[package]] 110 | name = "iana-time-zone-haiku" 111 | version = "0.1.2" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 114 | dependencies = [ 115 | "cc", 116 | ] 117 | 118 | [[package]] 119 | name = "itoa" 120 | version = "1.0.15" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 123 | 124 | [[package]] 125 | name = "js-sys" 126 | version = "0.3.77" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 129 | dependencies = [ 130 | "once_cell", 131 | "wasm-bindgen", 132 | ] 133 | 134 | [[package]] 135 | name = "libc" 136 | version = "0.2.172" 137 | source = "registry+https://github.com/rust-lang/crates.io-index" 138 | checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" 139 | 140 | [[package]] 141 | name = "log" 142 | version = "0.4.27" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 145 | 146 | [[package]] 147 | name = "memchr" 148 | version = "2.7.4" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 151 | 152 | [[package]] 153 | name = "num-traits" 154 | version = "0.2.19" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 157 | dependencies = [ 158 | "autocfg", 159 | ] 160 | 161 | [[package]] 162 | name = "once_cell" 163 | version = "1.21.3" 164 | source = "registry+https://github.com/rust-lang/crates.io-index" 165 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 166 | 167 | [[package]] 168 | name = "phf" 169 | version = "0.11.3" 170 | source = "registry+https://github.com/rust-lang/crates.io-index" 171 | checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" 172 | dependencies = [ 173 | "phf_macros", 174 | "phf_shared", 175 | ] 176 | 177 | [[package]] 178 | name = "phf_generator" 179 | version = "0.11.3" 180 | source = "registry+https://github.com/rust-lang/crates.io-index" 181 | checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" 182 | dependencies = [ 183 | "phf_shared", 184 | "rand", 185 | ] 186 | 187 | [[package]] 188 | name = "phf_macros" 189 | version = "0.11.3" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" 192 | dependencies = [ 193 | "phf_generator", 194 | "phf_shared", 195 | "proc-macro2", 196 | "quote", 197 | "syn", 198 | ] 199 | 200 | [[package]] 201 | name = "phf_shared" 202 | version = "0.11.3" 203 | source = "registry+https://github.com/rust-lang/crates.io-index" 204 | checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" 205 | dependencies = [ 206 | "siphasher", 207 | ] 208 | 209 | [[package]] 210 | name = "proc-macro2" 211 | version = "1.0.94" 212 | source = "registry+https://github.com/rust-lang/crates.io-index" 213 | checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" 214 | dependencies = [ 215 | "unicode-ident", 216 | ] 217 | 218 | [[package]] 219 | name = "quote" 220 | version = "1.0.40" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 223 | dependencies = [ 224 | "proc-macro2", 225 | ] 226 | 227 | [[package]] 228 | name = "rand" 229 | version = "0.8.5" 230 | source = "registry+https://github.com/rust-lang/crates.io-index" 231 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 232 | dependencies = [ 233 | "rand_core", 234 | ] 235 | 236 | [[package]] 237 | name = "rand_core" 238 | version = "0.6.4" 239 | source = "registry+https://github.com/rust-lang/crates.io-index" 240 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 241 | 242 | [[package]] 243 | name = "regex" 244 | version = "1.11.1" 245 | source = "registry+https://github.com/rust-lang/crates.io-index" 246 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 247 | dependencies = [ 248 | "aho-corasick", 249 | "memchr", 250 | "regex-automata", 251 | "regex-syntax", 252 | ] 253 | 254 | [[package]] 255 | name = "regex-automata" 256 | version = "0.4.9" 257 | source = "registry+https://github.com/rust-lang/crates.io-index" 258 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 259 | dependencies = [ 260 | "aho-corasick", 261 | "memchr", 262 | "regex-syntax", 263 | ] 264 | 265 | [[package]] 266 | name = "regex-syntax" 267 | version = "0.8.5" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 270 | 271 | [[package]] 272 | name = "rustversion" 273 | version = "1.0.20" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" 276 | 277 | [[package]] 278 | name = "ryu" 279 | version = "1.0.20" 280 | source = "registry+https://github.com/rust-lang/crates.io-index" 281 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 282 | 283 | [[package]] 284 | name = "serde" 285 | version = "1.0.219" 286 | source = "registry+https://github.com/rust-lang/crates.io-index" 287 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 288 | dependencies = [ 289 | "serde_derive", 290 | ] 291 | 292 | [[package]] 293 | name = "serde_derive" 294 | version = "1.0.219" 295 | source = "registry+https://github.com/rust-lang/crates.io-index" 296 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 297 | dependencies = [ 298 | "proc-macro2", 299 | "quote", 300 | "syn", 301 | ] 302 | 303 | [[package]] 304 | name = "serde_json" 305 | version = "1.0.140" 306 | source = "registry+https://github.com/rust-lang/crates.io-index" 307 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 308 | dependencies = [ 309 | "itoa", 310 | "memchr", 311 | "ryu", 312 | "serde", 313 | ] 314 | 315 | [[package]] 316 | name = "shlex" 317 | version = "1.3.0" 318 | source = "registry+https://github.com/rust-lang/crates.io-index" 319 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 320 | 321 | [[package]] 322 | name = "siphasher" 323 | version = "1.0.1" 324 | source = "registry+https://github.com/rust-lang/crates.io-index" 325 | checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" 326 | 327 | [[package]] 328 | name = "syn" 329 | version = "2.0.100" 330 | source = "registry+https://github.com/rust-lang/crates.io-index" 331 | checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" 332 | dependencies = [ 333 | "proc-macro2", 334 | "quote", 335 | "unicode-ident", 336 | ] 337 | 338 | [[package]] 339 | name = "unicode-ident" 340 | version = "1.0.18" 341 | source = "registry+https://github.com/rust-lang/crates.io-index" 342 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 343 | 344 | [[package]] 345 | name = "wasm-bindgen" 346 | version = "0.2.100" 347 | source = "registry+https://github.com/rust-lang/crates.io-index" 348 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 349 | dependencies = [ 350 | "cfg-if", 351 | "once_cell", 352 | "rustversion", 353 | "wasm-bindgen-macro", 354 | ] 355 | 356 | [[package]] 357 | name = "wasm-bindgen-backend" 358 | version = "0.2.100" 359 | source = "registry+https://github.com/rust-lang/crates.io-index" 360 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 361 | dependencies = [ 362 | "bumpalo", 363 | "log", 364 | "proc-macro2", 365 | "quote", 366 | "syn", 367 | "wasm-bindgen-shared", 368 | ] 369 | 370 | [[package]] 371 | name = "wasm-bindgen-macro" 372 | version = "0.2.100" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 375 | dependencies = [ 376 | "quote", 377 | "wasm-bindgen-macro-support", 378 | ] 379 | 380 | [[package]] 381 | name = "wasm-bindgen-macro-support" 382 | version = "0.2.100" 383 | source = "registry+https://github.com/rust-lang/crates.io-index" 384 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 385 | dependencies = [ 386 | "proc-macro2", 387 | "quote", 388 | "syn", 389 | "wasm-bindgen-backend", 390 | "wasm-bindgen-shared", 391 | ] 392 | 393 | [[package]] 394 | name = "wasm-bindgen-shared" 395 | version = "0.2.100" 396 | source = "registry+https://github.com/rust-lang/crates.io-index" 397 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 398 | dependencies = [ 399 | "unicode-ident", 400 | ] 401 | 402 | [[package]] 403 | name = "windows-core" 404 | version = "0.61.0" 405 | source = "registry+https://github.com/rust-lang/crates.io-index" 406 | checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" 407 | dependencies = [ 408 | "windows-implement", 409 | "windows-interface", 410 | "windows-link", 411 | "windows-result", 412 | "windows-strings", 413 | ] 414 | 415 | [[package]] 416 | name = "windows-implement" 417 | version = "0.60.0" 418 | source = "registry+https://github.com/rust-lang/crates.io-index" 419 | checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" 420 | dependencies = [ 421 | "proc-macro2", 422 | "quote", 423 | "syn", 424 | ] 425 | 426 | [[package]] 427 | name = "windows-interface" 428 | version = "0.59.1" 429 | source = "registry+https://github.com/rust-lang/crates.io-index" 430 | checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" 431 | dependencies = [ 432 | "proc-macro2", 433 | "quote", 434 | "syn", 435 | ] 436 | 437 | [[package]] 438 | name = "windows-link" 439 | version = "0.1.1" 440 | source = "registry+https://github.com/rust-lang/crates.io-index" 441 | checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" 442 | 443 | [[package]] 444 | name = "windows-result" 445 | version = "0.3.2" 446 | source = "registry+https://github.com/rust-lang/crates.io-index" 447 | checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" 448 | dependencies = [ 449 | "windows-link", 450 | ] 451 | 452 | [[package]] 453 | name = "windows-strings" 454 | version = "0.4.0" 455 | source = "registry+https://github.com/rust-lang/crates.io-index" 456 | checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" 457 | dependencies = [ 458 | "windows-link", 459 | ] 460 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["chengpeiquan "] 3 | description = "A tool for analyzing git commit logs and generating daily, weekly, or custom work reports." 4 | edition = "2021" 5 | license = "MIT" 6 | name = "git-commit-analytics" 7 | version = "2.0.0" 8 | 9 | [dependencies] 10 | # Error handling with context 11 | anyhow = "1.0" 12 | 13 | # Date and time handling 14 | chrono = "0.4" 15 | 16 | # Regular expression support 17 | regex = "1.10" 18 | 19 | # Serialization and deserialization 20 | serde = {version = "1.0", features = ["derive"]} 21 | 22 | # JSON parsing and formatting 23 | serde_json = "1.0" 24 | 25 | # Key-value pair storage 26 | phf = {version = "0.11", features = ["macros"]} 27 | 28 | [profile.release] 29 | # Optimize for size 30 | codegen-units = 1 31 | lto = true 32 | opt-level = 'z' 33 | panic = 'abort' 34 | strip = true 35 | -------------------------------------------------------------------------------- /Makefile.toml: -------------------------------------------------------------------------------- 1 | [tasks.dev] 2 | args = ["run"] 3 | command = "cargo" 4 | env = {APP_ENV = "development"} 5 | 6 | [tasks.build] 7 | args = ["build", "--release"] 8 | command = "cargo" 9 | env = {APP_ENV = "production"} 10 | 11 | [tasks.clean] 12 | args = ["clean"] 13 | command = "cargo" 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-commit-analytics 2 | 3 | English | [简体中文](https://github.com/analyticsjs/git-commit-analytics/blob/main/README.zh-CN.md) 4 | 5 | A tool for analyzing git commit logs and generating daily, weekly, or custom work reports. 6 | 7 | ![git-commit-analytics](https://cdn.chengpeiquan.com/img/2025/05/202505020245534.gif) 8 | 9 | ## 🚀 Download 10 | 11 | This is a client tool, so you need to download the program to use it. See: [The Latest Release](https://github.com/analyticsjs/git-commit-analytics/releases/latest) to download. 12 | 13 | > Note: This tool requires Git to be installed and properly configured in your system's PATH. Please make sure Git is installed before running the program. 14 | 15 | ## ⚡ Usage 16 | 17 | Create and fill in your configuration file, and then run the program to get your work report. 18 | 19 | ## 📂 Configuration File 20 | 21 | You need to create a `config.json` at the same folder with the program, and write the content in the following format. 22 | 23 | ```json 24 | { 25 | "lang": "en", 26 | "authors": ["my-name"], 27 | "dateRange": ["2025-04-01", "2025-05-01"], 28 | "repos": ["/path/to/my-project-folder"], 29 | "format": { 30 | "my-project-folder": "My Awesome Project" 31 | }, 32 | "includes": ["feat", "fix", "docs", "style", "refactor", "test", "chore"], 33 | "excludes": ["typo", "backup"] 34 | } 35 | ``` 36 | 37 | **NOTE:** Please configure the repos path according to your operating system. For example: 38 | 39 | - On Windows, you must use backslashes (\), e.g., `D:\\path\\to\\folder-name` 40 | - On macOS, use forward slashes (/), e.g., `/path/to/folder-name` 41 | 42 | The configuration items are described as follows: 43 | 44 | | key | type | description | 45 | | :-------: | :-----------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 46 | | lang | string | Set program default language, support `en` (English, default) and `zh` (Simplified Chinese). | 47 | | authors | string[] | Filter commit authors. Supports multiple author names, useful if you use different names in different repositories. | 48 | | dateRange | [string, string] | Specify [start date, end date]. Supports valid date formats. The statistics cover from `00:00:00` of the start date to `23:59:59` of the end date. If not set, defaults to the current day. | 49 | | repos | string[] | The Git repository folders on your computer. Please switch to the branch you want to analyze in advance. | 50 | | format | { [key: string]: string } | Format your folder names as project names. | 51 | | includes | string[] | Commit message prefixes to include in the statistics. | 52 | | excludes | string[] | Exclude commit messages containing these keywords from the results. | 53 | 54 | Among them, `authors` / `includes` / `excludes` will be created as regular expressions to match data. 55 | 56 | ## 📚 Report File 57 | 58 | The report file will be generated in `markdown` syntax (probably the most common format for developer?) and saved as a file in `.txt` format (probably the most compatible format?). 59 | 60 | The project name will be classified as the second-level title, and 7 types of commit prefixes will be classified as the third-level title: 61 | 62 | | type | description | 63 | | :------: | :-------------: | 64 | | feat | Features | 65 | | fix | Bug Fixes | 66 | | docs | Documentation | 67 | | style | Optimized Style | 68 | | refactor | Refactored | 69 | | test | Test Cases | 70 | | chore | Chores | 71 | 72 | You can click [Commit message and Change log writing guide](https://www.ruanyifeng.com/blog/2016/01/commit_message_change_log.html) to learn how to standardize the git commit. 73 | 74 | ## 📝 Release Notes 75 | 76 | Please refer to [CHANGELOG](./CHANGELOG.md) for details. 77 | 78 | ## 📜 License 79 | 80 | [MIT License](./LICENSE) © 2022 [chengpeiquan](https://github.com/chengpeiquan) 81 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # git-commit-analytics 2 | 3 | [English](https://github.com/analyticsjs/git-commit-analytics/blob/main/README.md) | 简体中文 4 | 5 | 一款用于分析 Git 提交日志并生成每日、每周或自定义工作报告的工具。 6 | 7 | ![git-commit-analytics](https://cdn.chengpeiquan.com/img/2025/05/202505020245534.gif) 8 | 9 | ## 🚀 下载安装 10 | 11 | 这是一个客户端工具,所以你需要下载程序去使用它,点击 [最新版本](https://github.com/analyticsjs/git-commit-analytics/releases/latest) 去下载客户端。 12 | 13 | > 注意: 本工具依赖于已安装并配置好的 Git,请确保在运行前已正确安装 Git 并将其添加到环境变量中。 14 | 15 | ## ⚡ 使用方法 16 | 17 | 创建并填写你的配置文件,然后运行程序,即可获得你的工作报告。 18 | 19 | ## 📂 配置文件 20 | 21 | 需要在与程序相同的文件夹下,创建一个名为 `config.json` 的文件,并写入以下格式的内容。 22 | 23 | ```json 24 | { 25 | "lang": "en", 26 | "authors": ["my-name"], 27 | "dateRange": ["2025-04-01", "2025-05-01"], 28 | "repos": ["/path/to/my-project-folder"], 29 | "format": { 30 | "my-project-folder": "My Awesome Project" 31 | }, 32 | "includes": ["feat", "fix", "docs", "style", "refactor", "test", "chore"], 33 | "excludes": ["typo", "backup"] 34 | } 35 | ``` 36 | 37 | **提醒:** 请根据操作系统正确配置 repos 字段的路径。例如: 38 | 39 | - 在 Windows 系统中,路径必须使用反斜杠(\),如:`D:\\path\\to\\folder-name` 40 | - 在 macOS 下,请使用正斜杠(/),如:`/path/to/folder-name` 41 | 42 | 配置项说明如下: 43 | 44 | | key | type | description | 45 | | :-------: | :-----------------------: | :------------------------------------------------------------------------------------------------------------------------------------------- | 46 | | lang | string | 设置软件的默认语言,支持 `en` (英语,默认值)和 `zh` (简体中文)。 | 47 | | authors | string[] | 筛选 commit 的作者名称,支持多个作者名称,用于你在不同的仓库可能有不同的名字。 | 48 | | dateRange | [string, string] | 填写 [开始日期, 结束日期] , 支持合法的时间格式,会从开始日期的 `00:00:00` 统计到截止日期的 `23:59:59` (如果不配置则默认运行程序的当天)。 | 49 | | repos | string[] | 你电脑里的 Git 仓库文件夹,需要提前切换到你要统计的分支。 | 50 | | format | { [key: string]: string } | 格式化你的文件夹名称为项目名。 | 51 | | includes | string[] | 要纳入统计的 commit message 前缀。 | 52 | | excludes | string[] | 在统计出来的结果里,排除掉包含了这些关键词的 commit message 。 | 53 | 54 | 其中,`authors` / `includes` / `excludes` 会创建为正则表达式去匹配数据。 55 | 56 | ## 📚 报告文件 57 | 58 | 报告文件会以 `markdown` 语法生成(可能是对程序员最通用的格式?),并以 `.txt` 格式的文件保存(可能是兼容性最好的格式?)。 59 | 60 | 会以项目名称作为二级标题归类,以 7 个类型的 commit 前缀作为三级标题归类: 61 | 62 | | type | description | 63 | | :------: | :---------: | 64 | | feat | 功能开发 | 65 | | fix | BUG 修复 | 66 | | docs | 完善文档 | 67 | | style | 优化样式 | 68 | | refactor | 代码重构 | 69 | | test | 测试用例 | 70 | | chore | 其他优化 | 71 | 72 | 可以点击 [Commit message 和 Change log 编写指南](https://www.ruanyifeng.com/blog/2016/01/commit_message_change_log.html) 学习如何规范化提交 Git Commit 。 73 | 74 | ## 📝 Release Notes 75 | 76 | 详细更新内容请参考 [更新记录](./CHANGELOG.md) 。 77 | 78 | ## 📜 License 79 | 80 | [MIT License](./LICENSE) © 2022 [chengpeiquan](https://github.com/chengpeiquan) 81 | -------------------------------------------------------------------------------- /src/config/constants.rs: -------------------------------------------------------------------------------- 1 | pub const CONFIG_FILE_NAME: &str = "config.json"; 2 | 3 | pub const REPORT_FILE_NAME: &str = "report.txt"; 4 | 5 | pub const CATEGORY_ORDER: &[&str] = &["feat", "fix", "docs", "style", "refactor", "test", "chore"]; 6 | -------------------------------------------------------------------------------- /src/config/error.rs: -------------------------------------------------------------------------------- 1 | use crate::config::constants::CONFIG_FILE_NAME; 2 | 3 | #[derive(Debug)] 4 | pub enum ConfigError { 5 | FileNotFound, 6 | ParseError, 7 | InvalidConfig(String), 8 | } 9 | 10 | impl std::error::Error for ConfigError {} 11 | 12 | impl std::fmt::Display for ConfigError { 13 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 14 | match self { 15 | ConfigError::FileNotFound => { 16 | write!( 17 | f, 18 | "\nPlease make sure there is a {} file in the program directory.", 19 | CONFIG_FILE_NAME 20 | ) 21 | } 22 | ConfigError::ParseError => { 23 | write!( 24 | f, 25 | "\nFailed to parse {}, please check the file format.", 26 | CONFIG_FILE_NAME 27 | ) 28 | } 29 | ConfigError::InvalidConfig(msg) => { 30 | write!(f, "{}", msg) 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod constants; 2 | mod error; 3 | mod schema; 4 | mod store; 5 | mod types; 6 | 7 | use constants::CONFIG_FILE_NAME; 8 | use error::ConfigError; 9 | use schema::ConfigFile; 10 | use std::path::PathBuf; 11 | 12 | pub use store::{init, is_chinese, print_config}; 13 | pub use types::Config; 14 | 15 | fn find_config_file(root_path: &PathBuf) -> Option { 16 | let config_file_path = root_path.join(CONFIG_FILE_NAME); 17 | if config_file_path.exists() { 18 | return Some(config_file_path); 19 | } 20 | 21 | // 3. If both are not found, return None 22 | None 23 | } 24 | 25 | /// Initialize configuration from file or use default settings 26 | pub fn init_config(root_path: &PathBuf) -> Result { 27 | let config_path = find_config_file(root_path).ok_or(ConfigError::FileNotFound)?; 28 | 29 | ConfigFile::from_file(config_path.to_str().unwrap()) 30 | .map_err(|_e| ConfigError::ParseError) 31 | .and_then(|file_config| { 32 | let config = Config::new_from_file(&file_config)?; 33 | init(config.clone()); 34 | Ok(config) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /src/config/schema.rs: -------------------------------------------------------------------------------- 1 | use super::error::ConfigError; 2 | use serde::Deserialize; 3 | use std::{collections::HashMap, fs}; 4 | 5 | // The schema of the config file 6 | #[derive(Debug, Deserialize)] 7 | pub struct ConfigFile { 8 | /// Language setting: 'en' or 'zh' 9 | #[serde(default)] 10 | pub lang: String, 11 | 12 | /// List of author names 13 | /// Vec is equivalent to string[] in TypeScript 14 | pub authors: Vec, 15 | 16 | /// Date range: [start_date, end_date] 17 | /// Vec here represents a fixed-length array of 2 strings 18 | #[serde(rename = "dateRange", default)] 19 | pub date_range: Vec, 20 | 21 | /// List of Git repository paths 22 | pub repos: Vec, 23 | 24 | /// Repository name formatting map 25 | /// HashMap is equivalent to Record in TypeScript 26 | /// or { [key: string]: string } 27 | #[serde(default)] 28 | pub format: HashMap, 29 | 30 | /// List of commit types to include 31 | #[serde(default)] 32 | pub includes: Vec, 33 | 34 | /// List of keywords to exclude 35 | #[serde(default)] 36 | pub excludes: Vec, 37 | } 38 | 39 | impl ConfigFile { 40 | pub fn from_file(path: &str) -> Result> { 41 | // Reading file contents 42 | let content = fs::read_to_string(path)?; 43 | 44 | // Parsing JSON 45 | let mut config: ConfigFile = serde_json::from_str(&content)?; 46 | 47 | // Allow missing fields 48 | config.fill_defaults(); 49 | 50 | // Validate the config 51 | config.validate()?; 52 | 53 | Ok(config) 54 | } 55 | 56 | fn fill_defaults(&mut self) { 57 | use chrono::Local; 58 | 59 | // lang default en 60 | if self.lang.is_empty() { 61 | self.lang = "en".to_string(); 62 | } 63 | 64 | // dateRange default today 65 | if self.date_range.len() != 2 { 66 | let today = Local::now().format("%Y-%m-%d").to_string(); 67 | self.date_range = vec![today.clone(), today]; 68 | } 69 | 70 | // includes default the day the program is running 71 | if self.includes.is_empty() { 72 | self.includes = vec![ 73 | "feat".into(), 74 | "fix".into(), 75 | "docs".into(), 76 | "style".into(), 77 | "refactor".into(), 78 | "test".into(), 79 | "chore".into(), 80 | ]; 81 | } 82 | 83 | // excludes default empty 84 | if self.excludes.is_empty() { 85 | self.excludes = vec![]; 86 | } 87 | 88 | // format default empty 89 | if self.format.is_empty() { 90 | self.format = std::collections::HashMap::new(); 91 | } 92 | } 93 | 94 | fn validate(&self) -> Result<(), ConfigError> { 95 | if self.authors.is_empty() { 96 | return Err(ConfigError::InvalidConfig( 97 | "authors list cannot be empty".to_string(), 98 | )); 99 | } 100 | if self.repos.is_empty() { 101 | return Err(ConfigError::InvalidConfig( 102 | "repos list cannot be empty".to_string(), 103 | )); 104 | } 105 | Ok(()) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/config/store.rs: -------------------------------------------------------------------------------- 1 | use super::error::ConfigError; 2 | use super::types::Config; 3 | use crate::i18n::t; 4 | use std::sync::OnceLock; 5 | 6 | // Global Configuration Instance 7 | static GLOBAL_CONFIG: OnceLock = OnceLock::new(); 8 | 9 | /// Initialize the global configuration 10 | pub fn init(config: Config) { 11 | let _ = GLOBAL_CONFIG.set(config); 12 | } 13 | 14 | /// Get the global configuration instance 15 | /// Returns an error if the configuration is not initialized 16 | pub fn global() -> Result<&'static Config, ConfigError> { 17 | GLOBAL_CONFIG 18 | .get() 19 | .ok_or_else(|| ConfigError::InvalidConfig(t("global_config_not_initialized").to_string())) 20 | } 21 | 22 | /// Check if the current language is Chinese 23 | pub fn is_chinese() -> bool { 24 | global() 25 | .map(|config| matches!(config.language, super::types::Language::Chinese)) 26 | .unwrap_or(false) 27 | } 28 | 29 | pub fn print_config() { 30 | match global() { 31 | Ok(config) => { 32 | println!(); 33 | println!("{}", config); 34 | println!(); 35 | } 36 | Err(e) => { 37 | eprintln!("{}", t("failed_print_config").replace("{}", &e.to_string())); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/config/types.rs: -------------------------------------------------------------------------------- 1 | use super::{error::ConfigError, schema::ConfigFile}; 2 | use crate::i18n::t; 3 | use std::fmt; 4 | 5 | /// Language options for the application 6 | #[derive(Debug, Clone)] 7 | #[allow(dead_code)] 8 | pub enum Language { 9 | English, 10 | Chinese, 11 | } 12 | 13 | // Implement the Display trait 14 | // e.g. `println!("{}", Language::English);` will print `en` 15 | impl fmt::Display for Language { 16 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 17 | match self { 18 | Language::English => write!(f, "en"), 19 | Language::Chinese => write!(f, "zh"), 20 | } 21 | } 22 | } 23 | 24 | /// Parsing from string to enumeration implementation 25 | impl Language { 26 | pub fn from_str(s: &str) -> Option { 27 | match s.to_lowercase().as_str() { 28 | "en" => Some(Language::English), 29 | "zh" => Some(Language::Chinese), 30 | _ => None, 31 | } 32 | } 33 | } 34 | 35 | /// Global configuration structure 36 | #[derive(Debug, Clone)] 37 | pub struct Config { 38 | pub language: Language, 39 | pub authors: Vec, 40 | pub date_range: Vec, 41 | pub repos: Vec, 42 | pub format: std::collections::HashMap, 43 | pub includes: Vec, 44 | pub excludes: Vec, 45 | } 46 | 47 | impl Config { 48 | /// Create a new configuration instance with default values 49 | pub fn new_from_file(file_config: &ConfigFile) -> Result { 50 | let language = Language::from_str(&file_config.lang) 51 | .ok_or_else(|| ConfigError::InvalidConfig("Invalid language setting".to_string()))?; 52 | 53 | Ok(Self { 54 | language, 55 | authors: file_config.authors.clone(), 56 | date_range: file_config.date_range.clone(), 57 | repos: file_config.repos.clone(), 58 | format: file_config.format.clone(), 59 | includes: file_config.includes.clone(), 60 | excludes: file_config.excludes.clone(), 61 | }) 62 | } 63 | } 64 | 65 | impl fmt::Display for Config { 66 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 67 | writeln!(f, "")?; 68 | writeln!(f, "{}", t("config_details"))?; 69 | writeln!(f, "")?; 70 | writeln!( 71 | f, 72 | "{}: {}", 73 | t("language"), 74 | if matches!(self.language, Language::Chinese) { 75 | "Chinese" 76 | } else { 77 | "English" 78 | } 79 | )?; 80 | writeln!(f, "{}: {:?}", t("authors"), self.authors)?; 81 | writeln!( 82 | f, 83 | "{}: {} - {}", 84 | t("date_range"), 85 | self.date_range.get(0).unwrap_or(&"N/A".to_string()), 86 | self.date_range.get(1).unwrap_or(&"N/A".to_string()) 87 | )?; 88 | writeln!(f, "{}:", t("repos"))?; 89 | for repo in &self.repos { 90 | if let Some(display_name) = self.format.get(repo) { 91 | writeln!(f, " - {} ({})", display_name, repo)?; 92 | } else { 93 | writeln!(f, " - {}", repo)?; 94 | } 95 | } 96 | writeln!(f, "{}:", t("includes"))?; 97 | for inc in &self.includes { 98 | writeln!(f, " - {}", inc)?; 99 | } 100 | if !self.excludes.is_empty() { 101 | writeln!(f, "{}:", t("excludes"))?; 102 | for exc in &self.excludes { 103 | writeln!(f, " - {}", exc)?; 104 | } 105 | } 106 | writeln!(f, "")?; 107 | writeln!(f, "--------------------------------")?; 108 | Ok(()) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/i18n/messages/en.rs: -------------------------------------------------------------------------------- 1 | use phf::phf_map; 2 | 3 | pub static MESSAGES: phf::Map<&'static str, &'static str> = phf_map! { 4 | // Git 5 | "err_git_not_found" => "Git is not installed or not in system PATH. Please install Git first.\nVisit https://git-scm.com/downloads to download.", 6 | "err_git_not_working" => "Git is installed but not working properly. Please check your Git installation.\nTo reinstall, visit https://git-scm.com/downloads .", 7 | 8 | // Configuration file I/O 9 | "config_loaded" => "Configuration loaded successfully", 10 | "failed_print_config" => "Failed to print configuration: {}", 11 | "failed_parse_config" => "Failed to parse config: {}", 12 | "global_config_not_initialized" => "Global configuration not initialized", 13 | 14 | // Configuration file details 15 | "config_details" => "---- Configuration Details -----", 16 | "language" => "Language", 17 | "authors" => "Authors", 18 | "date_range" => "Date Range", 19 | "repos" => "Repositories", 20 | "includes" => "Includes", 21 | "excludes" => "Excludes", 22 | "format" => "Format", 23 | 24 | // Keypress 25 | "wait_for_key" => "Press any key to continue...", 26 | "press_to_exit" => "Press any key to exit...", 27 | 28 | // Commit categories 29 | "commit_category_features" => "Features", 30 | "commit_category_bug_fixes" => "Bug Fixes", 31 | "commit_category_docs" => "Documentation", 32 | "commit_category_style" => "Optimized Style", 33 | "commit_category_refactor" => "Refactored", 34 | "commit_category_test" => "Test Cases", 35 | "commit_category_chores" => "Chores", 36 | 37 | // Git repository error messages 38 | "err_repo_not_found" => "Repo path does not exist or is not a directory: {}", 39 | "err_git_log_failed" => "git log failed in directory: {}\nstderr: {}", 40 | 41 | // Save report 42 | "no_report_generated" => "No report generated", 43 | "err_save_report_failed" => "Failed to save report: {}", 44 | "report_saved" => "Report saved to {}", 45 | }; 46 | -------------------------------------------------------------------------------- /src/i18n/messages/zh.rs: -------------------------------------------------------------------------------- 1 | use phf::phf_map; 2 | 3 | pub static MESSAGES: phf::Map<&'static str, &'static str> = phf_map! { 4 | // Git 5 | "err_git_not_found" => "Git 未安装或未添加到系统 PATH 中,请先安装 Git。\n访问 https://git-scm.com/downloads 下载安装。", 6 | "err_git_not_working" => "Git 已安装但无法正常工作,请检查 Git 安装状态。\n如需重新安装,请访问 https://git-scm.com/downloads 。", 7 | 8 | // Configuration file I/O 9 | "config_loaded" => "配置加载成功", 10 | "failed_print_config" => "打印配置失败:{}", 11 | "failed_parse_config" => "解析配置失败:{}", 12 | "global_config_not_initialized" => "全局配置未初始化", 13 | 14 | // Configuration file details 15 | "config_details" => "----------- 配置详情 -----------", 16 | "language" => "语言", 17 | "authors" => "作者", 18 | "date_range" => "日期范围", 19 | "repos" => "仓库", 20 | "includes" => "包含", 21 | "excludes" => "排除", 22 | "format" => "格式", 23 | 24 | // Keypress 25 | "wait_for_key" => "按任意键继续...", 26 | "press_to_exit" => "按任意键退出...", 27 | 28 | // Commit categories 29 | "commit_category_features" => "功能开发", 30 | "commit_category_bug_fixes" => "BUG修复", 31 | "commit_category_docs" => "完善文档", 32 | "commit_category_style" => "优化样式", 33 | "commit_category_refactor" => "代码重构", 34 | "commit_category_test" => "测试用例", 35 | "commit_category_chores" => "其他优化", 36 | 37 | // Git repository error messages 38 | "err_repo_not_found" => "仓库路径不存在或不是目录:{}", 39 | "err_git_log_failed" => "git log 执行失败,目录:{}\n错误信息:{}", 40 | 41 | // Save report 42 | "no_report_generated" => "未生成报告", 43 | "err_save_report_failed" => "保存报告失败:{}", 44 | "report_saved" => "报告已保存到:{}", 45 | }; 46 | -------------------------------------------------------------------------------- /src/i18n/mod.rs: -------------------------------------------------------------------------------- 1 | mod messages { 2 | pub mod en; 3 | pub mod zh; 4 | } 5 | 6 | use crate::config; 7 | 8 | pub fn t<'a>(key: &'a str) -> &'a str { 9 | let messages = if config::is_chinese() { 10 | &messages::zh::MESSAGES 11 | } else { 12 | &messages::en::MESSAGES 13 | }; 14 | 15 | messages.get(key).copied().unwrap_or(key) 16 | } 17 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | mod i18n; 3 | mod utils; 4 | 5 | use std::{ 6 | collections::{HashMap, HashSet}, 7 | path::PathBuf, 8 | }; 9 | 10 | use config::{init_config, print_config}; 11 | use i18n::t; 12 | use utils::{ 13 | check_git::check_git_available, 14 | filter_logs::filter_logs, 15 | format_log::{format_log, LogInfo}, 16 | get_repo_logs::get_repo_logs, 17 | get_repo_name::get_repo_name, 18 | keypress::exit_on_keypress, 19 | save_report::save_report_markdown, 20 | }; 21 | 22 | fn run(root_path: PathBuf) -> Result<(), Box> { 23 | // Initialize global configuration. 24 | // After initialization, the config is stored in a global OnceLock singleton, 25 | // and can be accessed from other modules via `config::store::global()`, 26 | // such as in the i18n module. 27 | let config = init_config(&root_path)?; 28 | 29 | // Check if git is available 30 | check_git_available()?; 31 | 32 | print_config(); 33 | 34 | let mut result: HashMap>> = HashMap::new(); 35 | 36 | // Deduplicate logs by repoName and typeName 37 | let mut dedup_map: HashMap>> = HashMap::new(); 38 | 39 | for repo_dir in &config.repos { 40 | // Get repo name 41 | let repo_name = 42 | get_repo_name(repo_dir, &config.format).unwrap_or_else(|| "undefined".to_string()); 43 | 44 | let logs = get_repo_logs(repo_dir)?; 45 | 46 | // Append repoName to the beginning of each log line 47 | let logs_with_repo: Vec = logs 48 | .into_iter() 49 | .map(|log| format!("{}|||{}", repo_name, log)) 50 | .collect(); 51 | 52 | // Filter logs according to the rules in the configuration file 53 | let filtered_logs = match filter_logs( 54 | &logs_with_repo, 55 | &config.authors, 56 | &config.includes, 57 | &config.excludes, 58 | ) { 59 | Ok(l) => l, 60 | Err(e) => { 61 | eprintln!( 62 | "{}", 63 | t("err_filter_logs_failed").replace("{}", &e.to_string()) 64 | ); 65 | continue; 66 | } 67 | }; 68 | 69 | // Formatting and aggregating logs 70 | for log in filtered_logs { 71 | let log_info = format_log(&log); 72 | let type_name = log_info.type_name.clone(); 73 | 74 | // get HashSet of type_name in repo_name, if not exist, create a new one 75 | let type_set = dedup_map 76 | .entry(repo_name.clone()) 77 | .or_default() 78 | .entry(type_name.clone()) 79 | .or_default(); 80 | 81 | // if message not in type_set, push to result 82 | if type_set.insert(log_info.message.clone()) { 83 | result 84 | .entry(repo_name.clone()) 85 | .or_default() 86 | .entry(type_name) 87 | .or_default() 88 | .push(log_info); 89 | } 90 | } 91 | 92 | save_report_markdown(&result, &root_path)?; 93 | 94 | exit_on_keypress(Some(t("press_to_exit"))); 95 | } 96 | 97 | Ok(()) 98 | } 99 | 100 | fn main() { 101 | // Get the APP_ENV environment variable from the startup command (see Makefile.toml). 102 | // Since the working directories differ between development and production, 103 | // we need to handle them separately. 104 | let env = std::env::var("APP_ENV").unwrap_or_else(|_| "production".to_string()); 105 | 106 | let root_path = if env == "development" { 107 | std::env::current_dir().unwrap() 108 | } else { 109 | std::env::current_exe() 110 | .unwrap() 111 | .parent() 112 | .unwrap() 113 | .to_path_buf() 114 | }; 115 | 116 | // Print the root path to help locate issues if the program crashes. 117 | println!(""); 118 | println!("\nProgram root directory:\n{}", root_path.display()); 119 | 120 | // Using `?` to propagate errors to `main` causes Rust's default error output 121 | // (via the `std::process::Termination` trait) to use the Debug format (`{:?}`) 122 | // for printing error types. As a result, only the enum variant name is shown 123 | // (e.g., `FileNotFound`), not the user-friendly message from the Display trait. 124 | // 125 | // Wrapping the main logic in a separate `run` function and handling errors 126 | // explicitly with `eprintln!` ensures that the Display output is used. 127 | if let Err(e) = run(root_path) { 128 | eprintln!("{}", e); // Use Display format to output the error 129 | exit_on_keypress(Some(t("press_to_exit"))); 130 | std::process::exit(1); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/utils/check_git.rs: -------------------------------------------------------------------------------- 1 | use crate::i18n::t; 2 | use std::process::Command; 3 | 4 | pub fn check_git_available() -> anyhow::Result<()> { 5 | let output = Command::new("git").arg("--version").output(); 6 | 7 | match output { 8 | Ok(output) if output.status.success() => Ok(()), 9 | Ok(_) => anyhow::bail!("\n{}\n", t("err_git_not_working")), 10 | Err(_) => anyhow::bail!("\n{}\n", t("err_git_not_found")), 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/filter_logs.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use regex::Regex; 3 | 4 | const AUTHOR_IDX: usize = 1; 5 | const MSG_IDX: usize = 3; 6 | 7 | /// Build a case-insensitive regex, return None if patterns is empty 8 | fn build_regex(patterns: &[String]) -> Option { 9 | let patterns: Vec<&str> = patterns 10 | .iter() 11 | .filter(|s| !s.is_empty()) 12 | .map(|s| s.as_str()) 13 | .collect(); 14 | if patterns.is_empty() { 15 | None 16 | } else { 17 | Some(Regex::new(&format!("(?i){}", patterns.join("|"))).unwrap()) 18 | } 19 | } 20 | 21 | pub fn filter_logs( 22 | logs: &[String], 23 | authors: &[String], 24 | includes: &[String], 25 | excludes: &[String], 26 | ) -> Result> { 27 | // Build regex by configuration 28 | let author_re = build_regex(authors); 29 | let include_re = build_regex(includes); 30 | let exclude_re = build_regex(excludes); 31 | 32 | let filtered: Vec = logs 33 | .iter() 34 | .filter(|log| { 35 | let fields: Vec<&str> = log.split("|||").collect(); 36 | let author = fields.get(AUTHOR_IDX).unwrap_or(&""); 37 | author_re.as_ref().map_or(true, |re| re.is_match(author)) 38 | }) 39 | .filter(|log| { 40 | let fields: Vec<&str> = log.split("|||").collect(); 41 | let msg = fields.get(MSG_IDX).unwrap_or(&""); 42 | include_re.as_ref().map_or(true, |re| re.is_match(msg)) 43 | }) 44 | .filter(|log| { 45 | let fields: Vec<&str> = log.split("|||").collect(); 46 | let msg = fields.get(MSG_IDX).unwrap_or(&""); 47 | !exclude_re.as_ref().map_or(false, |re| re.is_match(msg)) 48 | }) 49 | .cloned() 50 | .collect(); 51 | 52 | Ok(filtered) 53 | } 54 | 55 | #[cfg(test)] 56 | mod tests { 57 | use super::*; 58 | 59 | fn logs() -> Vec { 60 | vec![ 61 | "repo1|||Alice|||alice@example.com|||feat: add feature|||'abc123|||2024-06-01 12:00:00" 62 | .to_string(), 63 | "repo1|||Bob|||bob@example.com|||fix: bug|||'def456|||2024-06-01 13:00:00".to_string(), 64 | "repo2|||Alice|||alice@example.com|||docs: update|||'ghi789|||2024-06-01 14:00:00" 65 | .to_string(), 66 | "repo2|||Eve|||eve@example.com|||chore: deps|||'jkl012|||2024-06-01 15:00:00" 67 | .to_string(), 68 | ] 69 | } 70 | 71 | #[test] 72 | fn test_filter_by_author() { 73 | let logs = logs(); 74 | let authors = vec!["Alice".to_string()]; 75 | let includes = vec!["".to_string()]; // 匹配所有 76 | let excludes = vec!["".to_string()]; // 不排除 77 | let filtered = filter_logs(&logs, &authors, &includes, &excludes).unwrap(); 78 | assert_eq!(filtered.len(), 2); 79 | assert!(filtered.iter().all(|log| log.contains("Alice"))); 80 | } 81 | 82 | #[test] 83 | fn test_filter_by_include() { 84 | let logs = logs(); 85 | let authors = vec!["".to_string()]; // 匹配所有 86 | let includes = vec!["feat".to_string()]; 87 | let excludes = vec!["".to_string()]; 88 | let filtered = filter_logs(&logs, &authors, &includes, &excludes).unwrap(); 89 | assert_eq!(filtered.len(), 1); 90 | assert!(filtered[0].contains("feat: add feature")); 91 | } 92 | 93 | #[test] 94 | fn test_filter_by_exclude() { 95 | let logs = logs(); 96 | let authors = vec!["".to_string()]; 97 | let includes = vec!["".to_string()]; 98 | let excludes = vec!["chore".to_string()]; 99 | let filtered = filter_logs(&logs, &authors, &includes, &excludes).unwrap(); 100 | assert_eq!(filtered.len(), 3); 101 | assert!(filtered.iter().all(|log| !log.contains("chore"))); 102 | } 103 | 104 | #[test] 105 | fn test_filter_combined() { 106 | let logs = logs(); 107 | let authors = vec!["Alice".to_string()]; 108 | let includes = vec!["docs".to_string()]; 109 | let excludes = vec!["".to_string()]; 110 | let filtered = filter_logs(&logs, &authors, &includes, &excludes).unwrap(); 111 | assert_eq!(filtered.len(), 1); 112 | assert!(filtered[0].contains("docs: update")); 113 | } 114 | 115 | #[test] 116 | fn test_filter_none() { 117 | let logs = logs(); 118 | let authors = vec!["NonExist".to_string()]; 119 | let includes = vec!["feat".to_string()]; 120 | let excludes = vec!["".to_string()]; 121 | let filtered = filter_logs(&logs, &authors, &includes, &excludes).unwrap(); 122 | assert_eq!(filtered.len(), 0); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/utils/format_commit.rs: -------------------------------------------------------------------------------- 1 | #[cfg(not(test))] 2 | use crate::i18n::t; 3 | 4 | /// Commit type and its category 5 | pub struct CommitInfo { 6 | pub type_name: String, 7 | pub category: String, 8 | pub message: String, 9 | } 10 | 11 | /// Only used for testing 12 | #[cfg(test)] 13 | const TEST_CATEGORIES: &[(&str, &str)] = &[ 14 | ("feat", "Features"), 15 | ("fix", "Bug Fixes"), 16 | ("docs", "Documentation"), 17 | ("style", "Styles"), 18 | ("refactor", "Code Refactoring"), 19 | ("test", "Tests"), 20 | ("chore", "Chores"), 21 | ]; 22 | 23 | #[cfg(test)] 24 | fn get_commit_type_and_category(type_name: &str) -> (&str, String) { 25 | let (t, c) = TEST_CATEGORIES 26 | .iter() 27 | .find(|(t, _)| *t == type_name) 28 | .map(|&(t, c)| (t, c)) 29 | .unwrap_or(("chore", "Chores")); 30 | (t, c.to_string()) 31 | } 32 | 33 | #[cfg(not(test))] 34 | fn get_commit_type_and_category(type_name: &str) -> (&str, String) { 35 | match type_name { 36 | "feat" => ("feat", t("commit_category_features").to_string()), 37 | "fix" => ("fix", t("commit_category_bug_fixes").to_string()), 38 | "docs" => ("docs", t("commit_category_docs").to_string()), 39 | "style" => ("style", t("commit_category_style").to_string()), 40 | "refactor" => ("refactor", t("commit_category_refactor").to_string()), 41 | "test" => ("test", t("commit_category_test").to_string()), 42 | _ => ("chore", t("commit_category_chores").to_string()), 43 | } 44 | } 45 | 46 | /// Format commit message 47 | pub fn format_commit(commit: &str) -> CommitInfo { 48 | let commit_type = if let Some(index) = commit.find(':') { 49 | &commit[..index] 50 | } else { 51 | "chore" 52 | }; 53 | 54 | let base_type = if let Some(scope_start) = commit_type.find('(') { 55 | &commit_type[..scope_start] 56 | } else { 57 | commit_type 58 | }; 59 | 60 | let (type_name, category) = get_commit_type_and_category(base_type); 61 | 62 | // Extract commit message 63 | let mut message = commit.trim().to_string(); 64 | 65 | // Get commit message (remove type prefix) 66 | if let Some(index) = commit.find(':') { 67 | message = commit[index + 1..].trim().to_string(); 68 | 69 | // 提取 scope 70 | let action = &commit[..index]; 71 | if let Some(scope) = action.find('(').and_then(|start| { 72 | action 73 | .find(')') 74 | .map(|end| action[start + 1..end].to_string()) 75 | }) { 76 | message = format!("{}: {}", scope, message); 77 | } 78 | } 79 | 80 | CommitInfo { 81 | type_name: type_name.to_string(), 82 | category, 83 | message, 84 | } 85 | } 86 | 87 | #[cfg(test)] 88 | mod tests { 89 | use super::*; 90 | 91 | #[test] 92 | fn test_format_basic_commit() { 93 | let commit = "feat: add new feature"; 94 | let info = format_commit(commit); 95 | assert_eq!(info.type_name, "feat"); 96 | assert_eq!(info.category, "Features"); 97 | assert_eq!(info.message, "add new feature"); 98 | } 99 | 100 | #[test] 101 | fn test_format_commit_with_scope() { 102 | let commit = "feat(component): add button component"; 103 | let info = format_commit(commit); 104 | assert_eq!(info.type_name, "feat"); 105 | assert_eq!(info.category, "Features"); 106 | assert_eq!(info.message, "component: add button component"); 107 | } 108 | 109 | #[test] 110 | fn test_commit_types() { 111 | let test_cases = [ 112 | ("feat: new", "feat", "Features"), 113 | ("fix: bug", "fix", "Bug Fixes"), 114 | ("docs: update", "docs", "Documentation"), 115 | ("style: format", "style", "Styles"), 116 | ("refactor: code", "refactor", "Code Refactoring"), 117 | ("test: add", "test", "Tests"), 118 | ("chore: deps", "chore", "Chores"), 119 | ("unknown: something", "chore", "Chores"), 120 | ]; 121 | 122 | for (commit, expected_type, expected_category) in test_cases { 123 | let info = format_commit(commit); 124 | assert_eq!(info.type_name, expected_type); 125 | assert_eq!(info.category, expected_category); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/utils/format_log.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::format_commit::{format_commit, CommitInfo}; 2 | use chrono::{DateTime, Local, NaiveDateTime, TimeZone}; 3 | 4 | #[allow(dead_code)] 5 | #[derive(Debug)] 6 | pub struct LogInfo { 7 | pub repo: String, 8 | pub author: String, 9 | pub email: String, 10 | pub commit: String, 11 | pub type_name: String, 12 | pub category: String, 13 | pub message: String, 14 | pub hash: String, 15 | pub time: String, 16 | pub unix: i64, 17 | } 18 | 19 | fn parse_time(time_str: &str) -> (String, i64) { 20 | // Try RFC2822 first 21 | if let Ok(dt) = DateTime::parse_from_rfc2822(time_str) { 22 | let local_dt: DateTime = dt.with_timezone(&Local); 23 | ( 24 | local_dt.format("%Y-%m-%d %H:%M:%S").to_string(), 25 | local_dt.timestamp_millis(), 26 | ) 27 | } 28 | // Try common format 29 | else if let Ok(naive_dt) = NaiveDateTime::parse_from_str(time_str, "%Y-%m-%d %H:%M:%S") { 30 | // Use local timezone 31 | let local_dt = Local.from_local_datetime(&naive_dt).unwrap(); 32 | ( 33 | local_dt.format("%Y-%m-%d %H:%M:%S").to_string(), 34 | local_dt.timestamp_millis(), 35 | ) 36 | } 37 | // Parse failed 38 | else { 39 | (time_str.to_string(), 0) 40 | } 41 | } 42 | 43 | /// Format log 44 | pub fn format_log(log: &str) -> LogInfo { 45 | let arr: Vec<&str> = log.split("|||").collect(); 46 | 47 | let repo = arr.get(0).unwrap_or(&"").to_string(); 48 | let author = arr.get(1).unwrap_or(&"").to_string(); 49 | let email = arr.get(2).unwrap_or(&"").to_string(); 50 | let commit = arr.get(3).unwrap_or(&"").to_string(); 51 | let hash = arr.get(4).unwrap_or(&"").replace("'", "#"); 52 | let time_str = arr.get(5).unwrap_or(&"").to_string(); 53 | 54 | let (time, unix) = parse_time(&time_str); 55 | 56 | let CommitInfo { 57 | type_name, 58 | category, 59 | message, 60 | } = format_commit(&commit); 61 | 62 | LogInfo { 63 | repo, 64 | author, 65 | email, 66 | commit, 67 | type_name, 68 | category, 69 | message, 70 | hash, 71 | time, 72 | unix, 73 | } 74 | } 75 | 76 | #[cfg(test)] 77 | mod tests { 78 | use super::*; 79 | 80 | #[test] 81 | fn test_format_log_basic() { 82 | let log = 83 | "repo1|||Alice|||alice@example.com|||feat: add feature|||'abc123|||2024-06-01 12:00:00"; 84 | let info = format_log(log); 85 | assert_eq!(info.repo, "repo1"); 86 | assert_eq!(info.author, "Alice"); 87 | assert_eq!(info.email, "alice@example.com"); 88 | assert_eq!(info.commit, "feat: add feature"); 89 | assert_eq!(info.hash, "#abc123"); 90 | assert_eq!(info.time, "2024-06-01 12:00:00"); 91 | assert_eq!(info.type_name, "feat"); 92 | assert_eq!(info.message, "add feature"); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/utils/get_repo_logs.rs: -------------------------------------------------------------------------------- 1 | use crate::i18n::t; 2 | use std::path::Path; 3 | use std::process::Command; 4 | 5 | /// Get repository logs 6 | pub fn get_repo_logs(repo_dir: &str) -> anyhow::Result> { 7 | let path = Path::new(repo_dir); 8 | if !path.exists() || !path.is_dir() { 9 | anyhow::bail!(t("err_repo_not_found").replace("{}", &repo_dir)); 10 | } 11 | 12 | // Git log format reference 13 | // https://git-scm.com/docs/pretty-formats 14 | let output = Command::new("git") 15 | .arg("log") 16 | .arg("--pretty=format:%an|||%ae|||%s|||'%h|||%ad") 17 | .current_dir(path) 18 | .output()?; 19 | 20 | if !output.status.success() { 21 | anyhow::bail!( 22 | "{} {}", 23 | t("err_git_log_failed").replace("{}", &repo_dir), 24 | String::from_utf8_lossy(&output.stderr) 25 | ); 26 | } 27 | 28 | let stdout = String::from_utf8_lossy(&output.stdout); 29 | let lines: Vec = stdout 30 | .lines() 31 | .map(|line| line.trim().to_string()) 32 | .filter(|line| !line.is_empty()) 33 | .collect(); 34 | 35 | Ok(lines) 36 | } 37 | 38 | #[cfg(test)] 39 | mod tests { 40 | use super::*; 41 | 42 | #[test] 43 | #[ignore] 44 | fn test_git_log() { 45 | let repo_dir = "/Users/xxx/projects/my-repo"; 46 | let logs = get_repo_logs(repo_dir).unwrap(); 47 | assert!(!logs.is_empty()); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/get_repo_name.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::path::Path; 3 | 4 | /// Get repository name 5 | /// 6 | /// # Parameters 7 | /// - `repo_dir`: repository path 8 | /// - `format`: mapping from directory name to repository name 9 | /// 10 | /// # Returns 11 | /// - repository name (None if not found) 12 | pub fn get_repo_name(repo_dir: &str, format: &HashMap) -> Option { 13 | let repo_dir = repo_dir.trim_end_matches(std::path::MAIN_SEPARATOR); 14 | let path = Path::new(repo_dir); 15 | if let Some(key_osstr) = path.file_name() { 16 | let key = key_osstr.to_string_lossy(); 17 | format.get(key.as_ref()).cloned() 18 | } else { 19 | None 20 | } 21 | } 22 | 23 | #[cfg(test)] 24 | mod tests { 25 | use super::*; 26 | use std::collections::HashMap; 27 | 28 | #[test] 29 | fn test_get_repo_name_found() { 30 | let mut format = HashMap::new(); 31 | format.insert("my-repo".to_string(), "My Repository".to_string()); 32 | let repo_dir = "/Users/xxx/projects/my-repo"; 33 | let repo_name = get_repo_name(repo_dir, &format); 34 | assert_eq!(repo_name, Some("My Repository".to_string())); 35 | } 36 | 37 | #[test] 38 | fn test_get_repo_name_not_found() { 39 | let format = HashMap::new(); 40 | let repo_dir = "/Users/xxx/projects/unknown-repo"; 41 | let repo_name = get_repo_name(repo_dir, &format); 42 | assert_eq!(repo_name, None); 43 | } 44 | 45 | #[test] 46 | fn test_get_repo_name_empty_path() { 47 | let format = HashMap::new(); 48 | let repo_dir = ""; 49 | let repo_name = get_repo_name(repo_dir, &format); 50 | assert_eq!(repo_name, None); 51 | } 52 | 53 | #[test] 54 | fn test_get_repo_name_trailing_slash() { 55 | let mut format = HashMap::new(); 56 | format.insert("my-repo".to_string(), "My Repository".to_string()); 57 | let repo_dir = "/Users/xxx/projects/my-repo/"; 58 | let repo_name = get_repo_name(repo_dir, &format); 59 | assert_eq!(repo_name, Some("My Repository".to_string())); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/utils/keypress.rs: -------------------------------------------------------------------------------- 1 | use crate::i18n::t; 2 | use std::io::{self, Read, Write}; 3 | use std::process; 4 | 5 | /// Waits for user to press any key 6 | /// 7 | /// # Arguments 8 | /// 9 | /// * `prompt` - Optional message to display. If None, a default message will be shown 10 | /// * `is_en` - Whether to display messages in English 11 | pub fn wait_for_key(prompt: Option<&str>) { 12 | if let Some(msg) = prompt { 13 | print!("{} ", msg); 14 | } else { 15 | print!("{}", t("wait_for_key")); 16 | } 17 | io::stdout().flush().unwrap(); 18 | let mut buffer = [0u8; 1]; 19 | io::stdin().read_exact(&mut buffer).unwrap(); 20 | } 21 | 22 | /// Waits for user input and then exits the program 23 | /// 24 | /// # Arguments 25 | /// 26 | /// * `msg` - Optional message to display before exiting. If None, a default message will be shown 27 | pub fn exit_on_keypress(msg: Option<&str>) { 28 | wait_for_key(msg); 29 | process::exit(0); 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod check_git; 2 | pub mod filter_logs; 3 | pub mod format_commit; 4 | pub mod format_log; 5 | pub mod get_repo_logs; 6 | pub mod get_repo_name; 7 | pub mod keypress; 8 | pub mod save_report; 9 | -------------------------------------------------------------------------------- /src/utils/save_report.rs: -------------------------------------------------------------------------------- 1 | use crate::config::constants::{CATEGORY_ORDER, REPORT_FILE_NAME}; 2 | use crate::i18n::t; 3 | use crate::utils::format_log::LogInfo; 4 | use std::collections::HashMap; 5 | use std::fs::File; 6 | use std::io::Write; 7 | use std::path::{Path, PathBuf}; 8 | 9 | fn get_category_label(type_name: &str) -> &str { 10 | match type_name { 11 | "feat" => t("commit_category_features"), 12 | "fix" => t("commit_category_bug_fixes"), 13 | "docs" => t("commit_category_docs"), 14 | "style" => t("commit_category_style"), 15 | "refactor" => t("commit_category_refactor"), 16 | "test" => t("commit_category_test"), 17 | "chore" => t("commit_category_chores"), 18 | _ => type_name, 19 | } 20 | } 21 | 22 | /// Save Markdown report 23 | pub fn save_report_markdown( 24 | result: &HashMap>>, 25 | root_path: &PathBuf, 26 | ) -> std::io::Result<()> { 27 | // Check if result is empty 28 | if result.is_empty() { 29 | println!("{}", t("no_report_generated")); 30 | return Ok(()); 31 | } 32 | 33 | let mut md = String::new(); 34 | let mut repo_titles = vec![]; 35 | 36 | for (repo, type_map) in result { 37 | if !repo_titles.contains(repo) { 38 | md.push_str(&format!("## {}\n\n", repo)); 39 | repo_titles.push(repo.to_string()); 40 | } 41 | 42 | for &cat in CATEGORY_ORDER { 43 | if let Some(logs) = type_map.get(cat) { 44 | let label = get_category_label(cat); 45 | md.push_str(&format!("### {}\n\n", label)); 46 | for (index, log) in logs.iter().enumerate() { 47 | md.push_str(&format!("{}. {}\n", index + 1, log.message)); 48 | } 49 | md.push('\n'); 50 | } 51 | } 52 | md.push('\n'); 53 | } 54 | 55 | let output_path = root_path.join(REPORT_FILE_NAME); 56 | let mut file = File::create(Path::new(&output_path))?; 57 | file.write_all(md.as_bytes())?; 58 | 59 | println!( 60 | "{}", 61 | t("report_saved").replace("{}", output_path.to_str().unwrap()) 62 | ); 63 | println!(""); 64 | 65 | Ok(()) 66 | } 67 | --------------------------------------------------------------------------------