├── .cargo └── config.toml ├── .github ├── dependabot.yml └── workflows │ └── testing.yaml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── Cargo.lock ├── Cargo.toml ├── README.md ├── cSpell.json ├── rustfmt.toml ├── src ├── doc.rs ├── fetch.rs ├── lib.rs ├── main.rs ├── parsing.rs ├── prettify.rs └── regex.rs └── tests ├── golden_master_test.rs ├── integration ├── Cargo.lock ├── Cargo.toml ├── src │ ├── lib.rs │ └── main.rs └── tests │ └── parsing.rs ├── mocking_project.rs └── parsing.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | # equivalent to cargo test with no-color in this project 3 | r = "run -- --features no-color" 4 | t = "test --features no-color" 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | target-branch: "main" 8 | 9 | - package-ecosystem: cargo 10 | directory: / 11 | schedule: 12 | interval: daily 13 | target-branch: "main" 14 | -------------------------------------------------------------------------------- /.github/workflows/testing.yaml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | 10 | jobs: 11 | format: 12 | name: Formatting 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - id: checkout 17 | name: Checkout Repository 18 | uses: actions/checkout@v4 19 | 20 | - id: setup 21 | name: Setup Toolchain 22 | uses: dtolnay/rust-toolchain@stable 23 | with: 24 | toolchain: nightly 25 | components: rustfmt 26 | 27 | - id: cache 28 | name: Enable Workflow Cache 29 | uses: Swatinem/rust-cache@v2 30 | 31 | - id: format 32 | name: Run Formatting-Checks 33 | run: cargo fmt --check 34 | 35 | check: 36 | name: Static Analysis 37 | runs-on: ubuntu-latest 38 | needs: format 39 | 40 | strategy: 41 | matrix: 42 | toolchain: [stable, nightly] 43 | 44 | steps: 45 | - id: checkout 46 | name: Checkout Repository 47 | uses: actions/checkout@v4 48 | 49 | - id: setup 50 | name: Setup Toolchain 51 | uses: dtolnay/rust-toolchain@stable 52 | with: 53 | toolchain: ${{ matrix.toolchain }} 54 | components: clippy 55 | 56 | - id: cache 57 | name: Enable Workflow Cache 58 | uses: Swatinem/rust-cache@v2 59 | 60 | - id: check 61 | name: Run Build Checks 62 | run: cargo check --tests --benches --examples --workspace --all-targets --all-features 63 | 64 | - id: lint 65 | name: Run Lint Checks 66 | run: cargo clippy --tests --benches --examples --workspace --all-targets --all-features -- -D clippy::correctness -D clippy::suspicious -D clippy::complexity -D clippy::perf -D clippy::style -D clippy::pedantic 67 | 68 | - id: doc 69 | name: Run Documentation Checks 70 | run: cargo test --doc 71 | 72 | - id: semver 73 | name: Semver Checks 74 | uses: obi1kenobi/cargo-semver-checks-action@v2 75 | 76 | unit: 77 | name: Units 78 | runs-on: ubuntu-latest 79 | needs: check 80 | 81 | strategy: 82 | matrix: 83 | toolchain: [stable, nightly] 84 | 85 | steps: 86 | - id: checkout 87 | name: Checkout Repository 88 | uses: actions/checkout@v4 89 | 90 | - id: setup 91 | name: Setup Toolchain 92 | uses: dtolnay/rust-toolchain@stable 93 | with: 94 | toolchain: ${{ matrix.toolchain }} 95 | components: llvm-tools-preview 96 | 97 | - id: cache 98 | name: Enable Job Cache 99 | uses: Swatinem/rust-cache@v2 100 | 101 | - id: tools 102 | name: Install Tools 103 | uses: taiki-e/install-action@v2 104 | with: 105 | tool: cargo-llvm-cov, cargo-nextest 106 | 107 | - id: test 108 | name: Run Unit Tests 109 | run: cargo test -F "no-color" 110 | 111 | - id: pretty-test 112 | name: Run cargo pretty-test 113 | run: | 114 | cargo install --path . 115 | cargo pretty-test -F "no-color" --color=always 116 | echo '```text' >> $GITHUB_STEP_SUMMARY 117 | echo "$(cargo pretty-test -F "no-color" --color=never)" >> $GITHUB_STEP_SUMMARY 2>&1 118 | echo '```' >> $GITHUB_STEP_SUMMARY 119 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.coverage/ 2 | /.idea/ 3 | /.vscode/launch.json 4 | /target 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "streetsidesoftware.code-spell-checker", 4 | "rust-lang.rust-analyzer", 5 | "tamasfe.even-better-toml" 6 | ] 7 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[rust]": { 3 | "editor.formatOnSave": true 4 | }, 5 | "rust-analyzer.checkOnSave": true, 6 | "rust-analyzer.check.command": "clippy", 7 | "rust-analyzer.check.allTargets": true, 8 | "rust-analyzer.check.extraArgs": [ 9 | "--", 10 | "-D", 11 | "clippy::correctness", 12 | "-D", 13 | "clippy::suspicious", 14 | "-W", 15 | "clippy::complexity", 16 | "-W", 17 | "clippy::perf", 18 | "-W", 19 | "clippy::style", 20 | "-W", 21 | "clippy::pedantic", 22 | ], 23 | "evenBetterToml.formatter.allowedBlankLines": 1, 24 | "evenBetterToml.formatter.columnWidth": 130, 25 | "evenBetterToml.formatter.trailingNewline": true, 26 | "evenBetterToml.formatter.reorderKeys": true, 27 | "evenBetterToml.formatter.reorderArrays": true, 28 | } -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "bitflags" 7 | version = "2.4.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" 10 | 11 | [[package]] 12 | name = "cargo-pretty-test" 13 | version = "0.2.5" 14 | dependencies = [ 15 | "colored", 16 | "indexmap", 17 | "insta", 18 | "pretty_assertions", 19 | "regex-lite", 20 | "strip-ansi-escapes", 21 | "termtree", 22 | ] 23 | 24 | [[package]] 25 | name = "cc" 26 | version = "1.0.83" 27 | source = "registry+https://github.com/rust-lang/crates.io-index" 28 | checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" 29 | dependencies = [ 30 | "libc", 31 | ] 32 | 33 | [[package]] 34 | name = "colored" 35 | version = "2.0.4" 36 | source = "registry+https://github.com/rust-lang/crates.io-index" 37 | checksum = "2674ec482fbc38012cf31e6c42ba0177b431a0cb6f15fe40efa5aab1bda516f6" 38 | dependencies = [ 39 | "is-terminal", 40 | "lazy_static", 41 | "windows-sys 0.48.0", 42 | ] 43 | 44 | [[package]] 45 | name = "console" 46 | version = "0.15.7" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "c926e00cc70edefdc64d3a5ff31cc65bb97a3460097762bd23afb4d8145fccf8" 49 | dependencies = [ 50 | "encode_unicode", 51 | "lazy_static", 52 | "libc", 53 | "windows-sys 0.45.0", 54 | ] 55 | 56 | [[package]] 57 | name = "diff" 58 | version = "0.1.13" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" 61 | 62 | [[package]] 63 | name = "encode_unicode" 64 | version = "0.3.6" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" 67 | 68 | [[package]] 69 | name = "equivalent" 70 | version = "1.0.1" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 73 | 74 | [[package]] 75 | name = "errno" 76 | version = "0.3.3" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "136526188508e25c6fef639d7927dfb3e0e3084488bf202267829cf7fc23dbdd" 79 | dependencies = [ 80 | "errno-dragonfly", 81 | "libc", 82 | "windows-sys 0.48.0", 83 | ] 84 | 85 | [[package]] 86 | name = "errno-dragonfly" 87 | version = "0.1.2" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" 90 | dependencies = [ 91 | "cc", 92 | "libc", 93 | ] 94 | 95 | [[package]] 96 | name = "hashbrown" 97 | version = "0.14.1" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "7dfda62a12f55daeae5015f81b0baea145391cb4520f86c248fc615d72640d12" 100 | 101 | [[package]] 102 | name = "hermit-abi" 103 | version = "0.3.3" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" 106 | 107 | [[package]] 108 | name = "indexmap" 109 | version = "2.0.2" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "8adf3ddd720272c6ea8bf59463c04e0f93d0bbf7c5439b691bca2987e0270897" 112 | dependencies = [ 113 | "equivalent", 114 | "hashbrown", 115 | ] 116 | 117 | [[package]] 118 | name = "insta" 119 | version = "1.34.0" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | checksum = "5d64600be34b2fcfc267740a243fa7744441bb4947a619ac4e5bb6507f35fbfc" 122 | dependencies = [ 123 | "console", 124 | "lazy_static", 125 | "linked-hash-map", 126 | "similar", 127 | "yaml-rust", 128 | ] 129 | 130 | [[package]] 131 | name = "integration" 132 | version = "0.0.0" 133 | 134 | [[package]] 135 | name = "is-terminal" 136 | version = "0.4.9" 137 | source = "registry+https://github.com/rust-lang/crates.io-index" 138 | checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" 139 | dependencies = [ 140 | "hermit-abi", 141 | "rustix", 142 | "windows-sys 0.48.0", 143 | ] 144 | 145 | [[package]] 146 | name = "lazy_static" 147 | version = "1.4.0" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 150 | 151 | [[package]] 152 | name = "libc" 153 | version = "0.2.148" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b" 156 | 157 | [[package]] 158 | name = "linked-hash-map" 159 | version = "0.5.6" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" 162 | 163 | [[package]] 164 | name = "linux-raw-sys" 165 | version = "0.4.7" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "1a9bad9f94746442c783ca431b22403b519cd7fbeed0533fdd6328b2f2212128" 168 | 169 | [[package]] 170 | name = "pretty_assertions" 171 | version = "1.4.0" 172 | source = "registry+https://github.com/rust-lang/crates.io-index" 173 | checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" 174 | dependencies = [ 175 | "diff", 176 | "yansi", 177 | ] 178 | 179 | [[package]] 180 | name = "proc-macro2" 181 | version = "1.0.67" 182 | source = "registry+https://github.com/rust-lang/crates.io-index" 183 | checksum = "3d433d9f1a3e8c1263d9456598b16fec66f4acc9a74dacffd35c7bb09b3a1328" 184 | dependencies = [ 185 | "unicode-ident", 186 | ] 187 | 188 | [[package]] 189 | name = "quote" 190 | version = "1.0.33" 191 | source = "registry+https://github.com/rust-lang/crates.io-index" 192 | checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" 193 | dependencies = [ 194 | "proc-macro2", 195 | ] 196 | 197 | [[package]] 198 | name = "regex-lite" 199 | version = "0.1.5" 200 | source = "registry+https://github.com/rust-lang/crates.io-index" 201 | checksum = "30b661b2f27137bdbc16f00eda72866a92bb28af1753ffbd56744fb6e2e9cd8e" 202 | 203 | [[package]] 204 | name = "rustix" 205 | version = "0.38.14" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "747c788e9ce8e92b12cd485c49ddf90723550b654b32508f979b71a7b1ecda4f" 208 | dependencies = [ 209 | "bitflags", 210 | "errno", 211 | "libc", 212 | "linux-raw-sys", 213 | "windows-sys 0.48.0", 214 | ] 215 | 216 | [[package]] 217 | name = "similar" 218 | version = "2.2.1" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "420acb44afdae038210c99e69aae24109f32f15500aa708e81d46c9f29d55fcf" 221 | 222 | [[package]] 223 | name = "strip-ansi-escapes" 224 | version = "0.2.0" 225 | source = "registry+https://github.com/rust-lang/crates.io-index" 226 | checksum = "55ff8ef943b384c414f54aefa961dd2bd853add74ec75e7ac74cf91dba62bcfa" 227 | dependencies = [ 228 | "vte", 229 | ] 230 | 231 | [[package]] 232 | name = "termtree" 233 | version = "0.4.1" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" 236 | 237 | [[package]] 238 | name = "unicode-ident" 239 | version = "1.0.12" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 242 | 243 | [[package]] 244 | name = "utf8parse" 245 | version = "0.2.1" 246 | source = "registry+https://github.com/rust-lang/crates.io-index" 247 | checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" 248 | 249 | [[package]] 250 | name = "vte" 251 | version = "0.11.1" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" 254 | dependencies = [ 255 | "utf8parse", 256 | "vte_generate_state_changes", 257 | ] 258 | 259 | [[package]] 260 | name = "vte_generate_state_changes" 261 | version = "0.1.1" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "d257817081c7dffcdbab24b9e62d2def62e2ff7d00b1c20062551e6cccc145ff" 264 | dependencies = [ 265 | "proc-macro2", 266 | "quote", 267 | ] 268 | 269 | [[package]] 270 | name = "windows-sys" 271 | version = "0.45.0" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" 274 | dependencies = [ 275 | "windows-targets 0.42.2", 276 | ] 277 | 278 | [[package]] 279 | name = "windows-sys" 280 | version = "0.48.0" 281 | source = "registry+https://github.com/rust-lang/crates.io-index" 282 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 283 | dependencies = [ 284 | "windows-targets 0.48.5", 285 | ] 286 | 287 | [[package]] 288 | name = "windows-targets" 289 | version = "0.42.2" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 292 | dependencies = [ 293 | "windows_aarch64_gnullvm 0.42.2", 294 | "windows_aarch64_msvc 0.42.2", 295 | "windows_i686_gnu 0.42.2", 296 | "windows_i686_msvc 0.42.2", 297 | "windows_x86_64_gnu 0.42.2", 298 | "windows_x86_64_gnullvm 0.42.2", 299 | "windows_x86_64_msvc 0.42.2", 300 | ] 301 | 302 | [[package]] 303 | name = "windows-targets" 304 | version = "0.48.5" 305 | source = "registry+https://github.com/rust-lang/crates.io-index" 306 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 307 | dependencies = [ 308 | "windows_aarch64_gnullvm 0.48.5", 309 | "windows_aarch64_msvc 0.48.5", 310 | "windows_i686_gnu 0.48.5", 311 | "windows_i686_msvc 0.48.5", 312 | "windows_x86_64_gnu 0.48.5", 313 | "windows_x86_64_gnullvm 0.48.5", 314 | "windows_x86_64_msvc 0.48.5", 315 | ] 316 | 317 | [[package]] 318 | name = "windows_aarch64_gnullvm" 319 | version = "0.42.2" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 322 | 323 | [[package]] 324 | name = "windows_aarch64_gnullvm" 325 | version = "0.48.5" 326 | source = "registry+https://github.com/rust-lang/crates.io-index" 327 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 328 | 329 | [[package]] 330 | name = "windows_aarch64_msvc" 331 | version = "0.42.2" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 334 | 335 | [[package]] 336 | name = "windows_aarch64_msvc" 337 | version = "0.48.5" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 340 | 341 | [[package]] 342 | name = "windows_i686_gnu" 343 | version = "0.42.2" 344 | source = "registry+https://github.com/rust-lang/crates.io-index" 345 | checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 346 | 347 | [[package]] 348 | name = "windows_i686_gnu" 349 | version = "0.48.5" 350 | source = "registry+https://github.com/rust-lang/crates.io-index" 351 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 352 | 353 | [[package]] 354 | name = "windows_i686_msvc" 355 | version = "0.42.2" 356 | source = "registry+https://github.com/rust-lang/crates.io-index" 357 | checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 358 | 359 | [[package]] 360 | name = "windows_i686_msvc" 361 | version = "0.48.5" 362 | source = "registry+https://github.com/rust-lang/crates.io-index" 363 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 364 | 365 | [[package]] 366 | name = "windows_x86_64_gnu" 367 | version = "0.42.2" 368 | source = "registry+https://github.com/rust-lang/crates.io-index" 369 | checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 370 | 371 | [[package]] 372 | name = "windows_x86_64_gnu" 373 | version = "0.48.5" 374 | source = "registry+https://github.com/rust-lang/crates.io-index" 375 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 376 | 377 | [[package]] 378 | name = "windows_x86_64_gnullvm" 379 | version = "0.42.2" 380 | source = "registry+https://github.com/rust-lang/crates.io-index" 381 | checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 382 | 383 | [[package]] 384 | name = "windows_x86_64_gnullvm" 385 | version = "0.48.5" 386 | source = "registry+https://github.com/rust-lang/crates.io-index" 387 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 388 | 389 | [[package]] 390 | name = "windows_x86_64_msvc" 391 | version = "0.42.2" 392 | source = "registry+https://github.com/rust-lang/crates.io-index" 393 | checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 394 | 395 | [[package]] 396 | name = "windows_x86_64_msvc" 397 | version = "0.48.5" 398 | source = "registry+https://github.com/rust-lang/crates.io-index" 399 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 400 | 401 | [[package]] 402 | name = "yaml-rust" 403 | version = "0.4.5" 404 | source = "registry+https://github.com/rust-lang/crates.io-index" 405 | checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" 406 | dependencies = [ 407 | "linked-hash-map", 408 | ] 409 | 410 | [[package]] 411 | name = "yansi" 412 | version = "0.5.1" 413 | source = "registry+https://github.com/rust-lang/crates.io-index" 414 | checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" 415 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cargo-pretty-test" 3 | version = "0.2.5" 4 | edition = "2021" 5 | license = "MIT OR GPL-3.0" 6 | authors = ["vague ", "Jose Celano "] 7 | description = "A console command to format cargo test output" 8 | repository = "https://github.com/josecelano/cargo-pretty-test" 9 | exclude = [".*", "tests/", "cSpell.json", "rustfmt.toml"] 10 | 11 | [dependencies] 12 | termtree = "0.4" 13 | regex-lite = "0.1" 14 | indexmap = "2" 15 | colored = "2" 16 | strip-ansi-escapes = "0.2" 17 | 18 | [features] 19 | # Don't add ANSI escapes, which is useful for testing. 20 | no-color = ["colored/no-color"] 21 | 22 | # You should use `--features no-color` to run 23 | # these test. Or run `cargo t` as a shortcut. 24 | [[test]] 25 | name = "golden_master_test" 26 | path = "./tests/golden_master_test.rs" 27 | required-features = ["no-color"] 28 | [[test]] 29 | name = "mocking_project" 30 | path = "./tests/mocking_project.rs" 31 | required-features = ["no-color"] 32 | [[test]] 33 | name = "parsing" 34 | path = "./tests/parsing.rs" 35 | required-features = ["no-color"] 36 | 37 | [dev-dependencies] 38 | pretty_assertions = "1.4.0" 39 | insta = "1.34" 40 | 41 | [workspace] 42 | members = ["./tests/integration/"] 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cargo Pretty Test ✨ 2 | 3 | [![Testing](https://github.com/josecelano/pretty-test/actions/workflows/testing.yaml/badge.svg)](https://github.com/josecelano/pretty-test/actions/workflows/testing.yaml) 4 | [![Crates.io](https://img.shields.io/crates/v/cargo-pretty-test)](https://crates.io/crates/cargo-pretty-test) 5 | [![docs.rs](https://img.shields.io/docsrs/cargo-pretty-test)](https://docs.rs/cargo-pretty-test) 6 | 7 | A Rust command-line tool that prettifies the ugly `cargo test` output into a beautiful output. 8 | 9 | This crate can be also used as a library that [fully parses][parsing] the output from `cargo test`. 10 | 11 | [parsing]: https://docs.rs/cargo-pretty-test/*/cargo_pretty_test/parsing/index.html 12 | 13 | ```console 14 | $ cargo pretty-test --workspace --no-fail-fast 15 | 16 | Error details from `cargo test` if any ... (Omitted here) 17 | 18 | Generated by cargo-pretty-test 19 | ├── (OK) cargo_pretty_test ... (4 tests in 0.16s: ✅ 4) 20 | │ ├── (OK) tests/golden_master_test.rs ... (1 tests in 0.00s: ✅ 1) 21 | │ │ └─ ✅ golden_master_test 22 | │ ├── (OK) tests/mocking_project.rs ... (2 tests in 0.16s: ✅ 2) 23 | │ │ ├─ ✅ snapshot_testing_for_parsed_output 24 | │ │ └─ ✅ snapshot_testing_for_pretty_output 25 | │ └── (OK) tests/parsing.rs ... (1 tests in 0.00s: ✅ 1) 26 | │ └─ ✅ parse_stderr_stdout 27 | ├── (FAIL) integration ... (10 tests in 0.00s: ✅ 6; ❌ 2; 🔕 2) 28 | │ ├── (FAIL) src/lib.rs ... (8 tests in 0.00s: ✅ 4; ❌ 2; 🔕 2) 29 | │ │ ├── submod 30 | │ │ │ ├─ 🔕 ignore 31 | │ │ │ ├─ 🔕 ignore_without_reason 32 | │ │ │ ├─ ✅ normal_test 33 | │ │ │ └── panic 34 | │ │ │ ├─ ❌ panicked 35 | │ │ │ ├─ ✅ should_panic - should panic 36 | │ │ │ ├─ ❌ should_panic_but_didnt - should panic 37 | │ │ │ └─ ✅ should_panic_without_reanson - should panic 38 | │ │ └─ ✅ works 39 | │ ├── (OK) src/main.rs ... (1 tests in 0.00s: ✅ 1) 40 | │ │ └─ ✅ from_main_rs 41 | │ └── (OK) tests/parsing.rs ... (1 tests in 0.00s: ✅ 1) 42 | │ └─ ✅ from_integration 43 | └── (OK) Doc Tests ... (2 tests in 0.41s: ✅ 2) 44 | ├── (OK) cargo-pretty-test ... (1 tests in 0.20s: ✅ 1) 45 | │ └─ ✅ src/doc.rs - doc (line 3) 46 | └── (OK) integration ... (1 tests in 0.21s: ✅ 1) 47 | └─ ✅ tests/integration/src/lib.rs - doc (line 41) 48 | 49 | Status: FAIL; total 16 tests in 0.57s: 12 passed; 2 failed; 2 ignored; 0 measured; 0 filtered out 50 | ``` 51 | 52 | ![](https://user-images.githubusercontent.com/25300418/270264132-89de6fd2-11f8-4e5b-b9dc-8475fa022a5f.png) 53 | 54 | [More screenshots.](https://github.com/josecelano/cargo-pretty-test/wiki/cargo%E2%80%90pretty%E2%80%90test-screenshots) 55 | 56 | ## Usage 57 | 58 | Install: 59 | 60 | ```console 61 | cargo install cargo-pretty-test 62 | ``` 63 | 64 | Run in your project: 65 | 66 | ```console 67 | cargo pretty-test 68 | ``` 69 | 70 | Note: all the arguments passed to `cargo pretty-test` are forwarded to `cargo test`. 71 | 72 | --- 73 | 74 | Run in CI as a summary: [demo](https://github.com/josecelano/cargo-pretty-test/actions/runs/6334295212) 75 | 76 | ```console 77 | - id: pretty-test 78 | name: Run cargo pretty-test 79 | run: | 80 | cargo install cargo-pretty-test 81 | cargo pretty-test --color=always 82 | echo '```text' >> $GITHUB_STEP_SUMMARY 83 | echo "$(cargo pretty-test --color=never)" >> $GITHUB_STEP_SUMMARY 2>&1 84 | echo '```' >> $GITHUB_STEP_SUMMARY 85 | ``` 86 | 87 | ![](https://user-images.githubusercontent.com/25300418/271169701-6efc57fb-9ab3-4842-9599-aa5784001c36.png) 88 | 89 | Note: `--color=always` produces texts in color when running CI, and `--color=never` 90 | strips ANSI escapes before written to summary. 91 | 92 | ## Credits 93 | 94 | - First commit author [@ZJPzjp](https://github.com/zjp-CN). 95 | - Idea described on [https://users.rust-lang.org](https://users.rust-lang.org/t/cargo-test-output-with-indentation/100149) by [@josecelano](https://github.com/josecelano). 96 | 97 | ### Links 98 | 99 | - 100 | - 101 | -------------------------------------------------------------------------------- /cSpell.json: -------------------------------------------------------------------------------- 1 | { 2 | "words": [ 3 | "Pzjp", 4 | "splitn", 5 | "termtree" 6 | ], 7 | "enableFiletypes": [ 8 | "dockerfile", 9 | "shellscript", 10 | "toml" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josecelano/cargo-pretty-test/23e7a940f1e3c4a778f0ed1aebb67a31b5a85c70/rustfmt.toml -------------------------------------------------------------------------------- /src/doc.rs: -------------------------------------------------------------------------------- 1 | //! Doc test from src/lib.rs in cargo-pretty-test crate. 2 | //! 3 | //! ``` 4 | //! ``` 5 | -------------------------------------------------------------------------------- /src/fetch.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | parsing::{parse_cargo_test, Stats}, 3 | prettify::{make_pretty, TestTree, ICON_NOTATION}, 4 | regex::re, 5 | Result, 6 | }; 7 | use colored::{control::set_override, Colorize}; 8 | use std::process::{Command, ExitCode, Output}; 9 | use termtree::Tree; 10 | 11 | /// Output from `cargo test` 12 | pub struct Emit { 13 | /// Raw output. None means don't run `cargo test` like for `--version`. 14 | output: Option, 15 | /// Don't parse the output. Forward the output instead. 16 | no_parse: bool, 17 | } 18 | 19 | impl Emit { 20 | pub fn run(self) -> ExitCode { 21 | let Emit { output, no_parse } = self; 22 | let Some(output) = output else { 23 | return ExitCode::SUCCESS; 24 | }; 25 | let raw_err = String::from_utf8_lossy(&output.stderr); 26 | let raw_out = String::from_utf8_lossy(&output.stdout); 27 | let stderr = strip_ansi_escapes::strip(&*raw_err); 28 | let stdout = strip_ansi_escapes::strip(&*raw_out); 29 | let stderr = String::from_utf8_lossy(&stderr); 30 | let stdout = String::from_utf8_lossy(&stdout); 31 | if no_parse { 32 | println!( 33 | "{phelp}\n{ICON_NOTATION}\n{sep}\n\n{help}", 34 | phelp = "cargo pretty-test help:".blue().bold(), 35 | sep = re().separator, 36 | help = "cargo test help:".blue().bold() 37 | ); 38 | eprintln!("{stderr}"); 39 | println!("{stdout}"); 40 | } else { 41 | let (tree, stats) = match parse_cargo_test_output(&stderr, &stdout) { 42 | Ok(res) => res, 43 | Err(err) => { 44 | println!( 45 | "{}:\n{err}\n\n{}\n{raw_err}\n{raw_out}", 46 | "Error from cargo-pretty-test".red().bold(), 47 | "Error from cargo test:".red().bold() 48 | ); 49 | return ExitCode::FAILURE; 50 | } 51 | }; 52 | println!("{tree}\n{stats}"); 53 | if !stats.ok { 54 | return ExitCode::FAILURE; 55 | } 56 | } 57 | ExitCode::SUCCESS 58 | } 59 | } 60 | 61 | /// entrypoint for main.rs 62 | pub fn run() -> ExitCode { 63 | cargo_test().run() 64 | } 65 | 66 | /// Collect arguments and forward them to `cargo test`. 67 | /// 68 | /// Note: This filters some arguments that mess up the output, like 69 | /// `--nocapture` which prints in the status part and hinders parsing. 70 | pub fn cargo_test() -> Emit { 71 | let passin: Vec<_> = std::env::args().collect(); 72 | let forward = if passin 73 | .get(..2) 74 | .is_some_and(|v| v[0].ends_with("cargo-pretty-test") && v[1] == "pretty-test") 75 | { 76 | // `cargo pretty-test` yields ["path-to-cargo-pretty-test", "pretty-test", rest] 77 | &passin[2..] 78 | } else { 79 | // `cargo-pretty-test` yields ["path-to-cargo-pretty-test", rest] 80 | &passin[1..] 81 | }; 82 | if forward.iter().any(|arg| arg == "--version" || arg == "-V") { 83 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 84 | println!("cargo-pretty-test version: {VERSION}"); 85 | return Emit { 86 | output: None, 87 | no_parse: true, 88 | }; 89 | } 90 | set_color(forward); 91 | let no_parse = forward.iter().any(|arg| arg == "--help" || arg == "-h"); 92 | let args = forward.iter().filter(|&arg| arg != "--nocapture"); 93 | Emit { 94 | output: Some( 95 | Command::new("cargo") 96 | .arg("test") 97 | .args(args) 98 | .output() 99 | .expect("`cargo test` failed"), 100 | ), 101 | no_parse, 102 | } 103 | } 104 | 105 | /// reintepret `--color` 106 | fn set_color(forward: &[String]) { 107 | fn detect_env() { 108 | if let Some(set_color) = std::env::var_os("CARGO_TERM_COLOR") { 109 | match set_color.to_str().map(str::to_ascii_lowercase).as_deref() { 110 | Some("always") => set_override(true), 111 | Some("never") => set_override(false), 112 | Some("auto") => (), 113 | _ => unreachable!("--color only accepts one of always,never,auto"), 114 | } 115 | } 116 | } 117 | if let Some(pos) = forward.iter().position(|arg| arg.starts_with("--color")) { 118 | match (&*forward[pos], forward.get(pos + 1).map(|s| &**s)) { 119 | ("--color=always", _) | ("--color", Some("always")) => set_override(true), 120 | ("--color=never", _) | ("--color", Some("never")) => set_override(false), 121 | ("--color=auto", _) | ("--color", Some("auto")) => (), 122 | _ => unreachable!("--color only accepts one of always,never,auto"), 123 | } 124 | } else { 125 | detect_env(); 126 | } 127 | } 128 | 129 | pub fn parse_cargo_test_output<'s>( 130 | stderr: &'s str, 131 | stdout: &'s str, 132 | ) -> Result<(TestTree<'s>, Stats)> { 133 | let mut tree = Tree::new("Generated by cargo-pretty-test".bold().to_string().into()); 134 | let mut stats = Stats::default(); 135 | for (pkg, data) in parse_cargo_test(stderr, stdout)?.pkgs { 136 | stats += &data.stats; 137 | let root = data.stats.root_string(pkg.unwrap_or("tests")).into(); 138 | tree.push( 139 | Tree::new(root).with_leaves(data.inner.into_iter().filter_map(|data| { 140 | let parsed = data.info.parsed; 141 | let detail_without_stats = parsed.detail; 142 | if !detail_without_stats.is_empty() { 143 | eprintln!("{detail_without_stats}\n\n{}\n", re().separator); 144 | } 145 | let root = data.info.stats.subroot_string(data.runner.src.src_path); 146 | make_pretty(root, parsed.tree.into_iter()) 147 | })), 148 | ); 149 | } 150 | Ok((tree, stats)) 151 | } 152 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate can be used as a binary or a library. 2 | //! 3 | //! * library: [parse the output from `cargo test`][crate::parsing] 4 | //! * binary: `cargo install cargo-pretty-test` 5 | //! 6 | //! ```text 7 | //! $ cargo pretty-test --workspace --no-fail-fast 8 | //! 9 | //! Error details from `cargo test` if any ... (Omitted here) 10 | //! 11 | //! Generated by cargo-pretty-test 12 | //! ├── (OK) cargo_pretty_test ... (4 tests in 0.16s: ✅ 4) 13 | //! │ ├── (OK) tests/golden_master_test.rs ... (1 tests in 0.00s: ✅ 1) 14 | //! │ │ └─ ✅ golden_master_test 15 | //! │ ├── (OK) tests/mocking_project.rs ... (2 tests in 0.16s: ✅ 2) 16 | //! │ │ ├─ ✅ snapshot_testing_for_parsed_output 17 | //! │ │ └─ ✅ snapshot_testing_for_pretty_output 18 | //! │ └── (OK) tests/parsing.rs ... (1 tests in 0.00s: ✅ 1) 19 | //! │ └─ ✅ parse_stderr_stdout 20 | //! ├── (FAIL) integration ... (10 tests in 0.00s: ✅ 6; ❌ 2; 🔕 2) 21 | //! │ ├── (FAIL) src/lib.rs ... (8 tests in 0.00s: ✅ 4; ❌ 2; 🔕 2) 22 | //! │ │ ├── submod 23 | //! │ │ │ ├─ 🔕 ignore 24 | //! │ │ │ ├─ 🔕 ignore_without_reason 25 | //! │ │ │ ├─ ✅ normal_test 26 | //! │ │ │ └── panic 27 | //! │ │ │ ├─ ❌ panicked 28 | //! │ │ │ ├─ ✅ should_panic - should panic 29 | //! │ │ │ ├─ ❌ should_panic_but_didnt - should panic 30 | //! │ │ │ └─ ✅ should_panic_without_reanson - should panic 31 | //! │ │ └─ ✅ works 32 | //! │ ├── (OK) src/main.rs ... (1 tests in 0.00s: ✅ 1) 33 | //! │ │ └─ ✅ from_main_rs 34 | //! │ └── (OK) tests/parsing.rs ... (1 tests in 0.00s: ✅ 1) 35 | //! │ └─ ✅ from_integration 36 | //! └── (OK) Doc Tests ... (2 tests in 0.41s: ✅ 2) 37 | //! ├── (OK) cargo-pretty-test ... (1 tests in 0.20s: ✅ 1) 38 | //! │ └─ ✅ src/doc.rs - doc (line 3) 39 | //! └── (OK) integration ... (1 tests in 0.21s: ✅ 1) 40 | //! └─ ✅ tests/integration/src/lib.rs - doc (line 41) 41 | //! 42 | //! Status: FAIL; total 16 tests in 0.57s: 12 passed; 2 failed; 2 ignored; 0 measured; 0 filtered out 43 | //! ``` 44 | //! 45 | //! ![](https://user-images.githubusercontent.com/25300418/270264132-89de6fd2-11f8-4e5b-b9dc-8475fa022a5f.png) 46 | //! 47 | //! [More screenshots.](https://github.com/josecelano/cargo-pretty-test/wiki/cargo%E2%80%90pretty%E2%80%90test-screenshots) 48 | 49 | #![allow( 50 | clippy::missing_panics_doc, 51 | clippy::missing_errors_doc, 52 | clippy::must_use_candidate, 53 | clippy::enum_glob_use 54 | )] 55 | 56 | #[doc(hidden)] 57 | pub mod doc; 58 | 59 | pub mod fetch; 60 | pub mod parsing; 61 | pub mod prettify; 62 | pub mod regex; 63 | 64 | pub type Error = String; 65 | pub type Result = ::std::result::Result; 66 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use cargo_pretty_test::fetch::run; 2 | use std::process::ExitCode; 3 | 4 | fn main() -> ExitCode { 5 | run() 6 | } 7 | -------------------------------------------------------------------------------- /src/parsing.rs: -------------------------------------------------------------------------------- 1 | use crate::{regex::re, Result}; 2 | use colored::{ColoredString, Colorize}; 3 | use indexmap::IndexMap; 4 | use std::{ 5 | path::{Component, Path}, 6 | time::Duration, 7 | }; 8 | 9 | /// The core parsing function that extracts all the information from `cargo test` 10 | /// but filters out empty tests. 11 | pub fn parse_cargo_test<'s>(stderr: &'s str, stdout: &'s str) -> Result> { 12 | use TestType::*; 13 | 14 | let mut pkg = None; 15 | Ok(TestRunners::new( 16 | parse_cargo_test_with_empty_ones(stderr, stdout)? 17 | .filter_map(|(runner, info)| { 18 | match runner.ty { 19 | UnitLib | UnitBin => pkg = Some(runner.src.bin_name), 20 | Doc => pkg = Some("Doc Tests"), 21 | _ => (), 22 | } 23 | if info.stats.total == 0 { 24 | // don't show test types that have no tests 25 | None 26 | } else { 27 | Some((pkg, runner, info)) 28 | } 29 | }) 30 | .collect(), 31 | )) 32 | } 33 | 34 | /// The core parsing function that extracts all the information from `cargo test`. 35 | pub fn parse_cargo_test_with_empty_ones<'s>( 36 | stderr: &'s str, 37 | stdout: &'s str, 38 | ) -> Result, TestInfo<'s>)>> { 39 | let parsed_stderr = parse_stderr(stderr)?; 40 | let parsed_stdout = parse_stdout(stdout)?; 41 | let err_len = parsed_stderr.len(); 42 | let out_len = parsed_stdout.len(); 43 | if err_len != out_len { 44 | return Err(format!( 45 | "{err_len} (the amount of test runners from stderr) should \ 46 | equal to {out_len} (that from stdout)\n\ 47 | stderr = {stderr:?}\nstdout = {stdout:?}" 48 | )); 49 | } 50 | Ok(parsed_stderr.into_iter().zip(parsed_stdout)) 51 | } 52 | 53 | /// Pkg/crate name determined by the unittests. 54 | /// It's possible to be None because unittests can be omitted in `cargo test` 55 | /// and we can't determine which crate emits the tests. 56 | /// This mainly affacts how the project structure looks like specifically the root node. 57 | pub type Pkg<'s> = Option>; 58 | 59 | /// All the test runners with original display order but filtering empty types out. 60 | #[derive(Debug, Default)] 61 | pub struct TestRunners<'s> { 62 | pub pkgs: IndexMap, PkgTest<'s>>, 63 | } 64 | 65 | impl<'s> TestRunners<'s> { 66 | pub fn new(v: Vec<(Pkg<'s>, TestRunner<'s>, TestInfo<'s>)>) -> TestRunners<'s> { 67 | let mut runners = TestRunners::default(); 68 | for (pkg, runner, info) in v { 69 | match runners.pkgs.entry(pkg) { 70 | indexmap::map::Entry::Occupied(mut item) => { 71 | item.get_mut().push(runner, info); 72 | } 73 | indexmap::map::Entry::Vacant(empty) => { 74 | empty.insert(PkgTest::new(runner, info)); 75 | } 76 | } 77 | } 78 | runners 79 | } 80 | } 81 | 82 | /// The raw output from `cargo test`. 83 | pub type Text<'s> = &'s str; 84 | 85 | /// Tests information in a pkg/crate. 86 | /// For doc test type, tests from multiple crates are considered 87 | /// to be under a presumed Doc pkg. 88 | #[derive(Debug, Default)] 89 | pub struct PkgTest<'s> { 90 | pub inner: Vec>, 91 | pub stats: Stats, 92 | } 93 | 94 | impl<'s> PkgTest<'s> { 95 | pub fn new(runner: TestRunner<'s>, info: TestInfo<'s>) -> PkgTest<'s> { 96 | let stats = info.stats.clone(); 97 | PkgTest { 98 | inner: vec![Data { runner, info }], 99 | stats, 100 | } 101 | } 102 | pub fn push(&mut self, runner: TestRunner<'s>, info: TestInfo<'s>) { 103 | self.stats += &info.stats; 104 | self.inner.push(Data { runner, info }); 105 | } 106 | } 107 | 108 | /// Information extracted from stdout & stderr. 109 | #[derive(Debug)] 110 | pub struct Data<'s> { 111 | pub runner: TestRunner<'s>, 112 | pub info: TestInfo<'s>, 113 | } 114 | 115 | /// A test runner determined by the type and binary & source path. 116 | #[derive(Debug, Hash, PartialEq, Eq)] 117 | pub struct TestRunner<'s> { 118 | pub ty: TestType, 119 | pub src: Src<'s>, 120 | } 121 | 122 | /// All the information reported by a test runner. 123 | #[derive(Debug)] 124 | pub struct TestInfo<'s> { 125 | /// Raw test information from stdout. 126 | pub raw: Text<'s>, 127 | pub stats: Stats, 128 | pub parsed: ParsedCargoTestOutput<'s>, 129 | } 130 | 131 | /// Types of a test. 132 | #[derive(Debug, Hash, PartialEq, Eq, Clone, Copy)] 133 | pub enum TestType { 134 | UnitLib, 135 | UnitBin, 136 | Doc, 137 | Tests, 138 | Examples, 139 | Benches, 140 | } 141 | 142 | /// Source location and binary name for a test runner. 143 | #[derive(Debug, Hash, PartialEq, Eq, Clone, Copy)] 144 | pub struct Src<'s> { 145 | /// Path of source code (except Doc type) which is relative to its crate 146 | /// rather than root of project. 147 | /// 148 | /// This means it's possible to see same path from different crates. 149 | pub src_path: Text<'s>, 150 | /// Name from the path of test runner binary. The path usually starts with `target/`. 151 | /// 152 | /// But this field doesn't contain neither the `target/...` prefix nor hash postfix, 153 | /// so it's possible to see same name from different crates. 154 | pub bin_name: Text<'s>, 155 | } 156 | 157 | /// Statistics of test. 158 | #[derive(Debug, PartialEq, Eq, Clone)] 159 | pub struct Stats { 160 | pub ok: bool, 161 | pub total: u32, 162 | pub passed: u32, 163 | pub failed: u32, 164 | pub ignored: u32, 165 | pub measured: u32, 166 | pub filtered_out: u32, 167 | pub finished_in: Duration, 168 | } 169 | 170 | /// Summary text on the bottom. 171 | impl std::fmt::Display for Stats { 172 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 173 | let Stats { 174 | ok, 175 | total, 176 | passed, 177 | failed, 178 | ignored, 179 | measured, 180 | filtered_out, 181 | finished_in, 182 | } = *self; 183 | let time = finished_in.as_secs_f32(); 184 | let fail = if failed == 0 { 185 | format!("{failed} failed") 186 | } else { 187 | format!("{failed} failed").red().bold().to_string() 188 | }; 189 | write!( 190 | f, 191 | "Status: {}; total {total} tests in {time:.2}s: \ 192 | {passed} passed; {fail}; {ignored} ignored; \ 193 | {measured} measured; {filtered_out} filtered out", 194 | status(ok) 195 | ) 196 | } 197 | } 198 | 199 | fn status(ok: bool) -> ColoredString { 200 | if ok { 201 | "OK".green().bold() 202 | } else { 203 | "FAIL".red().bold() 204 | } 205 | } 206 | 207 | impl Stats { 208 | /// Summary text at the end of root node. 209 | /// If the metric is zero, it won't be shown. 210 | pub fn inlay_summary_string(&self) -> String { 211 | let Stats { 212 | total, 213 | passed, 214 | failed, 215 | ignored, 216 | filtered_out, 217 | finished_in, 218 | .. 219 | } = *self; 220 | let time = finished_in.as_secs_f32(); 221 | let mut metrics = Vec::with_capacity(4); 222 | if passed != 0 { 223 | metrics.push(format!("✅ {passed}")); 224 | }; 225 | if failed != 0 { 226 | metrics.push(format!("❌ {failed}").red().to_string()); 227 | }; 228 | if ignored != 0 { 229 | metrics.push(format!("🔕 {ignored}")); 230 | }; 231 | if filtered_out != 0 { 232 | metrics.push(format!("✂️ {filtered_out}")); 233 | }; 234 | format!("{total} tests in {time:.2}s: {}", metrics.join("; ")) 235 | } 236 | 237 | /// Root of test tree node depending on the test type. 238 | pub fn root_string(&self, pkg_name: Text) -> String { 239 | format!( 240 | "({}) {:} ... ({})", 241 | status(self.ok), 242 | pkg_name.blue().bold(), 243 | self.inlay_summary_string().bold() 244 | ) 245 | } 246 | 247 | /// Subroot of test tree node depending on the test type. 248 | /// Compared with `Stats::root_string`, texts except status are non-bold. 249 | pub fn subroot_string(&self, runner_name: Text) -> String { 250 | format!( 251 | "({}) {} ... ({})", 252 | status(self.ok), 253 | runner_name, 254 | self.inlay_summary_string() 255 | ) 256 | } 257 | } 258 | 259 | impl Default for Stats { 260 | fn default() -> Self { 261 | Stats { 262 | ok: true, 263 | total: 0, 264 | passed: 0, 265 | failed: 0, 266 | ignored: 0, 267 | measured: 0, 268 | filtered_out: 0, 269 | finished_in: Duration::from_secs(0), 270 | } 271 | } 272 | } 273 | 274 | impl std::ops::Add<&Stats> for &Stats { 275 | type Output = Stats; 276 | 277 | fn add(self, rhs: &Stats) -> Self::Output { 278 | Stats { 279 | ok: self.ok && rhs.ok, 280 | total: self.total + rhs.total, 281 | passed: self.passed + rhs.passed, 282 | failed: self.failed + rhs.failed, 283 | ignored: self.ignored + rhs.ignored, 284 | measured: self.measured + rhs.measured, 285 | filtered_out: self.filtered_out + rhs.filtered_out, 286 | finished_in: self.finished_in + rhs.finished_in, 287 | } 288 | } 289 | } 290 | 291 | impl std::ops::AddAssign<&Stats> for Stats { 292 | fn add_assign(&mut self, rhs: &Stats) { 293 | *self = &*self + rhs; 294 | } 295 | } 296 | 297 | /// Output from one test runner. 298 | #[derive(Debug)] 299 | pub struct ParsedCargoTestOutput<'s> { 300 | pub head: Text<'s>, 301 | pub tree: Vec>, 302 | pub detail: Text<'s>, 303 | } 304 | 305 | pub fn parse_stderr(stderr: &str) -> Result> { 306 | fn parse_stderr_inner<'s>(cap: ®ex_lite::Captures<'s>) -> Result> { 307 | if let Some((path, pkg)) = cap.name("path").zip(cap.name("pkg")) { 308 | let path = path.as_str(); 309 | let path_norm = Path::new(path); 310 | let ty = if cap.name("is_unit").is_some() { 311 | if path_norm 312 | .components() 313 | .take(2) 314 | .map(Component::as_os_str) 315 | .eq(["src", "lib.rs"]) 316 | { 317 | TestType::UnitLib 318 | } else { 319 | TestType::UnitBin 320 | } 321 | } else { 322 | let Some(base_dir) = path_norm 323 | .components() 324 | .next() 325 | .and_then(|p| p.as_os_str().to_str()) 326 | else { 327 | return Err(format!("failed to parse the type of test: {path:?}")); 328 | }; 329 | match base_dir { 330 | "tests" => TestType::Tests, 331 | "examples" => TestType::Examples, 332 | "benches" => TestType::Benches, 333 | _ => return Err(format!("failed to parse the type of test: {path:?}")), 334 | } 335 | }; 336 | 337 | // e.g. target/debug/deps/cargo_pretty_test-xxxxxxxxxxxxxxxx 338 | let mut pkg_comp = Path::new(pkg.as_str()).components(); 339 | match pkg_comp.next().map(|p| p.as_os_str() == "target") { 340 | Some(true) => (), 341 | _ => return Err(format!("failed to parse the location of test: {pkg:?}")), 342 | } 343 | let pkg = pkg_comp 344 | .nth(2) 345 | .ok_or_else(|| format!("can't get the third component in {pkg:?}"))? 346 | .as_os_str() 347 | .to_str() 348 | .ok_or_else(|| format!("can't turn os_str into str in {pkg:?}"))?; 349 | let pkg = &pkg[..pkg 350 | .find('-') 351 | .ok_or_else(|| format!("pkg `{pkg}` should be of `pkgname-hash` pattern"))?]; 352 | Ok(TestRunner { 353 | ty, 354 | src: Src { 355 | src_path: path, 356 | bin_name: pkg, 357 | }, 358 | }) 359 | } else if let Some(s) = cap.name("doc").map(|m| m.as_str()) { 360 | Ok(TestRunner { 361 | ty: TestType::Doc, 362 | src: Src { 363 | src_path: s, 364 | bin_name: s, 365 | }, 366 | }) 367 | } else { 368 | Err(format!("{cap:?} is not supported to be parsed")) 369 | } 370 | } 371 | re().ty 372 | .captures_iter(stderr) 373 | .map(|cap| parse_stderr_inner(&cap)) 374 | .collect::>>() 375 | } 376 | 377 | #[allow(clippy::too_many_lines)] 378 | pub fn parse_stdout(stdout: &str) -> Result> { 379 | fn parse_stdout_except_head(raw: &str) -> Result<(Vec, Text, Stats, Text)> { 380 | fn parse_tree_detail(text: &str) -> (Vec, Text) { 381 | let line: Vec<_> = re().tree.find_iter(text).collect(); 382 | let tree_end = line.last().map_or(0, |cap| cap.end() + 1); 383 | let mut tree: Vec<_> = line.into_iter().map(|cap| cap.as_str()).collect(); 384 | tree.sort_unstable(); 385 | (tree, text[tree_end..].trim()) 386 | } 387 | 388 | if raw.is_empty() { 389 | Err("raw stdout is empty".into()) 390 | } else { 391 | let (tree, detail) = parse_tree_detail(raw); 392 | let cap = re() 393 | .stats 394 | .captures(detail) 395 | .ok_or_else(|| format!("`stats` is not found in {raw:?}"))?; 396 | let stats = Stats { 397 | ok: cap 398 | .name("ok") 399 | .ok_or_else(|| format!("`ok` is not found in {raw:?}"))? 400 | .as_str() 401 | == "ok", 402 | total: u32::try_from(tree.len()).map_err(|err| err.to_string())?, 403 | passed: cap 404 | .name("passed") 405 | .ok_or_else(|| format!("`passed` is not found in {raw:?}"))? 406 | .as_str() 407 | .parse::() 408 | .map_err(|err| err.to_string())?, 409 | failed: cap 410 | .name("failed") 411 | .ok_or_else(|| format!("`failed` is not found in {raw:?}"))? 412 | .as_str() 413 | .parse::() 414 | .map_err(|err| err.to_string())?, 415 | ignored: cap 416 | .name("ignored") 417 | .ok_or_else(|| format!("`ignored` is not found in {raw:?}"))? 418 | .as_str() 419 | .parse::() 420 | .map_err(|err| err.to_string())?, 421 | measured: cap 422 | .name("measured") 423 | .ok_or_else(|| format!("`measured` is not found in {raw:?}"))? 424 | .as_str() 425 | .parse::() 426 | .map_err(|err| err.to_string())?, 427 | filtered_out: cap 428 | .name("filtered") 429 | .ok_or_else(|| format!("`filtered` is not found in {raw:?}"))? 430 | .as_str() 431 | .parse::() 432 | .map_err(|err| err.to_string())?, 433 | finished_in: Duration::from_secs_f32( 434 | cap.name("time") 435 | .ok_or_else(|| format!("`time` is not found in {raw:?}"))? 436 | .as_str() 437 | .parse::() 438 | .map_err(|err| err.to_string())?, 439 | ), 440 | }; 441 | let stats_start = cap 442 | .get(0) 443 | .ok_or_else(|| format!("can't get stats start in {raw:?}"))? 444 | .start(); 445 | Ok((tree, detail[..stats_start].trim(), stats, raw)) 446 | } 447 | } 448 | 449 | let split: Vec<_> = re() 450 | .head 451 | .captures_iter(stdout) 452 | .filter_map(|cap| { 453 | let full = cap.get(0)?; 454 | Some(( 455 | full.start(), 456 | full.as_str(), 457 | cap.name("amount")?.as_str().parse::().ok()?, 458 | )) 459 | }) 460 | .collect(); 461 | if split.is_empty() { 462 | return Err(format!( 463 | "{stdout:?} should contain `running (?P\\d+) tests?` pattern" 464 | )); 465 | } 466 | let parsed_stdout = if split.len() == 1 { 467 | vec![parse_stdout_except_head(stdout)?] 468 | } else { 469 | let start = split.iter().map(|v| v.0); 470 | let end = start.clone().skip(1).chain([stdout.len()]); 471 | start 472 | .zip(end) 473 | .map(|(a, b)| { 474 | let src = &stdout[a..b]; 475 | parse_stdout_except_head(src) 476 | }) 477 | .collect::>>()? 478 | }; 479 | 480 | // check the amount of tests 481 | let parsed_amount_from_head: Vec<_> = split.iter().map(|v| v.2).collect(); 482 | let stats_total: Vec<_> = parsed_stdout.iter().map(|v| v.2.total).collect(); 483 | if parsed_amount_from_head != stats_total { 484 | return Err(format!( 485 | "the parsed amount of running tests {parsed_amount_from_head:?} \ 486 | should equal to the number in stats.total {stats_total:?}\n\ 487 | split = {split:#?}\nparsed_stdout = {parsed_stdout:#?}" 488 | )); 489 | } 490 | 491 | Ok(split 492 | .iter() 493 | .zip(parsed_stdout) 494 | .map(|(head_info, v)| TestInfo { 495 | parsed: ParsedCargoTestOutput { 496 | head: head_info.1, 497 | tree: v.0, 498 | detail: v.1, 499 | }, 500 | stats: v.2, 501 | raw: v.3, 502 | }) 503 | .collect()) 504 | } 505 | -------------------------------------------------------------------------------- /src/prettify.rs: -------------------------------------------------------------------------------- 1 | use crate::regex::re; 2 | use colored::Colorize; 3 | use std::{ 4 | borrow::Cow, 5 | collections::{btree_map::Entry, BTreeMap}, 6 | }; 7 | use termtree::{GlyphPalette, Tree}; 8 | 9 | pub type TestTree<'s> = Tree>; 10 | 11 | /// Make the cargo test output pretty. 12 | #[must_use] 13 | pub fn make_pretty<'s, S>(root: S, lines: impl Iterator) -> Option> 14 | where 15 | S: Into>, 16 | { 17 | let mut path = BTreeMap::new(); 18 | for line in lines { 19 | let cap = re().tree.captures(line)?; 20 | let mut split = cap.name("split")?.as_str().split("::"); 21 | let status = cap.name("status")?.as_str(); 22 | let next = split.next(); 23 | make_node(split, status, &mut path, next); 24 | } 25 | let mut tree = Tree::new(root.into()); 26 | for (name, child) in path { 27 | make_tree(name, &child, &mut tree); 28 | } 29 | Some(tree) 30 | } 31 | 32 | #[derive(Debug)] 33 | enum Node<'s> { 34 | Path(BTreeMap<&'s str, Node<'s>>), 35 | Status(&'s str), 36 | } 37 | 38 | /// Add paths to Node. 39 | fn make_node<'s>( 40 | mut split: impl Iterator, 41 | status: &'s str, 42 | path: &mut BTreeMap<&'s str, Node<'s>>, 43 | key: Option<&'s str>, 44 | ) { 45 | let Some(key) = key else { return }; 46 | let next = split.next(); 47 | match path.entry(key) { 48 | Entry::Vacant(empty) => { 49 | if next.is_some() { 50 | let mut btree = BTreeMap::new(); 51 | make_node(split, status, &mut btree, next); 52 | empty.insert(Node::Path(btree)); 53 | } else { 54 | empty.insert(Node::Status(status)); 55 | } 56 | } 57 | Entry::Occupied(mut node) => { 58 | if let Node::Path(btree) = node.get_mut() { 59 | make_node(split, status, btree, next); 60 | } 61 | } 62 | } 63 | } 64 | 65 | /// Add Node to Tree. 66 | fn make_tree<'s>(root: &'s str, node: &Node<'s>, parent: &mut TestTree<'s>) { 67 | match node { 68 | Node::Path(btree) => { 69 | let mut testtree = Tree::new(root.into()); 70 | for (path, child) in btree { 71 | make_tree(path, child, &mut testtree); 72 | } 73 | parent.push(testtree); 74 | } 75 | Node::Status(s) => { 76 | let status = Status::new(s); 77 | let testtree = Tree::new(status.set_color(root)); 78 | parent.push(testtree.with_glyphs(status.glyph())); 79 | } 80 | } 81 | } 82 | 83 | #[derive(Clone, Copy)] 84 | pub enum Status { 85 | Ok, 86 | Ignored, 87 | Failed, 88 | } 89 | 90 | impl Status { 91 | pub fn new(status: &str) -> Status { 92 | if status.ends_with("ok") { 93 | // including the case that should panic and did panic 94 | Status::Ok 95 | } else if status.starts_with("ignored") { 96 | Status::Ignored 97 | } else { 98 | // including should panic but didn't panic 99 | Status::Failed 100 | } 101 | } 102 | 103 | pub const fn icon(self) -> &'static str { 104 | match self { 105 | Status::Ok => "─ ✅ ", 106 | Status::Ignored => "─ 🔕 ", 107 | Status::Failed => "─ ❌ ", 108 | } 109 | } 110 | 111 | /// Display with a status icon 112 | pub fn glyph(self) -> GlyphPalette { 113 | let mut glyph = GlyphPalette::new(); 114 | glyph.item_indent = self.icon(); 115 | glyph 116 | } 117 | 118 | pub fn set_color(self, s: &str) -> Cow<'_, str> { 119 | match self { 120 | Status::Ok => s.into(), 121 | Status::Ignored => s.bright_black().to_string().into(), 122 | Status::Failed => s.red().bold().to_string().into(), 123 | } 124 | } 125 | } 126 | 127 | pub const ICON_NOTATION: &str = " 128 | Icon Notation: 129 | ─ ✅ pass (including the case that should panic and did panic) 130 | ─ ❌ fail (including the case that should panic but didn't panic) 131 | ─ 🔕 ignored (with reason omitted) 132 | ─ ✂️ filtered out (won't show in the test tree, but will be computed in the summary) 133 | "; 134 | -------------------------------------------------------------------------------- /src/regex.rs: -------------------------------------------------------------------------------- 1 | use colored::{ColoredString, Colorize}; 2 | use regex_lite::Regex; 3 | 4 | // Lazily initialize a global variable. 5 | #[doc(hidden)] 6 | #[macro_export] 7 | macro_rules! lazy_static { 8 | ($v:vis $f:ident, $t:ty, $e:block $(;)?) => { 9 | lazy_static! { $v $f -> &'static $t, $t, $e } 10 | }; 11 | ($v:vis $f:ident -> $ret:ty, $t:ty, $e:block $(;)?) => { 12 | #[allow(dead_code)] 13 | $v fn $f() -> $ret { 14 | static TMP: ::std::sync::OnceLock<$t> = ::std::sync::OnceLock::new(); 15 | TMP.get_or_init(|| $e) 16 | } 17 | }; 18 | } 19 | 20 | const RE_ERROR: &str = "regex pattern error"; 21 | 22 | pub struct Re { 23 | pub ty: Regex, 24 | pub head: Regex, 25 | pub tree: Regex, 26 | pub stats: Regex, 27 | pub separator: ColoredString, 28 | } 29 | 30 | lazy_static!(pub re, Re, { 31 | Re { 32 | // Running unittests src/lib.rs (target/debug/deps/cargo_pretty_test-9b4400a4dee777d5) 33 | // Running unittests src/main.rs (target/debug/deps/cargo_pretty_test-269f1bfba2d44b88) 34 | // Running tests/golden_master_test.rs (target/debug/deps/golden_master_test-4deced585767cf11) 35 | // Running tests/mocking_project.rs (target/debug/deps/mocking_project-bd11dfdabc9464fa) 36 | // Doc-tests cargo-pretty-test 37 | ty: Regex::new(r"(?m)^\s+(Running (?Punittests )?(?P\S+) \((?P.*)\))|(Doc-tests (?P\S+))$") 38 | .expect(RE_ERROR), 39 | // running 0 tests; running 1 test; running 2 tests; ... 40 | head: Regex::new(r"running (?P\d+) tests?").expect(RE_ERROR), 41 | // Common test info: 42 | // test submod::normal_test ... ok 43 | // test submod::ignore ... ignored, reason 44 | // test submod::ignore_without_reason ... ignored 45 | // test submod::panic::should_panic - should panic ... ok 46 | // test submod::panic::should_panic_without_reanson - should panic ... ok 47 | // 48 | // Doc Test: ^test (?P\S+) - (?P\S+) \(line \d+\)( - compile( fail)?)? ... (?P\S+(, .*)?)$ 49 | // test src/doc.rs - doc (line 3) ... ok 50 | // test tests/integration/src/lib.rs - attribute::edition2018 (line 100) ... ok 51 | // test tests/integration/src/lib.rs - attribute::ignore (line 76) ... ignored 52 | // test tests/integration/src/lib.rs - attribute::no_run (line 86) - compile ... ok 53 | // test tests/integration/src/lib.rs - attribute::should_compile_fail (line 90) - compile fail ... ok 54 | // test tests/integration/src/lib.rs - attribute::should_compile_fail_but_didnt (line 96) - compile fail ... FAILED 55 | // test tests/integration/src/lib.rs - attribute::should_panic (line 80) ... ok 56 | // test tests/integration/src/lib.rs - empty_doc_mod (line 41) ... ok 57 | // test tests/integration/src/lib.rs - empty_doc_mod::Item (line 48) ... ok 58 | // test tests/integration/src/lib.rs - empty_doc_mod::private_mod (line 44) ... ok 59 | // test tests/integration/src/lib.rs - (line 1) ... ok 60 | tree: Regex::new(r"(?m)^test (?P\S+( - should panic)?(? -( \S+)? \(line \d+\)( - compile( fail)?)?)?) \.\.\. (?P\S+(, .*)?)$").expect(RE_ERROR), 61 | // test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s 62 | stats: Regex::new(r"(?mx) 63 | ^test\ result:\ (?P\S+)\. 64 | \ (?P\d+)\ passed; 65 | \ (?P\d+)\ failed; 66 | \ (?P\d+)\ ignored; 67 | \ (?P\d+)\ measured; 68 | \ (?P\d+)\ filtered\ out; 69 | \ finished\ in\ (?P