├── .github └── workflows │ ├── audit.yml │ ├── ci.yml │ └── nightly.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── demo.tape ├── deny.toml ├── img ├── cover.png └── demo.gif └── src ├── lib.rs └── main.rs /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | push: 7 | paths: 8 | - "**/Cargo.toml" 9 | - "**/Cargo.lock" 10 | jobs: 11 | security_audit: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: EmbarkStudios/cargo-deny-action@v2 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [pull_request, workflow_dispatch] 3 | env: 4 | CARGO_TERM_COLOR: always 5 | 6 | jobs: 7 | lint: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: dtolnay/rust-toolchain@stable 12 | with: 13 | components: "clippy, rustfmt" 14 | - uses: Swatinem/rust-cache@v2 15 | - run: cargo fmt --all -- --check 16 | - run: cargo clippy --all-targets --all-features -- -D clippy::pedantic -D warnings 17 | 18 | test: 19 | strategy: 20 | matrix: 21 | os: [ubuntu-latest, macos-latest, windows-latest] 22 | runs-on: ${{ matrix.os }} 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: dtolnay/rust-toolchain@stable 26 | - uses: Swatinem/rust-cache@v2 27 | - run: cargo test --all-features 28 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: Nightly 2 | on: [pull_request, workflow_dispatch] 3 | env: 4 | CARGO_TERM_COLOR: always 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: dtolnay/rust-toolchain@nightly 12 | - uses: Swatinem/rust-cache@v2 13 | - run: cargo test --all-features 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | cargo-test*.profraw 3 | /target 4 | -------------------------------------------------------------------------------- /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 = "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 = "anyhow" 16 | version = "1.0.86" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" 19 | 20 | [[package]] 21 | name = "cargo-testdox" 22 | version = "0.2.1" 23 | dependencies = [ 24 | "anyhow", 25 | "colored", 26 | "regex", 27 | ] 28 | 29 | [[package]] 30 | name = "colored" 31 | version = "2.1.0" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" 34 | dependencies = [ 35 | "lazy_static", 36 | "windows-sys", 37 | ] 38 | 39 | [[package]] 40 | name = "lazy_static" 41 | version = "1.5.0" 42 | source = "registry+https://github.com/rust-lang/crates.io-index" 43 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 44 | 45 | [[package]] 46 | name = "memchr" 47 | version = "2.7.4" 48 | source = "registry+https://github.com/rust-lang/crates.io-index" 49 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 50 | 51 | [[package]] 52 | name = "regex" 53 | version = "1.10.6" 54 | source = "registry+https://github.com/rust-lang/crates.io-index" 55 | checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" 56 | dependencies = [ 57 | "aho-corasick", 58 | "memchr", 59 | "regex-automata", 60 | "regex-syntax", 61 | ] 62 | 63 | [[package]] 64 | name = "regex-automata" 65 | version = "0.4.7" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" 68 | dependencies = [ 69 | "aho-corasick", 70 | "memchr", 71 | "regex-syntax", 72 | ] 73 | 74 | [[package]] 75 | name = "regex-syntax" 76 | version = "0.8.4" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" 79 | 80 | [[package]] 81 | name = "windows-sys" 82 | version = "0.48.0" 83 | source = "registry+https://github.com/rust-lang/crates.io-index" 84 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 85 | dependencies = [ 86 | "windows-targets", 87 | ] 88 | 89 | [[package]] 90 | name = "windows-targets" 91 | version = "0.48.5" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 94 | dependencies = [ 95 | "windows_aarch64_gnullvm", 96 | "windows_aarch64_msvc", 97 | "windows_i686_gnu", 98 | "windows_i686_msvc", 99 | "windows_x86_64_gnu", 100 | "windows_x86_64_gnullvm", 101 | "windows_x86_64_msvc", 102 | ] 103 | 104 | [[package]] 105 | name = "windows_aarch64_gnullvm" 106 | version = "0.48.5" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 109 | 110 | [[package]] 111 | name = "windows_aarch64_msvc" 112 | version = "0.48.5" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 115 | 116 | [[package]] 117 | name = "windows_i686_gnu" 118 | version = "0.48.5" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 121 | 122 | [[package]] 123 | name = "windows_i686_msvc" 124 | version = "0.48.5" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 127 | 128 | [[package]] 129 | name = "windows_x86_64_gnu" 130 | version = "0.48.5" 131 | source = "registry+https://github.com/rust-lang/crates.io-index" 132 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 133 | 134 | [[package]] 135 | name = "windows_x86_64_gnullvm" 136 | version = "0.48.5" 137 | source = "registry+https://github.com/rust-lang/crates.io-index" 138 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 139 | 140 | [[package]] 141 | name = "windows_x86_64_msvc" 142 | version = "0.48.5" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 145 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cargo-testdox" 3 | version = "0.2.1" 4 | authors = ["John Arundel "] 5 | edition = "2021" 6 | description = """ 7 | A Cargo subcommand to print your test names as sentences. 8 | """ 9 | keywords = ["test", "testdox", "unit-tests"] 10 | categories = [ 11 | "command-line-utilities", 12 | "development-tools", 13 | "development-tools::cargo-plugins", 14 | "development-tools::testing", 15 | ] 16 | license = "MIT OR Apache-2.0" 17 | readme = "README.md" 18 | documentation = "https://docs.rs/cargo-testdox" 19 | homepage = "https://github.com/bitfield/cargo-testdox" 20 | repository = "https://github.com/bitfield/cargo-testdox" 21 | exclude = ["/.github/"] 22 | 23 | [badges] 24 | github = { repository = "bitfield/cargo-testdox", workflow = "CI" } 25 | maintenance = { status = "actively-developed" } 26 | 27 | [dependencies] 28 | anyhow = "1.0.86" 29 | colored = "2.1.0" 30 | regex = "1.10.6" 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 John Arundel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Crate](https://img.shields.io/crates/v/cargo-testdox.svg)](https://crates.io/crates/cargo-testdox) 2 | [![Docs](https://docs.rs/cargo-testdox/badge.svg)](https://docs.rs/cargo-testdox) 3 | ![CI](https://github.com/bitfield/cargo-testdox/actions/workflows/ci.yml/badge.svg) 4 | ![Audit](https://github.com/bitfield/cargo-testdox/actions/workflows/audit.yml/badge.svg) 5 | ![Maintenance](https://img.shields.io/badge/maintenance-actively--developed-brightgreen.svg) 6 | 7 | # cargo-testdox 8 | 9 | A Cargo subcommand to print your Rust test names as sentences. 10 | 11 | ## Installation 12 | 13 | ```sh 14 | cargo install cargo-testdox 15 | ``` 16 | 17 | ## Usage 18 | 19 | In any Rust project with tests, run: 20 | 21 | ```sh 22 | cargo testdox 23 | ``` 24 | 25 | ![Animated demo](img/demo.gif) 26 | 27 | `cargo-testdox` will first invoke `cargo test` to run your tests, with any extra arguments that you give it. It will then show the result for each test (passed, failed, or ignored), with the test name formatted as a sentence. That is, with underscores replaced by spaces. 28 | 29 | For example, the following test: 30 | 31 | ```rust 32 | #[test] 33 | fn it_works() {} 34 | ``` 35 | 36 | will produce this output when run with `cargo-testdox`: 37 | 38 | ``` 39 | ✔ it works 40 | ``` 41 | 42 | If the test were failing, it would produce: 43 | 44 | ``` 45 | x it works 46 | ``` 47 | 48 | If the test were ignored, it would produce: 49 | 50 | ``` 51 | ? it works 52 | ``` 53 | 54 | Doctests are ignored, since they can't currently be named (pending [RFC #3311](https://github.com/rust-lang/rfcs/pull/3311)). 55 | 56 | ### Function names with underscores 57 | 58 | To avoid underscores in a snake-case function name from being replaced, put `_fn_` after the function name: 59 | 60 | ```rust 61 | #[test] 62 | fn print_hello_world_fn_prints_hello_world() {} 63 | ``` 64 | 65 | becomes: 66 | 67 | ``` 68 | ✔ print_hello_world prints hello world 69 | ``` 70 | 71 | ## Why 72 | 73 | Because [test names should be sentences](https://bitfieldconsulting.com/posts/test-names). 74 | 75 | Compare [`gotestdox`](https://github.com/bitfield/gotestdox), a similar tool for Go tests. 76 | 77 | This is an example project from my book [The Secrets of Rust: Tools](https://bitfieldconsulting.com/books/rust-tools). 78 | 79 | [![Secrets of Rust: Tools cover image](img/cover.png)](https://bitfieldconsulting.com/books/rust-tools) 80 | -------------------------------------------------------------------------------- /demo.tape: -------------------------------------------------------------------------------- 1 | # https://github.com/charmbracelet/vhs 2 | 3 | Output img/demo.gif 4 | 5 | # Set Shell zsh 6 | Set FontSize 16 7 | Set Width 800 8 | Set Height 380 9 | Set Padding 5 10 | Set WindowBar Colorful 11 | Set FontFamily "Recursive Monospace" 12 | 13 | Type "cargo testdox" Sleep 500ms Enter 14 | 15 | Sleep 30s 16 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # This template contains all of the possible sections and their default values 2 | 3 | # Note that all fields that take a lint level have these possible values: 4 | # * deny - An error will be produced and the check will fail 5 | # * warn - A warning will be produced, but the check will not fail 6 | # * allow - No warning or error will be produced, though in some cases a note 7 | # will be 8 | 9 | # The values provided in this template are the default values that will be used 10 | # when any section or field is not specified in your own configuration 11 | 12 | # Root options 13 | 14 | # The graph table configures how the dependency graph is constructed and thus 15 | # which crates the checks are performed against 16 | [graph] 17 | # If 1 or more target triples (and optionally, target_features) are specified, 18 | # only the specified targets will be checked when running `cargo deny check`. 19 | # This means, if a particular package is only ever used as a target specific 20 | # dependency, such as, for example, the `nix` crate only being used via the 21 | # `target_family = "unix"` configuration, that only having windows targets in 22 | # this list would mean the nix crate, as well as any of its exclusive 23 | # dependencies not shared by any other crates, would be ignored, as the target 24 | # list here is effectively saying which targets you are building for. 25 | targets = [ 26 | # The triple can be any string, but only the target triples built in to 27 | # rustc (as of 1.40) can be checked against actual config expressions 28 | #"x86_64-unknown-linux-musl", 29 | # You can also specify which target_features you promise are enabled for a 30 | # particular target. target_features are currently not validated against 31 | # the actual valid features supported by the target architecture. 32 | #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, 33 | ] 34 | # When creating the dependency graph used as the source of truth when checks are 35 | # executed, this field can be used to prune crates from the graph, removing them 36 | # from the view of cargo-deny. This is an extremely heavy hammer, as if a crate 37 | # is pruned from the graph, all of its dependencies will also be pruned unless 38 | # they are connected to another crate in the graph that hasn't been pruned, 39 | # so it should be used with care. The identifiers are [Package ID Specifications] 40 | # (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) 41 | #exclude = [] 42 | # If true, metadata will be collected with `--all-features`. Note that this can't 43 | # be toggled off if true, if you want to conditionally enable `--all-features` it 44 | # is recommended to pass `--all-features` on the cmd line instead 45 | all-features = false 46 | # If true, metadata will be collected with `--no-default-features`. The same 47 | # caveat with `all-features` applies 48 | no-default-features = false 49 | # If set, these feature will be enabled when collecting metadata. If `--features` 50 | # is specified on the cmd line they will take precedence over this option. 51 | #features = [] 52 | 53 | # The output table provides options for how/if diagnostics are outputted 54 | [output] 55 | # When outputting inclusion graphs in diagnostics that include features, this 56 | # option can be used to specify the depth at which feature edges will be added. 57 | # This option is included since the graphs can be quite large and the addition 58 | # of features from the crate(s) to all of the graph roots can be far too verbose. 59 | # This option can be overridden via `--feature-depth` on the cmd line 60 | feature-depth = 1 61 | 62 | # This section is considered when running `cargo deny check advisories` 63 | # More documentation for the advisories section can be found here: 64 | # https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html 65 | [advisories] 66 | # The path where the advisory databases are cloned/fetched into 67 | #db-path = "$CARGO_HOME/advisory-dbs" 68 | # The url(s) of the advisory databases to use 69 | #db-urls = ["https://github.com/rustsec/advisory-db"] 70 | # A list of advisory IDs to ignore. Note that ignored advisories will still 71 | # output a note when they are encountered. 72 | ignore = [ 73 | #"RUSTSEC-0000-0000", 74 | #{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" }, 75 | #"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish 76 | #{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" }, 77 | ] 78 | # If this is true, then cargo deny will use the git executable to fetch advisory database. 79 | # If this is false, then it uses a built-in git library. 80 | # Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. 81 | # See Git Authentication for more information about setting up git authentication. 82 | #git-fetch-with-cli = true 83 | 84 | # This section is considered when running `cargo deny check licenses` 85 | # More documentation for the licenses section can be found here: 86 | # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html 87 | [licenses] 88 | # List of explicitly allowed licenses 89 | # See https://spdx.org/licenses/ for list of possible licenses 90 | # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. 91 | allow = [ 92 | "MIT", 93 | "Apache-2.0", 94 | "MPL-2.0", 95 | #"Apache-2.0 WITH LLVM-exception", 96 | ] 97 | # The confidence threshold for detecting a license from license text. 98 | # The higher the value, the more closely the license text must be to the 99 | # canonical license text of a valid SPDX license file. 100 | # [possible values: any between 0.0 and 1.0]. 101 | confidence-threshold = 0.8 102 | # Allow 1 or more licenses on a per-crate basis, so that particular licenses 103 | # aren't accepted for every possible crate as with the normal allow list 104 | exceptions = [ 105 | # Each entry is the crate and version constraint, and its specific allow 106 | # list 107 | #{ allow = ["Zlib"], crate = "adler32" }, 108 | ] 109 | 110 | # Some crates don't have (easily) machine readable licensing information, 111 | # adding a clarification entry for it allows you to manually specify the 112 | # licensing information 113 | #[[licenses.clarify]] 114 | # The package spec the clarification applies to 115 | #crate = "ring" 116 | # The SPDX expression for the license requirements of the crate 117 | #expression = "MIT AND ISC AND OpenSSL" 118 | # One or more files in the crate's source used as the "source of truth" for 119 | # the license expression. If the contents match, the clarification will be used 120 | # when running the license check, otherwise the clarification will be ignored 121 | # and the crate will be checked normally, which may produce warnings or errors 122 | # depending on the rest of your configuration 123 | #license-files = [ 124 | # Each entry is a crate relative path, and the (opaque) hash of its contents 125 | #{ path = "LICENSE", hash = 0xbd0eed23 } 126 | #] 127 | 128 | [licenses.private] 129 | # If true, ignores workspace crates that aren't published, or are only 130 | # published to private registries. 131 | # To see how to mark a crate as unpublished (to the official registry), 132 | # visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. 133 | ignore = false 134 | # One or more private registries that you might publish crates to, if a crate 135 | # is only published to private registries, and ignore is true, the crate will 136 | # not have its license(s) checked 137 | registries = [ 138 | #"https://sekretz.com/registry 139 | ] 140 | 141 | # This section is considered when running `cargo deny check bans`. 142 | # More documentation about the 'bans' section can be found here: 143 | # https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html 144 | [bans] 145 | # Lint level for when multiple versions of the same crate are detected 146 | multiple-versions = "warn" 147 | # Lint level for when a crate version requirement is `*` 148 | wildcards = "allow" 149 | # The graph highlighting used when creating dotgraphs for crates 150 | # with multiple versions 151 | # * lowest-version - The path to the lowest versioned duplicate is highlighted 152 | # * simplest-path - The path to the version with the fewest edges is highlighted 153 | # * all - Both lowest-version and simplest-path are used 154 | highlight = "all" 155 | # The default lint level for `default` features for crates that are members of 156 | # the workspace that is being checked. This can be overridden by allowing/denying 157 | # `default` on a crate-by-crate basis if desired. 158 | workspace-default-features = "allow" 159 | # The default lint level for `default` features for external crates that are not 160 | # members of the workspace. This can be overridden by allowing/denying `default` 161 | # on a crate-by-crate basis if desired. 162 | external-default-features = "allow" 163 | # List of crates that are allowed. Use with care! 164 | allow = [ 165 | #"ansi_term@0.11.0", 166 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" }, 167 | ] 168 | # List of crates to deny 169 | deny = [ 170 | #"ansi_term@0.11.0", 171 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" }, 172 | # Wrapper crates can optionally be specified to allow the crate when it 173 | # is a direct dependency of the otherwise banned crate 174 | #{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] }, 175 | ] 176 | 177 | # List of features to allow/deny 178 | # Each entry the name of a crate and a version range. If version is 179 | # not specified, all versions will be matched. 180 | #[[bans.features]] 181 | #crate = "reqwest" 182 | # Features to not allow 183 | #deny = ["json"] 184 | # Features to allow 185 | #allow = [ 186 | # "rustls", 187 | # "__rustls", 188 | # "__tls", 189 | # "hyper-rustls", 190 | # "rustls", 191 | # "rustls-pemfile", 192 | # "rustls-tls-webpki-roots", 193 | # "tokio-rustls", 194 | # "webpki-roots", 195 | #] 196 | # If true, the allowed features must exactly match the enabled feature set. If 197 | # this is set there is no point setting `deny` 198 | #exact = true 199 | 200 | # Certain crates/versions that will be skipped when doing duplicate detection. 201 | skip = [ 202 | #"ansi_term@0.11.0", 203 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" }, 204 | ] 205 | # Similarly to `skip` allows you to skip certain crates during duplicate 206 | # detection. Unlike skip, it also includes the entire tree of transitive 207 | # dependencies starting at the specified crate, up to a certain depth, which is 208 | # by default infinite. 209 | skip-tree = [ 210 | #"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies 211 | #{ crate = "ansi_term@0.11.0", depth = 20 }, 212 | ] 213 | 214 | # This section is considered when running `cargo deny check sources`. 215 | # More documentation about the 'sources' section can be found here: 216 | # https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html 217 | [sources] 218 | # Lint level for what to happen when a crate from a crate registry that is not 219 | # in the allow list is encountered 220 | unknown-registry = "warn" 221 | # Lint level for what to happen when a crate from a git repository that is not 222 | # in the allow list is encountered 223 | unknown-git = "warn" 224 | # List of URLs for allowed crate registries. Defaults to the crates.io index 225 | # if not specified. If it is specified but empty, no registries are allowed. 226 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 227 | # List of URLs for allowed Git repositories 228 | allow-git = [] 229 | 230 | [sources.allow-org] 231 | # 1 or more github.com organizations to allow git sources for 232 | #github = [""] 233 | # 1 or more gitlab.com organizations to allow git sources for 234 | #gitlab = [""] 235 | # 1 or more bitbucket.org organizations to allow git sources for 236 | #bitbucket = [""] 237 | -------------------------------------------------------------------------------- /img/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfield/cargo-testdox/5ffddb6e075bcc849a6eaf39f6bc6bdd3bdfdb8f/img/cover.png -------------------------------------------------------------------------------- /img/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfield/cargo-testdox/5ffddb6e075bcc849a6eaf39f6bc6bdd3bdfdb8f/img/demo.gif -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A Cargo subcommand for printing your Rust test names as sentences. 2 | //! 3 | //! Also contains functions to parse the (human-readable) output of `cargo test`, 4 | //! and to format test names as sentences. 5 | //! 6 | //! Further reading and context: [Test names should be 7 | //! sentences](https://bitfieldconsulting.com/posts/test-names). 8 | use anyhow::Context; 9 | use colored::Colorize; 10 | use regex::Regex; 11 | use std::{process::Command, sync::LazyLock}; 12 | 13 | #[must_use] 14 | /// Runs `cargo test` with any supplied extra arguments, and returns the 15 | /// resulting standard output. 16 | /// 17 | /// # Panics 18 | /// 19 | /// If executing the `cargo test` command fails. 20 | pub fn get_cargo_test_output(extra_args: Vec) -> String { 21 | let mut cargo = Command::new("cargo"); 22 | cargo.arg("test"); 23 | if !extra_args.is_empty() { 24 | cargo.arg("--"); 25 | cargo.args(extra_args); 26 | } 27 | let raw_output = cargo 28 | .output() 29 | .context(format!("{cargo:?}")) 30 | .expect("executing command should succeed") 31 | .stdout; 32 | String::from_utf8_lossy(&raw_output).to_string() 33 | } 34 | 35 | #[must_use] 36 | /// Parses the standard output of `cargo test` into a vec of `TestResult`. 37 | pub fn parse_test_results(test_output: &str) -> Vec { 38 | test_output.lines().filter_map(parse_line).collect() 39 | } 40 | 41 | static MODULE_PREFIX: LazyLock = LazyLock::new(|| Regex::new(".*::").unwrap()); 42 | 43 | /// Parses a line from the standard output of `cargo test`. 44 | /// 45 | /// If the line represents the result of a test, returns `Some(TestResult)`, 46 | /// otherwise returns `None`. 47 | pub fn parse_line>(line: S) -> Option { 48 | let line = line.as_ref().strip_prefix("test ")?; 49 | if line.starts_with("result") || line.contains("(line ") { 50 | return None; 51 | } 52 | let line = MODULE_PREFIX.replace(line, ""); 53 | let splits: Vec<_> = line.split(" ... ").collect(); 54 | let (name, result) = (splits[0], splits[1]); 55 | Some(TestResult { 56 | name: prettify(name), 57 | status: match result { 58 | "ok" => Status::Pass, 59 | "FAILED" => Status::Fail, 60 | "ignored" => Status::Ignored, 61 | _ => todo!("unhandled test status {:?}", result), 62 | }, 63 | }) 64 | } 65 | 66 | #[must_use] 67 | /// Formats the name of a test function as a sentence. 68 | /// 69 | /// Underscores are replaced with spaces. To retain the underscores in a function name, put `_fn_` after it. For example: 70 | /// 71 | /// ```text 72 | /// parse_line_fn_parses_a_line 73 | /// ``` 74 | /// 75 | /// becomes: 76 | /// 77 | /// ```text 78 | /// parse_line parses a line 79 | /// ``` 80 | pub fn prettify>(input: S) -> String { 81 | let mut output = String::new(); 82 | if let Some((fn_name, sentence)) = input.as_ref().split_once("_fn_") { 83 | output.push_str(fn_name); 84 | output.push(' '); 85 | output.push_str(sentence.replace('_', " ").as_ref()); 86 | } else { 87 | output.push_str(input.as_ref().replace('_', " ").as_ref()); 88 | } 89 | output 90 | } 91 | 92 | #[derive(Debug, PartialEq)] 93 | /// The (prettified) name and pass/fail status of a given test. 94 | pub struct TestResult { 95 | pub name: String, 96 | pub status: Status, 97 | } 98 | 99 | impl std::fmt::Display for TestResult { 100 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 101 | let status = match self.status { 102 | Status::Pass => "✔".bright_green(), 103 | Status::Fail => "x".bright_red(), 104 | Status::Ignored => "?".bright_yellow(), 105 | }; 106 | write!(f, " {status} {}", self.name) 107 | } 108 | } 109 | 110 | #[derive(Debug, PartialEq)] 111 | /// The status of a given test, as reported by `cargo test`. 112 | pub enum Status { 113 | Pass, 114 | Fail, 115 | Ignored, 116 | } 117 | 118 | #[cfg(test)] 119 | mod tests { 120 | use super::*; 121 | 122 | #[test] 123 | fn prettify_returns_expected_results() { 124 | struct Case { 125 | input: &'static str, 126 | want: String, 127 | } 128 | let cases = Vec::from([ 129 | Case { 130 | input: "anagrams_must_use_all_letters_exactly_once", 131 | want: "anagrams must use all letters exactly once".into(), 132 | }, 133 | Case { 134 | input: "no_matches", 135 | want: "no matches".into(), 136 | }, 137 | Case { 138 | input: "single", 139 | want: "single".into(), 140 | }, 141 | Case { 142 | input: "parse_line_fn_does_stuff", 143 | want: "parse_line does stuff".into(), 144 | }, 145 | ]); 146 | for case in cases { 147 | assert_eq!(case.want, prettify(case.input)); 148 | } 149 | } 150 | 151 | #[test] 152 | fn parse_line_fn_returns_expected_result() { 153 | struct Case { 154 | line: &'static str, 155 | want: Option, 156 | } 157 | let cases = Vec::from([ 158 | Case { 159 | line: " Finished `test` profile [unoptimized + debuginfo] target(s) in 0.20s", 160 | want: None, 161 | }, 162 | Case { 163 | line: "test foo ... ok", 164 | want: Some(TestResult { 165 | name: "foo".into(), 166 | status: Status::Pass, 167 | }), 168 | }, 169 | Case { 170 | line: "test tests::urls_correctly_extracts_valid_urls ... FAILED", 171 | want: Some(TestResult { 172 | name: "urls correctly extracts valid urls".into(), 173 | status: Status::Fail, 174 | }), 175 | }, 176 | Case { 177 | line: "test files::test::files_can_be_sorted_in_descending_order ... ignored", 178 | want: Some(TestResult { 179 | name: "files can be sorted in descending order".into(), 180 | status: Status::Ignored, 181 | }), 182 | }, 183 | Case { 184 | line: "test src/lib.rs - find_top_n_largest_files (line 17) ... ok", 185 | want: None, 186 | }, 187 | ]); 188 | for case in cases { 189 | assert_eq!(case.want, parse_line(case.line)); 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use cargo_testdox::{get_cargo_test_output, parse_test_results, Status}; 2 | 3 | fn main() { 4 | let output = get_cargo_test_output(std::env::args().skip(2).collect()); 5 | let results = parse_test_results(&output); 6 | let mut failed = false; 7 | for result in results { 8 | println!("{result}"); 9 | if result.status == Status::Fail { 10 | failed = true; 11 | } 12 | } 13 | if failed { 14 | std::process::exit(1); 15 | } 16 | } 17 | --------------------------------------------------------------------------------