├── .gitignore ├── README.md ├── crates ├── snapbox │ ├── LICENSE-MIT │ ├── LICENSE-APACHE │ ├── examples │ │ ├── snap-example-fixture.rs │ │ └── diff.rs │ ├── tests │ │ └── testsuite │ │ │ ├── main.rs │ │ │ ├── assert.rs │ │ │ └── cmd.rs │ ├── src │ │ ├── report │ │ │ ├── mod.rs │ │ │ └── color.rs │ │ ├── utils │ │ │ ├── mod.rs │ │ │ └── lines.rs │ │ ├── dir │ │ │ ├── mod.rs │ │ │ ├── tests.rs │ │ │ ├── root.rs │ │ │ ├── fixture.rs │ │ │ └── ops.rs │ │ ├── assert │ │ │ ├── action.rs │ │ │ └── error.rs │ │ ├── bin │ │ │ └── snap-fixture.rs │ │ ├── data │ │ │ ├── filters.rs │ │ │ ├── source.rs │ │ │ ├── format.rs │ │ │ └── tests.rs │ │ ├── filter │ │ │ ├── test.rs │ │ │ ├── test_unordered.rs │ │ │ ├── mod.rs │ │ │ └── redactions.rs │ │ ├── macros.rs │ │ └── lib.rs │ ├── README.md │ └── Cargo.toml ├── trycmd │ ├── LICENSE-MIT │ ├── LICENSE-APACHE │ ├── tests │ │ ├── cmd │ │ │ ├── cwd-base.in │ │ │ │ ├── foo │ │ │ │ │ └── .keep │ │ │ │ └── file.txt │ │ │ ├── diff_subset.in │ │ │ │ ├── dir │ │ │ │ │ ├── .keep │ │ │ │ │ └── ignore │ │ │ │ ├── ignore │ │ │ │ └── file.txt │ │ │ ├── stderr-to-stdout.stderr │ │ │ ├── subst-cwd.in │ │ │ │ ├── foo │ │ │ │ │ └── .keep │ │ │ │ └── file.txt │ │ │ ├── symlink.in │ │ │ ├── diff_subset.out │ │ │ │ ├── dir │ │ │ │ │ └── .keep │ │ │ │ └── file.txt │ │ │ ├── multistep.out │ │ │ │ └── file.txt │ │ │ ├── subst-cwd.stderr │ │ │ ├── basic.toml │ │ │ ├── normalize.out │ │ │ │ └── file.txt │ │ │ ├── normalize.stderr │ │ │ ├── sandbox.out │ │ │ │ └── file.txt │ │ │ ├── sandbox.stderr │ │ │ ├── stderr.stderr │ │ │ ├── stdout.stdout │ │ │ ├── cwd-base.stderr │ │ │ ├── infer-cwd.in │ │ │ │ └── file.txt │ │ │ ├── infer-cwd.stderr │ │ │ ├── normalize.stdout │ │ │ ├── symlink-target │ │ │ │ └── file.txt │ │ │ ├── symlink.stderr │ │ │ ├── basic.trycmd │ │ │ ├── large.toml │ │ │ ├── stderr-to-stdout.stdout │ │ │ ├── infer-cwd.toml │ │ │ ├── symlink.toml │ │ │ ├── code.toml │ │ │ ├── failure.toml │ │ │ ├── stderr.toml │ │ │ ├── stdout.toml │ │ │ ├── stdout.trycmd │ │ │ ├── unresolved.trycmd │ │ │ ├── subst-cwd.toml │ │ │ ├── vars.trycmd │ │ │ ├── cwd-base.toml │ │ │ ├── diff_subset.toml │ │ │ ├── ignored_bin.trycmd │ │ │ ├── stderr-to-stdout-large.toml │ │ │ ├── sandbox.toml │ │ │ ├── timeout.toml │ │ │ ├── stderr_toml.toml │ │ │ ├── normalize.toml │ │ │ ├── stderr-to-stdout.toml │ │ │ └── multistep.trycmd │ │ ├── testsuite │ │ │ ├── main.rs │ │ │ └── schema.rs │ │ ├── example_tests.rs │ │ └── cli_tests.rs │ ├── src │ │ ├── bin │ │ │ ├── trycmd-schema.rs │ │ │ └── bin-fixture.rs │ │ ├── cargo.rs │ │ ├── registry.rs │ │ ├── spec.rs │ │ ├── cases.rs │ │ └── lib.rs │ ├── README.md │ ├── Cargo.toml │ └── schema.json ├── tryfn │ ├── LICENSE-MIT │ ├── LICENSE-APACHE │ ├── README.md │ ├── CHANGELOG.md │ ├── Cargo.toml │ └── src │ │ └── lib.rs └── snapbox-macros │ ├── LICENSE-MIT │ ├── LICENSE-APACHE │ ├── Cargo.toml │ ├── README.md │ └── src │ └── lib.rs ├── release.toml ├── examples ├── example-fixture.rs ├── README.md ├── example-fixture.md └── demo_trycmd │ ├── tests │ └── trycmd.rs │ ├── Cargo.toml │ ├── src │ └── main.rs │ └── README.md ├── .cargo └── config.toml ├── .gitattributes ├── committed.toml ├── .pre-commit-config.yaml ├── .github ├── workflows │ ├── spelling.yml │ ├── pre-commit.yml │ ├── committed.yml │ ├── audit.yml │ ├── rust-next.yml │ └── ci.yml ├── settings.yml └── renovate.json5 ├── .clippy.toml ├── LICENSE-MIT ├── Cargo.toml ├── CONTRIBUTING.md ├── deny.toml └── LICENSE-APACHE /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | crates/snapbox/README.md -------------------------------------------------------------------------------- /crates/snapbox/LICENSE-MIT: -------------------------------------------------------------------------------- 1 | ../../LICENSE-MIT -------------------------------------------------------------------------------- /crates/trycmd/LICENSE-MIT: -------------------------------------------------------------------------------- 1 | ../../LICENSE-MIT -------------------------------------------------------------------------------- /crates/tryfn/LICENSE-MIT: -------------------------------------------------------------------------------- 1 | ../../LICENSE-MIT -------------------------------------------------------------------------------- /release.toml: -------------------------------------------------------------------------------- 1 | allow-branch = ["main"] 2 | -------------------------------------------------------------------------------- /crates/trycmd/LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | ../../LICENSE-APACHE -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/cwd-base.in/foo/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/diff_subset.in/dir/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/diff_subset.in/ignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/stderr-to-stdout.stderr: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/subst-cwd.in/foo/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/symlink.in: -------------------------------------------------------------------------------- 1 | symlink-target -------------------------------------------------------------------------------- /crates/tryfn/LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | ../../LICENSE-APACHE -------------------------------------------------------------------------------- /crates/snapbox-macros/LICENSE-MIT: -------------------------------------------------------------------------------- 1 | ../../LICENSE-MIT -------------------------------------------------------------------------------- /crates/snapbox/LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | ../../LICENSE-APACHE -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/diff_subset.in/dir/ignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/diff_subset.out/dir/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/multistep.out/file.txt: -------------------------------------------------------------------------------- 1 | Goodbye -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/subst-cwd.stderr: -------------------------------------------------------------------------------- 1 | [CWD] 2 | -------------------------------------------------------------------------------- /examples/example-fixture.rs: -------------------------------------------------------------------------------- 1 | ../src/bin/bin-fixture.rs -------------------------------------------------------------------------------- /crates/snapbox-macros/LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | ../../LICENSE-APACHE -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | Examples and example test cases 2 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/basic.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/normalize.out/file.txt: -------------------------------------------------------------------------------- 1 | Goodbye 2 | World -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/normalize.stderr: -------------------------------------------------------------------------------- 1 | Goodbye 2 | World 3 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/sandbox.out/file.txt: -------------------------------------------------------------------------------- 1 | Goodbye 2 | World -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/sandbox.stderr: -------------------------------------------------------------------------------- 1 | Goodbye 2 | World 3 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/stderr.stderr: -------------------------------------------------------------------------------- 1 | Hello 2 | World! 3 | 4 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/stdout.stdout: -------------------------------------------------------------------------------- 1 | Hello 2 | World! 3 | 4 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/cwd-base.in/file.txt: -------------------------------------------------------------------------------- 1 | Hello 2 | World! 3 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/cwd-base.stderr: -------------------------------------------------------------------------------- 1 | Hello 2 | World! 3 | 4 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/diff_subset.in/file.txt: -------------------------------------------------------------------------------- 1 | Goodbye 2 | World -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/diff_subset.out/file.txt: -------------------------------------------------------------------------------- 1 | Goodbye 2 | World -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/infer-cwd.in/file.txt: -------------------------------------------------------------------------------- 1 | Hello 2 | World! 3 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/infer-cwd.stderr: -------------------------------------------------------------------------------- 1 | Hello 2 | World! 3 | 4 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/normalize.stdout: -------------------------------------------------------------------------------- 1 | Hello 2 | World! 3 | 4 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/subst-cwd.in/file.txt: -------------------------------------------------------------------------------- 1 | Hello 2 | World! 3 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/symlink-target/file.txt: -------------------------------------------------------------------------------- 1 | Hello 2 | World! 3 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/symlink.stderr: -------------------------------------------------------------------------------- 1 | Hello 2 | World! 3 | 4 | -------------------------------------------------------------------------------- /examples/example-fixture.md: -------------------------------------------------------------------------------- 1 | ``` 2 | $ example-fixture 3 | ``` 4 | -------------------------------------------------------------------------------- /crates/snapbox/examples/snap-example-fixture.rs: -------------------------------------------------------------------------------- 1 | ../src/bin/snap-fixture.rs -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [resolver] 2 | incompatible-rust-versions = "fallback" 3 | -------------------------------------------------------------------------------- /crates/snapbox/tests/testsuite/main.rs: -------------------------------------------------------------------------------- 1 | automod::dir!("tests/testsuite"); 2 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/basic.trycmd: -------------------------------------------------------------------------------- 1 | ``` 2 | $ bin-fixture 3 | 4 | ``` 5 | -------------------------------------------------------------------------------- /crates/trycmd/tests/testsuite/main.rs: -------------------------------------------------------------------------------- 1 | automod::dir!("tests/testsuite"); 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # force LF on checkout to avoid rustfmt messing up the EOL 2 | * text eol=lf 3 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/large.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | 3 | [env.add] 4 | echo_large = "1" 5 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/stderr-to-stdout.stdout: -------------------------------------------------------------------------------- 1 | Hello 2 | World! 3 | 4 | Goodnight 5 | Moon! 6 | 7 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/infer-cwd.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | 3 | [env.add] 4 | cat = "file.txt" 5 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/symlink.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | 3 | [env.add] 4 | cat = "file.txt" 5 | -------------------------------------------------------------------------------- /committed.toml: -------------------------------------------------------------------------------- 1 | style="conventional" 2 | ignore_author_re="(dependabot|renovate)" 3 | merge_commit = false 4 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/code.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | status.code = 30 3 | 4 | [env.add] 5 | exit = "30" 6 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/failure.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | status = "failed" 3 | 4 | [env.add] 5 | exit = "1" 6 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/stderr.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | 3 | [env.add] 4 | stderr = """ 5 | Hello 6 | World! 7 | """ 8 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/stdout.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | 3 | [env.add] 4 | stdout = """ 5 | Hello 6 | World! 7 | """ 8 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/stdout.trycmd: -------------------------------------------------------------------------------- 1 | ``` 2 | $ stdout='Hello' stderr='Goodbye' bin-fixture 3 | Hello 4 | Goodbye 5 | 6 | ``` 7 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/unresolved.trycmd: -------------------------------------------------------------------------------- 1 | Gracefully handle a non-existent name: 2 | ``` 3 | $ non-existent-name 4 | 5 | ``` 6 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/subst-cwd.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | fs.cwd = "subst-cwd.in/foo" 3 | 4 | [env.add] 5 | echo_cwd = "1" 6 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/vars.trycmd: -------------------------------------------------------------------------------- 1 | ``` 2 | $ stdout='example' stderr='Goodbye' bin-fixture 3 | [EXAMPLE] 4 | Goodbye 5 | 6 | ``` 7 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/cwd-base.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | fs.cwd = "cwd-base.in/foo" 3 | 4 | [env.add] 5 | cat = "../file.txt" 6 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/diff_subset.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | fs.sandbox = true 3 | 4 | [env.add] 5 | stdout = "Hello\r\nWorld!\r\n" 6 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/ignored_bin.trycmd: -------------------------------------------------------------------------------- 1 | Ignored by test runner: 2 | ``` 3 | $ ignored-bin I have No Impact 4 | I'm ignored too 5 | 6 | ``` 7 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/stderr-to-stdout-large.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | stderr-to-stdout = true 3 | 4 | [env.add] 5 | echo_large = "1" 6 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/sandbox.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | fs.sandbox = true 3 | 4 | [env.add] 5 | write = "file.txt = Goodbye\nWorld" 6 | cat = "file.txt" 7 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/timeout.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | timeout = "1ms" 3 | # From timeout 4 | status = "interrupted" 5 | 6 | [env.add] 7 | sleep = "30" 8 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/stderr_toml.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | stderr = """ 3 | Hello 4 | World! 5 | 6 | """ 7 | 8 | [env.add] 9 | stderr = """ 10 | Hello 11 | World! 12 | """ 13 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/normalize.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | fs.sandbox = true 3 | 4 | [env.add] 5 | write = "file.txt = Goodbye\r\nWorld\r\n" 6 | cat = "file.txt" 7 | stdout = "Hello\r\nWorld!\r\n" 8 | -------------------------------------------------------------------------------- /examples/demo_trycmd/tests/trycmd.rs: -------------------------------------------------------------------------------- 1 | #[test] 2 | fn trycmd() { 3 | trycmd::TestCases::new() 4 | .case("README.md") 5 | .insert_var("[REPLACEMENT]", "runtime-value") 6 | .unwrap(); 7 | } 8 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/stderr-to-stdout.toml: -------------------------------------------------------------------------------- 1 | bin.name = "bin-fixture" 2 | stderr-to-stdout = true 3 | 4 | [env.add] 5 | stdout = """ 6 | Hello 7 | World! 8 | """ 9 | stderr = """ 10 | Goodnight 11 | Moon! 12 | """ 13 | -------------------------------------------------------------------------------- /crates/snapbox/src/report/mod.rs: -------------------------------------------------------------------------------- 1 | //! Utilities to report test results to users 2 | 3 | mod color; 4 | mod diff; 5 | 6 | pub use color::Palette; 7 | pub(crate) use color::Style; 8 | pub use color::Styled; 9 | pub use diff::write_diff; 10 | -------------------------------------------------------------------------------- /crates/trycmd/tests/example_tests.rs: -------------------------------------------------------------------------------- 1 | #[test] 2 | #[cfg(feature = "examples")] 3 | fn example_tests() { 4 | let t = trycmd::TestCases::new(); 5 | t.register_bins(trycmd::cargo::compile_examples([]).unwrap()); 6 | t.case("examples/*.md"); 7 | } 8 | -------------------------------------------------------------------------------- /crates/snapbox/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | mod lines; 2 | 3 | pub use lines::LinesWithTerminator; 4 | 5 | #[doc(inline)] 6 | pub use crate::cargo_rustc_current_dir; 7 | #[doc(inline)] 8 | pub use crate::current_dir; 9 | #[doc(inline)] 10 | pub use crate::current_rs; 11 | -------------------------------------------------------------------------------- /examples/demo_trycmd/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "demo_trycmd" 3 | version = "0.1.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | [workspace] 8 | 9 | [[bin]] 10 | name = "simple" 11 | path = "src/main.rs" 12 | 13 | [dependencies] 14 | trycmd = { path = "../.." } 15 | -------------------------------------------------------------------------------- /crates/trycmd/src/bin/trycmd-schema.rs: -------------------------------------------------------------------------------- 1 | use std::io::prelude::*; 2 | 3 | fn main() { 4 | let schema = schemars::schema_for!(trycmd::schema::OneShot); 5 | let schema = serde_json::to_string_pretty(&schema).unwrap(); 6 | std::io::stdout().write_all(schema.as_bytes()).unwrap(); 7 | } 8 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cmd/multistep.trycmd: -------------------------------------------------------------------------------- 1 | Basic test: 2 | ``` 3 | $ stdout='Hello' stderr='Goodbye' bin-fixture 4 | Hello 5 | Goodbye 6 | 7 | ``` 8 | 9 | Mess with the sandbox: 10 | ``` 11 | $ write='file.txt = Goodbye' bin-fixture 12 | 13 | $ cat='file.txt' bin-fixture 14 | Goodbye 15 | 16 | ``` 17 | -------------------------------------------------------------------------------- /crates/trycmd/tests/testsuite/schema.rs: -------------------------------------------------------------------------------- 1 | #[test] 2 | #[cfg(feature = "schema")] 3 | fn dump_schema() { 4 | let bin_path = snapbox::cmd::cargo_bin!("trycmd-schema"); 5 | snapbox::cmd::Command::new(bin_path) 6 | .assert() 7 | .success() 8 | .stdout_eq(snapbox::file!["../../schema.json"]); 9 | } 10 | -------------------------------------------------------------------------------- /examples/demo_trycmd/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | fn main() { 4 | let args: Vec = env::args().collect(); 5 | if args.len() == 2 { 6 | if env::var_os("GOODBYE").is_some() { 7 | println!("Goodbye {}!", args[1]); 8 | } else { 9 | println!("Hello {}!", args[1]); 10 | } 11 | } else { 12 | eprintln!("Must supply exactly one argument."); 13 | std::process::exit(1); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_install_hook_types: ["pre-commit", "commit-msg"] 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: check-yaml 7 | - id: check-json 8 | - id: check-toml 9 | - id: check-merge-conflict 10 | - id: check-case-conflict 11 | - id: detect-private-key 12 | - repo: https://github.com/crate-ci/typos 13 | rev: v1.32.0 14 | hooks: 15 | - id: typos 16 | - repo: https://github.com/crate-ci/committed 17 | rev: v1.1.7 18 | hooks: 19 | - id: committed 20 | -------------------------------------------------------------------------------- /.github/workflows/spelling.yml: -------------------------------------------------------------------------------- 1 | name: Spelling 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: [pull_request] 7 | 8 | env: 9 | RUST_BACKTRACE: 1 10 | CARGO_TERM_COLOR: always 11 | CLICOLOR: 1 12 | 13 | concurrency: 14 | group: "${{ github.workflow }}-${{ github.ref }}" 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | spelling: 19 | name: Spell Check with Typos 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout Actions Repository 23 | uses: actions/checkout@v6 24 | - name: Spell Check Repo 25 | uses: crate-ci/typos@master 26 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | permissions: {} # none 4 | 5 | on: 6 | pull_request: 7 | push: 8 | branches: [main] 9 | 10 | env: 11 | RUST_BACKTRACE: 1 12 | CARGO_TERM_COLOR: always 13 | CLICOLOR: 1 14 | 15 | concurrency: 16 | group: "${{ github.workflow }}-${{ github.ref }}" 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | pre-commit: 21 | permissions: 22 | contents: read 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v6 26 | - uses: actions/setup-python@v6 27 | with: 28 | python-version: '3.x' 29 | - uses: pre-commit/action@v3.0.1 30 | -------------------------------------------------------------------------------- /crates/snapbox/src/dir/mod.rs: -------------------------------------------------------------------------------- 1 | //! Initialize working directories and assert on how they've changed 2 | 3 | mod diff; 4 | mod fixture; 5 | mod ops; 6 | mod root; 7 | #[cfg(test)] 8 | mod tests; 9 | 10 | pub use diff::FileType; 11 | pub use diff::PathDiff; 12 | pub use fixture::DirFixture; 13 | #[cfg(feature = "dir")] 14 | pub use ops::copy_template; 15 | pub use ops::resolve_dir; 16 | pub use ops::strip_trailing_slash; 17 | #[cfg(feature = "dir")] 18 | pub use ops::Walk; 19 | pub use root::DirRoot; 20 | 21 | #[cfg(feature = "dir")] 22 | pub(crate) use ops::canonicalize; 23 | pub(crate) use ops::display_relpath; 24 | pub(crate) use ops::shallow_copy; 25 | -------------------------------------------------------------------------------- /.github/workflows/committed.yml: -------------------------------------------------------------------------------- 1 | # Not run as part of pre-commit checks because they don't handle sending the correct commit 2 | # range to `committed` 3 | name: Lint Commits 4 | on: [pull_request] 5 | 6 | permissions: 7 | contents: read 8 | 9 | env: 10 | RUST_BACKTRACE: 1 11 | CARGO_TERM_COLOR: always 12 | CLICOLOR: 1 13 | 14 | concurrency: 15 | group: "${{ github.workflow }}-${{ github.ref }}" 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | committed: 20 | name: Lint Commits 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout Actions Repository 24 | uses: actions/checkout@v6 25 | with: 26 | fetch-depth: 0 27 | - name: Lint Commits 28 | uses: crate-ci/committed@master 29 | -------------------------------------------------------------------------------- /crates/snapbox-macros/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "snapbox-macros" 3 | version = "0.4.0" 4 | description = "Snapshot testing toolbox" 5 | categories = ["development-tools::testing"] 6 | keywords = ["cli", "test", "assert", "command"] 7 | repository.workspace = true 8 | license.workspace = true 9 | edition.workspace = true 10 | rust-version.workspace = true 11 | include.workspace = true 12 | 13 | [package.metadata.docs.rs] 14 | all-features = true 15 | rustdoc-args = ["--generate-link-to-definition"] 16 | 17 | [features] 18 | default = [] 19 | color = ["dep:anstream"] 20 | 21 | ## Extra debugging information 22 | debug = [] 23 | 24 | [dependencies] 25 | anstream = { version = "0.6.20", optional = true } 26 | 27 | [lints] 28 | workspace = true 29 | -------------------------------------------------------------------------------- /.clippy.toml: -------------------------------------------------------------------------------- 1 | allow-print-in-tests = true 2 | allow-expect-in-tests = true 3 | allow-unwrap-in-tests = true 4 | allow-dbg-in-tests = true 5 | disallowed-methods = [ 6 | { path = "std::option::Option::map_or", reason = "prefer `map(..).unwrap_or(..)` for legibility" }, 7 | { path = "std::option::Option::map_or_else", reason = "prefer `map(..).unwrap_or_else(..)` for legibility" }, 8 | { path = "std::result::Result::map_or", reason = "prefer `map(..).unwrap_or(..)` for legibility" }, 9 | { path = "std::result::Result::map_or_else", reason = "prefer `map(..).unwrap_or_else(..)` for legibility" }, 10 | { path = "std::iter::Iterator::for_each", reason = "prefer `for` for side-effects" }, 11 | { path = "std::iter::Iterator::try_for_each", reason = "prefer `for` for side-effects" }, 12 | ] 13 | -------------------------------------------------------------------------------- /crates/snapbox/examples/diff.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | let mut args = std::env::args(); 3 | let _ = args.next().expect("expects `$ diff `"); 4 | let old_path = args.next().expect("expects `$ diff `"); 5 | let new_path = args.next().expect("expects `$ diff `"); 6 | if args.next().is_some() { 7 | panic!("expects `$ diff `"); 8 | } 9 | 10 | let old = snapbox::Data::text(std::fs::read_to_string(&old_path).unwrap()); 11 | let new = snapbox::Data::text(std::fs::read_to_string(&new_path).unwrap()); 12 | 13 | let mut output = String::new(); 14 | snapbox::report::write_diff( 15 | &mut output, 16 | &old, 17 | &new, 18 | Some(&old_path), 19 | Some(&new_path), 20 | snapbox::report::Palette::color(), 21 | ) 22 | .unwrap(); 23 | println!("{output}"); 24 | } 25 | -------------------------------------------------------------------------------- /crates/snapbox/src/utils/lines.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Debug)] 2 | pub struct LinesWithTerminator<'a> { 3 | data: &'a str, 4 | } 5 | 6 | impl<'a> LinesWithTerminator<'a> { 7 | pub fn new(data: &'a str) -> LinesWithTerminator<'a> { 8 | LinesWithTerminator { data } 9 | } 10 | } 11 | 12 | impl<'a> Iterator for LinesWithTerminator<'a> { 13 | type Item = &'a str; 14 | 15 | #[inline] 16 | fn next(&mut self) -> Option<&'a str> { 17 | match self.data.find('\n') { 18 | None if self.data.is_empty() => None, 19 | None => { 20 | let line = self.data; 21 | self.data = ""; 22 | Some(line) 23 | } 24 | Some(end) => { 25 | let line = &self.data[..end + 1]; 26 | self.data = &self.data[end + 1..]; 27 | Some(line) 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /crates/trycmd/tests/cli_tests.rs: -------------------------------------------------------------------------------- 1 | #[test] 2 | fn cli_tests() { 3 | let t = trycmd::TestCases::new(); 4 | t.case("tests/cmd/*.trycmd").case("tests/cmd/*.toml"); 5 | #[cfg(not(feature = "schema"))] 6 | { 7 | t.skip("tests/cmd/schema.toml"); 8 | } 9 | #[cfg(not(feature = "filesystem"))] 10 | { 11 | t.skip("tests/cmd/diff_subset.toml"); 12 | t.skip("tests/cmd/sandbox.toml"); 13 | t.skip("tests/cmd/multistep.trycmd"); 14 | t.skip("tests/cmd/normalize.toml"); 15 | t.skip("tests/cmd/symlink.toml"); 16 | t.skip("tests/cmd/subst-cwd.toml"); 17 | } 18 | #[cfg(target_os = "windows")] 19 | { 20 | // On windows, crashes are returned as code=1 21 | t.skip("tests/cmd/timeout.toml"); 22 | } 23 | t.extend_vars([("[EXAMPLE]", "example")]).unwrap(); 24 | t.register_bin("ignored-bin", trycmd::schema::Bin::Ignore); 25 | } 26 | -------------------------------------------------------------------------------- /crates/tryfn/README.md: -------------------------------------------------------------------------------- 1 | # tryfn 2 | 3 | > File-driven snapshot testing for a function 4 | 5 | [![Documentation](https://img.shields.io/badge/docs-master-blue.svg)][Documentation] 6 | ![License](https://img.shields.io/crates/l/tryfn.svg) 7 | [![Crates Status](https://img.shields.io/crates/v/tryfn.svg)][Crates.io] 8 | 9 | ## License 10 | 11 | Licensed under either of 12 | 13 | * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or ) 14 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or ) 15 | 16 | at your option. 17 | 18 | ### Contribution 19 | 20 | Unless you explicitly state otherwise, any contribution intentionally 21 | submitted for inclusion in the work by you, as defined in the Apache-2.0 22 | license, shall be dual-licensed as above, without any additional terms or 23 | conditions. 24 | 25 | [Crates.io]: https://crates.io/crates/tryfn 26 | [Documentation]: https://docs.rs/tryfn 27 | -------------------------------------------------------------------------------- /crates/snapbox/tests/testsuite/assert.rs: -------------------------------------------------------------------------------- 1 | use snapbox::assert_data_eq; 2 | use snapbox::data::IntoData; 3 | use snapbox::file; 4 | use snapbox::str; 5 | 6 | #[test] 7 | fn test_trivial_assert() { 8 | assert_data_eq!("5", str!["5"]); 9 | } 10 | 11 | #[test] 12 | fn smoke_test_indent() { 13 | assert_data_eq!( 14 | "\ 15 | line1 16 | line2 17 | ", 18 | str![[r#" 19 | line1 20 | line2 21 | 22 | "#]], 23 | ); 24 | } 25 | 26 | #[test] 27 | fn test_expect_file() { 28 | assert_data_eq!(include_str!("../../README.md"), file!["../../README.md"]); 29 | } 30 | 31 | #[test] 32 | #[cfg(feature = "json")] 33 | fn actual_expected_formats_differ() { 34 | assert_data_eq!( 35 | r#"{} 36 | {"order": 1} 37 | {"order": 2} 38 | {"order": 3} 39 | "#, 40 | str![[r#" 41 | [ 42 | {}, 43 | { 44 | "order": 1 45 | }, 46 | { 47 | "order": 2 48 | }, 49 | { 50 | "order": 3 51 | } 52 | ] 53 | "#]].is_json().against_jsonlines(), 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /crates/snapbox/tests/testsuite/cmd.rs: -------------------------------------------------------------------------------- 1 | #[test] 2 | fn regular_stdout_split() { 3 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("snap-fixture")) 4 | .env("echo_cwd", "1") 5 | .assert() 6 | .success(); 7 | } 8 | 9 | #[test] 10 | fn large_stdout_split() { 11 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("snap-fixture")) 12 | .env("echo_large", "1") 13 | .assert() 14 | .success(); 15 | } 16 | 17 | #[test] 18 | #[cfg(feature = "cmd")] 19 | fn regular_stdout_single() { 20 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("snap-fixture")) 21 | .env("echo_cwd", "1") 22 | .stderr_to_stdout() 23 | .assert() 24 | .success(); 25 | } 26 | 27 | #[test] 28 | #[cfg(feature = "cmd")] 29 | fn large_stdout_single() { 30 | snapbox::cmd::Command::new(snapbox::cmd::cargo_bin!("snap-fixture")) 31 | .env("echo_large", "1") 32 | .stderr_to_stdout() 33 | .assert() 34 | .success(); 35 | } 36 | -------------------------------------------------------------------------------- /crates/snapbox-macros/README.md: -------------------------------------------------------------------------------- 1 | # snapbox-macros 2 | 3 | > Implementation details for snapbox 4 | 5 | [![Documentation](https://img.shields.io/badge/docs-master-blue.svg)][Documentation] 6 | ![License](https://img.shields.io/crates/l/snapbox.svg) 7 | [![Crates Status](https://img.shields.io/crates/v/snapbox.svg)](https://crates.io/crates/snapbox) 8 | 9 | ## License 10 | 11 | Licensed under either of 12 | 13 | * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or ) 14 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or ) 15 | 16 | at your option. 17 | 18 | ### Contribution 19 | 20 | Unless you explicitly state otherwise, any contribution intentionally 21 | submitted for inclusion in the work by you, as defined in the Apache-2.0 22 | license, shall be dual-licensed as above, without any additional terms or 23 | conditions. 24 | 25 | [Crates.io]: https://crates.io/crates/snapbox 26 | [Documentation]: https://docs.rs/snapbox 27 | -------------------------------------------------------------------------------- /crates/snapbox-macros/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(docsrs, feature(doc_cfg))] 2 | #![warn(clippy::print_stderr)] 3 | #![warn(clippy::print_stdout)] 4 | 5 | #[cfg(feature = "color")] 6 | pub use anstream::eprint; 7 | #[cfg(feature = "color")] 8 | pub use anstream::eprintln; 9 | #[cfg(not(feature = "color"))] 10 | pub use std::eprint; 11 | #[cfg(not(feature = "color"))] 12 | pub use std::eprintln; 13 | 14 | /// Feature-flag controlled additional test debug information 15 | #[cfg(feature = "debug")] 16 | #[macro_export] 17 | macro_rules! debug { 18 | ($($arg:tt)*) => ({ 19 | #![allow(unexpected_cfgs)] // HACK: until we upgrade the minimum anstream 20 | $crate::eprint!("[{:>w$}] \t", module_path!(), w = 28); 21 | $crate::eprintln!($($arg)*); 22 | }) 23 | } 24 | 25 | /// Feature-flag controlled additional test debug information 26 | #[cfg(not(feature = "debug"))] 27 | #[macro_export] 28 | macro_rules! debug { 29 | ($($arg:tt)*) => {}; 30 | } 31 | 32 | #[doc = include_str!("../README.md")] 33 | #[cfg(doctest)] 34 | pub struct ReadmeDoctests; 35 | -------------------------------------------------------------------------------- /crates/snapbox/src/assert/action.rs: -------------------------------------------------------------------------------- 1 | pub const DEFAULT_ACTION_ENV: &str = "SNAPSHOTS"; 2 | 3 | /// Test action, see [`Assert`][crate::Assert] 4 | #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] 5 | pub enum Action { 6 | /// Do not run the test 7 | Skip, 8 | /// Ignore test failures 9 | Ignore, 10 | /// Fail on mismatch 11 | #[default] 12 | Verify, 13 | /// Overwrite on mismatch 14 | Overwrite, 15 | } 16 | 17 | impl Action { 18 | pub fn with_env_var(var: impl AsRef) -> Option { 19 | let var = var.as_ref(); 20 | let value = std::env::var_os(var)?; 21 | Self::with_env_value(value) 22 | } 23 | 24 | pub fn with_env_value(value: impl AsRef) -> Option { 25 | let value = value.as_ref(); 26 | match value.to_str()? { 27 | "skip" => Some(Action::Skip), 28 | "ignore" => Some(Action::Ignore), 29 | "verify" => Some(Action::Verify), 30 | "overwrite" => Some(Action::Overwrite), 31 | _ => None, 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) Individual contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /crates/snapbox/src/dir/tests.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn strips_trailing_slash() { 5 | let path = std::path::Path::new("/foo/bar/"); 6 | let rendered = path.display().to_string(); 7 | assert_eq!(rendered.as_bytes()[rendered.len() - 1], b'/'); 8 | 9 | let stripped = strip_trailing_slash(path); 10 | let rendered = stripped.display().to_string(); 11 | assert_eq!(rendered.as_bytes()[rendered.len() - 1], b'r'); 12 | } 13 | 14 | #[test] 15 | fn file_type_detect_file() { 16 | let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); 17 | dbg!(&path); 18 | let actual = FileType::from_path(&path); 19 | assert_eq!(actual, FileType::File); 20 | } 21 | 22 | #[test] 23 | fn file_type_detect_dir() { 24 | let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); 25 | dbg!(path); 26 | let actual = FileType::from_path(path); 27 | assert_eq!(actual, FileType::Dir); 28 | } 29 | 30 | #[test] 31 | fn file_type_detect_missing() { 32 | let path = std::path::Path::new("this-should-never-exist"); 33 | dbg!(path); 34 | let actual = FileType::from_path(path); 35 | assert_eq!(actual, FileType::Missing); 36 | } 37 | -------------------------------------------------------------------------------- /crates/tryfn/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](https://semver.org/). 6 | 7 | 8 | ## [Unreleased] - ReleaseDate 9 | 10 | ### Compatibility 11 | 12 | - Bump MSRV to 1.66 13 | 14 | ## [0.2.3] - 2024-07-25 15 | 16 | ## [0.2.2] - 2024-07-23 17 | 18 | ## [0.2.1] - 2024-05-23 19 | 20 | ## [0.2.0] - 2024-05-17 21 | 22 | ### Breaking Changes 23 | 24 | - Removed deprecated functions 25 | - Change `Case::expected` from `PathBuf` to `Data` 26 | 27 | ### Features 28 | 29 | - Allow customizing type, filters on `Case::expected` 30 | - Generalize `setup`, `test` with traits 31 | 32 | ## [0.1.0] - 2024-05-17 33 | 34 | 35 | [Unreleased]: https://github.com/assert-rs/trycmd/compare/tryfn-v0.2.3...HEAD 36 | [0.2.3]: https://github.com/assert-rs/trycmd/compare/tryfn-v0.2.2...tryfn-v0.2.3 37 | [0.2.2]: https://github.com/assert-rs/trycmd/compare/tryfn-v0.2.1...tryfn-v0.2.2 38 | [0.2.1]: https://github.com/assert-rs/trycmd/compare/tryfn-v0.2.0...tryfn-v0.2.1 39 | [0.2.0]: https://github.com/assert-rs/trycmd/compare/tryfn-v0.1.0...tryfn-v0.2.0 40 | [0.1.0]: https://github.com/assert-rs/trycmd/compare/3e293f6f6167270d85f57a7b59fd94590af6fa97...tryfn-v0.1.0 41 | -------------------------------------------------------------------------------- /crates/snapbox/README.md: -------------------------------------------------------------------------------- 1 | # snapbox 2 | 3 | > When you have to treat your tests like pets, instead of [cattle][trycmd] 4 | 5 | [![Documentation](https://img.shields.io/badge/docs-master-blue.svg)][Documentation] 6 | ![License](https://img.shields.io/crates/l/snapbox.svg) 7 | [![Crates Status](https://img.shields.io/crates/v/snapbox.svg)](https://crates.io/crates/snapbox) 8 | 9 | `snapbox` is a snapshot-testing toolbox that is ready to use for verifying output from 10 | - Function return values 11 | - CLI stdout/stderr 12 | - Filesystem changes 13 | 14 | It is also flexible enough to build your own test harness like [trycmd]. 15 | 16 | See the [docs](http://docs.rs/snapbox) for more. 17 | 18 | ## License 19 | 20 | Licensed under either of 21 | 22 | * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or ) 23 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or ) 24 | 25 | at your option. 26 | 27 | ### Contribution 28 | 29 | Unless you explicitly state otherwise, any contribution intentionally 30 | submitted for inclusion in the work by you, as defined in the Apache-2.0 31 | license, shall be dual-licensed as above, without any additional terms or 32 | conditions. 33 | 34 | [Crates.io]: https://crates.io/crates/snapbox 35 | [Documentation]: https://docs.rs/snapbox 36 | [trycmd]: https://crates.io/crates/trycmd 37 | -------------------------------------------------------------------------------- /examples/demo_trycmd/README.md: -------------------------------------------------------------------------------- 1 | # Trycmd demo 2 | 3 | This crate demos the `.trycmd` file format. In fact, the file you're reading right now is a trycmd 4 | test, registered in `tests/trycmd.rs`. 5 | 6 | Let's test our simple hello world binary (found in `src/main.rs`): 7 | 8 | ```console 9 | $ simple World 10 | Hello World! 11 | 12 | $ simple Ferris 13 | Hello Ferris! 14 | 15 | ``` 16 | 17 | The format looks for code blocks with the `console` or `trycmd` language: 18 | 19 | ~~~md 20 | ```console 21 | $ command ... 22 | ``` 23 | ~~~ 24 | 25 | You can also test for command failures and pass in environment variables: 26 | 27 | ```console 28 | $ simple 29 | ? 1 30 | Must supply exactly one argument. 31 | 32 | $ GOODBYE=true simple World 33 | Goodbye World! 34 | 35 | ``` 36 | 37 | Sometimes, your test might include output that is generated at runtime. When that's the case, you 38 | can 39 | use variables to replace those values. In our `tests/trycmd.rs`, we've defined a 40 | variable `[REPLACEMENT]` such that whenever the value `runtime-value` appears, it will be 41 | replaced with `[REPLACEMENT]`: 42 | 43 | ```console 44 | $ simple "blah blah runtime-value blah" 45 | Hello blah blah [REPLACEMENT] blah! 46 | 47 | $ simple "blah blah runtime-value blah" 48 | Hello blah blah runtime-value blah! 49 | 50 | ``` 51 | 52 | Note that the tests can still contain `runtime-value`: using `[REPLACEMENT]` is purely for 53 | your convenience. 54 | -------------------------------------------------------------------------------- /crates/tryfn/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tryfn" 3 | version = "0.2.3" 4 | description = "File-driven snapshot testing for a function" 5 | categories = ["development-tools::testing"] 6 | keywords = ["test", "assert", "snapsjot"] 7 | repository.workspace = true 8 | license.workspace = true 9 | edition.workspace = true 10 | rust-version.workspace = true 11 | include.workspace = true 12 | 13 | [package.metadata.docs.rs] 14 | all-features = true 15 | rustdoc-args = ["--generate-link-to-definition"] 16 | 17 | [package.metadata.release] 18 | pre-release-replacements = [ 19 | {file="CHANGELOG.md", search="Unreleased", replace="{{version}}", min=1}, 20 | {file="CHANGELOG.md", search="\\.\\.\\.HEAD", replace="...{{tag_name}}", exactly=1}, 21 | {file="CHANGELOG.md", search="ReleaseDate", replace="{{date}}", min=1}, 22 | {file="CHANGELOG.md", search="", replace="\n## [Unreleased] - ReleaseDate\n", exactly=1}, 23 | {file="CHANGELOG.md", search="", replace="\n[Unreleased]: https://github.com/assert-rs/trycmd/compare/{{tag_name}}...HEAD", exactly=1}, 24 | ] 25 | 26 | [features] 27 | default = ["color-auto", "diff"] 28 | diff = ["snapbox/diff"] 29 | color = ["snapbox/color"] 30 | color-auto = ["snapbox/color-auto"] 31 | 32 | [dependencies] 33 | snapbox = { path = "../snapbox", version = "0.6.23", default-features = false } 34 | libtest-mimic = "0.7.0" 35 | ignore = "0.4.20" 36 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | pull_request: 8 | paths: 9 | - '**/Cargo.toml' 10 | - '**/Cargo.lock' 11 | push: 12 | branches: 13 | - main 14 | 15 | env: 16 | RUST_BACKTRACE: 1 17 | CARGO_TERM_COLOR: always 18 | CLICOLOR: 1 19 | 20 | concurrency: 21 | group: "${{ github.workflow }}-${{ github.ref }}" 22 | cancel-in-progress: true 23 | 24 | jobs: 25 | security_audit: 26 | permissions: 27 | issues: write # to create issues (actions-rs/audit-check) 28 | checks: write # to create check (actions-rs/audit-check) 29 | runs-on: ubuntu-latest 30 | # Prevent sudden announcement of a new advisory from failing ci: 31 | continue-on-error: true 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@v6 35 | - uses: actions-rs/audit-check@v1 36 | with: 37 | token: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | cargo_deny: 40 | permissions: 41 | issues: write # to create issues (actions-rs/audit-check) 42 | checks: write # to create check (actions-rs/audit-check) 43 | runs-on: ubuntu-latest 44 | strategy: 45 | matrix: 46 | checks: 47 | - bans licenses sources 48 | steps: 49 | - uses: actions/checkout@v6 50 | - uses: EmbarkStudios/cargo-deny-action@v2 51 | with: 52 | command: check ${{ matrix.checks }} 53 | rust-version: stable 54 | -------------------------------------------------------------------------------- /crates/snapbox/src/bin/snap-fixture.rs: -------------------------------------------------------------------------------- 1 | //! For `snapbox`s tests only 2 | 3 | use std::env; 4 | use std::error::Error; 5 | use std::io; 6 | use std::io::Write; 7 | use std::process; 8 | 9 | fn run() -> Result<(), Box> { 10 | if let Ok(text) = env::var("stdout") { 11 | println!("{text}"); 12 | } 13 | if let Ok(text) = env::var("stderr") { 14 | eprintln!("{text}"); 15 | } 16 | 17 | if env::var("echo_large").as_deref() == Ok("1") { 18 | for i in 0..(128 * 1024) { 19 | println!("{i}"); 20 | } 21 | } 22 | 23 | if env::var("echo_cwd").as_deref() == Ok("1") { 24 | if let Ok(cwd) = env::current_dir() { 25 | eprintln!("{}", cwd.display()); 26 | } 27 | } 28 | 29 | if let Ok(raw) = env::var("write") { 30 | let (path, text) = raw.split_once('=').unwrap_or((raw.as_str(), "")); 31 | std::fs::write(path.trim(), text.trim()).unwrap(); 32 | } 33 | 34 | if let Ok(path) = env::var("cat") { 35 | let text = std::fs::read_to_string(path).unwrap(); 36 | eprintln!("{text}"); 37 | } 38 | 39 | if let Some(timeout) = env::var("sleep").ok().and_then(|s| s.parse().ok()) { 40 | std::thread::sleep(std::time::Duration::from_secs(timeout)); 41 | } 42 | 43 | let code = env::var("exit") 44 | .ok() 45 | .map(|v| v.parse::()) 46 | .map(|r| r.map(Some)) 47 | .unwrap_or(Ok(None))? 48 | .unwrap_or(0); 49 | process::exit(code); 50 | } 51 | 52 | fn main() { 53 | let code = match run() { 54 | Ok(_) => 0, 55 | Err(ref e) => { 56 | write!(&mut io::stderr(), "{e}").expect("writing to stderr won't fail"); 57 | 1 58 | } 59 | }; 60 | process::exit(code); 61 | } 62 | -------------------------------------------------------------------------------- /crates/trycmd/src/cargo.rs: -------------------------------------------------------------------------------- 1 | //! Interact with `cargo` 2 | 3 | #[doc(inline)] 4 | pub use snapbox::cmd::cargo_bin; 5 | 6 | /// Prepare an example for testing 7 | /// 8 | /// Unlike `cargo_bin!`, this does not inherit all of the current compiler settings. It 9 | /// will match the current target and profile but will not get feature flags. Pass those arguments 10 | /// to the compiler via `args`. 11 | /// 12 | /// ## Example 13 | /// 14 | /// ```rust,no_run 15 | /// #[test] 16 | /// fn cli_tests() { 17 | /// trycmd::TestCases::new() 18 | /// .register_bin("example-fixture", trycmd::cargo::compile_example("example-fixture", [])) 19 | /// .case("examples/cmd/*.trycmd"); 20 | /// } 21 | /// ``` 22 | #[cfg(feature = "examples")] 23 | pub fn compile_example<'a>( 24 | target_name: &str, 25 | args: impl IntoIterator, 26 | ) -> crate::schema::Bin { 27 | snapbox::cmd::compile_example(target_name, args).into() 28 | } 29 | 30 | /// Prepare all examples for testing 31 | /// 32 | /// Unlike `cargo_bin!`, this does not inherit all of the current compiler settings. It 33 | /// will match the current target and profile but will not get feature flags. Pass those arguments 34 | /// to the compiler via `args`. 35 | /// 36 | /// ## Example 37 | /// 38 | /// ```rust,no_run 39 | /// #[test] 40 | /// fn cli_tests() { 41 | /// trycmd::TestCases::new() 42 | /// .register_bins(trycmd::cargo::compile_examples([]).unwrap()) 43 | /// .case("examples/cmd/*.trycmd"); 44 | /// } 45 | /// ``` 46 | #[cfg(feature = "examples")] 47 | pub fn compile_examples<'a>( 48 | args: impl IntoIterator, 49 | ) -> Result, crate::Error> { 50 | snapbox::cmd::compile_examples(args).map(|i| i.map(|(name, path)| (name, path.into()))) 51 | } 52 | -------------------------------------------------------------------------------- /crates/trycmd/src/bin/bin-fixture.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::error::Error; 3 | use std::io; 4 | use std::io::Write; 5 | use std::process; 6 | 7 | fn run() -> Result<(), Box> { 8 | if let Ok(text) = env::var("stdout") { 9 | println!("{text}"); 10 | } 11 | if let Ok(text) = env::var("stderr") { 12 | eprintln!("{text}"); 13 | } 14 | 15 | if env::var("echo_large").as_deref() == Ok("1") { 16 | for i in 0..(128 * 1024) { 17 | println!("{i}"); 18 | } 19 | } 20 | 21 | if env::var("echo_cwd").as_deref() == Ok("1") { 22 | if let Ok(cwd) = env::current_dir() { 23 | eprintln!("{}", cwd.display()); 24 | } 25 | } 26 | 27 | if let Ok(raw) = env::var("write") { 28 | let (path, text) = raw.split_once('=').unwrap_or((raw.as_str(), "")); 29 | std::fs::write(path.trim(), text.trim()).unwrap(); 30 | } 31 | 32 | if let Ok(path) = env::var("cat") { 33 | let text = std::fs::read_to_string(path).unwrap(); 34 | eprintln!("{text}"); 35 | } 36 | 37 | if let Some(timeout) = env::var("sleep").ok().and_then(|s| s.parse().ok()) { 38 | std::thread::sleep(std::time::Duration::from_secs(timeout)); 39 | } 40 | 41 | let exit = env::var("exit").ok(); 42 | if exit.as_deref() == Some("panic") { 43 | panic!("Panic requested"); 44 | } 45 | 46 | let code = exit 47 | .map(|v| v.parse::()) 48 | .map(|r| r.map(Some)) 49 | .unwrap_or(Ok(None))? 50 | .unwrap_or(0); 51 | process::exit(code); 52 | } 53 | 54 | fn main() { 55 | let code = match run() { 56 | Ok(_) => 0, 57 | Err(ref e) => { 58 | write!(&mut io::stderr(), "{e}").expect("writing to stderr won't fail"); 59 | 1 60 | } 61 | }; 62 | process::exit(code); 63 | } 64 | -------------------------------------------------------------------------------- /.github/workflows/rust-next.yml: -------------------------------------------------------------------------------- 1 | name: rust-next 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | schedule: 8 | - cron: '20 20 20 * *' 9 | 10 | env: 11 | RUST_BACKTRACE: 1 12 | CARGO_TERM_COLOR: always 13 | CLICOLOR: 1 14 | 15 | concurrency: 16 | group: "${{ github.workflow }}-${{ github.ref }}" 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | test: 21 | name: Test 22 | strategy: 23 | matrix: 24 | os: ["ubuntu-latest", "windows-latest", "macos-latest"] 25 | rust: ["stable", "beta"] 26 | include: 27 | - os: ubuntu-latest 28 | rust: "nightly" 29 | continue-on-error: ${{ matrix.rust != 'stable' }} 30 | runs-on: ${{ matrix.os }} 31 | env: 32 | # Reduce amount of data cached 33 | CARGO_PROFILE_DEV_DEBUG: line-tables-only 34 | steps: 35 | - name: Checkout repository 36 | uses: actions/checkout@v6 37 | - name: Install Rust 38 | uses: dtolnay/rust-toolchain@stable 39 | with: 40 | toolchain: ${{ matrix.rust }} 41 | - uses: Swatinem/rust-cache@v2 42 | - uses: taiki-e/install-action@cargo-hack 43 | - name: Build 44 | run: cargo test --workspace --no-run 45 | - name: Test 46 | run: cargo hack test --each-feature --workspace 47 | latest: 48 | name: "Check latest dependencies" 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: Checkout repository 52 | uses: actions/checkout@v6 53 | - name: Install Rust 54 | uses: dtolnay/rust-toolchain@stable 55 | with: 56 | toolchain: stable 57 | - uses: Swatinem/rust-cache@v2 58 | - uses: taiki-e/install-action@cargo-hack 59 | - name: Update dependencies 60 | run: cargo update 61 | - name: Build 62 | run: cargo test --workspace --no-run 63 | - name: Test 64 | run: cargo hack test --each-feature --workspace 65 | -------------------------------------------------------------------------------- /crates/trycmd/src/registry.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Debug)] 2 | pub(crate) struct BinRegistry { 3 | bins: std::collections::BTreeMap, 4 | fallback: bool, 5 | } 6 | 7 | impl BinRegistry { 8 | pub(crate) fn new() -> Self { 9 | Self { 10 | bins: Default::default(), 11 | fallback: true, 12 | } 13 | } 14 | 15 | pub(crate) fn register_bin(&mut self, name: String, bin: crate::schema::Bin) { 16 | self.bins.insert(name, bin); 17 | } 18 | 19 | pub(crate) fn register_bins( 20 | &mut self, 21 | bins: impl Iterator, 22 | ) { 23 | self.bins.extend(bins); 24 | } 25 | 26 | pub(crate) fn resolve_bin( 27 | &self, 28 | bin: crate::schema::Bin, 29 | ) -> Result { 30 | match bin { 31 | crate::schema::Bin::Path(path) => { 32 | let bin = crate::schema::Bin::Path(path); 33 | Ok(bin) 34 | } 35 | crate::schema::Bin::Name(name) => { 36 | let bin = self.resolve_name(&name); 37 | Ok(bin) 38 | } 39 | crate::schema::Bin::Ignore => Ok(crate::schema::Bin::Ignore), 40 | crate::schema::Bin::Error(err) => Err(err), 41 | } 42 | } 43 | 44 | pub(crate) fn resolve_name(&self, name: &str) -> crate::schema::Bin { 45 | if let Some(path) = self.bins.get(name) { 46 | return path.clone(); 47 | } 48 | 49 | if self.fallback { 50 | let path = crate::cargo::cargo_bin(name); 51 | if path.exists() { 52 | return crate::schema::Bin::Path(path); 53 | } 54 | } 55 | 56 | crate::schema::Bin::Name(name.to_owned()) 57 | } 58 | } 59 | 60 | impl Default for BinRegistry { 61 | fn default() -> Self { 62 | Self::new() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | # These settings are synced to GitHub by https://probot.github.io/apps/settings/ 2 | 3 | repository: 4 | description: "Snapshot testing for a herd of CLI tests" 5 | homepage: "docs.rs/trycmd" 6 | topics: "rust cli test" 7 | has_issues: true 8 | has_projects: false 9 | has_wiki: false 10 | has_downloads: true 11 | default_branch: main 12 | 13 | # Preference: people do clean commits 14 | allow_merge_commit: true 15 | # Backup in case we need to clean up commits 16 | allow_squash_merge: true 17 | # Not really needed 18 | allow_rebase_merge: false 19 | 20 | allow_auto_merge: true 21 | delete_branch_on_merge: true 22 | 23 | squash_merge_commit_title: "PR_TITLE" 24 | squash_merge_commit_message: "PR_BODY" 25 | merge_commit_message: "PR_BODY" 26 | 27 | labels: 28 | - name: "A-snapbox" 29 | description: "Area: snapbox package" 30 | color: '#f7e101' 31 | - name: "A-trycmd" 32 | description: "Area: trycmd package" 33 | color: '#f7e101' 34 | # Type 35 | - name: bug 36 | color: '#b60205' 37 | description: "Not as expected" 38 | - name: enhancement 39 | color: '#1d76db' 40 | description: "Improve the expected" 41 | # Flavor 42 | - name: question 43 | color: "#cc317c" 44 | description: "Uncertainty is involved" 45 | - name: breaking-change 46 | color: "#e99695" 47 | - name: good first issue 48 | color: '#c2e0c6' 49 | description: "Help wanted!" 50 | 51 | # This serves more as documentation. 52 | # Branch protection API was replaced by rulesets but settings isn't updated. 53 | # See https://github.com/repository-settings/app/issues/825 54 | # 55 | # branches: 56 | # - name: main 57 | # protection: 58 | # required_pull_request_reviews: null 59 | # required_conversation_resolution: true 60 | # required_status_checks: 61 | # # Required. Require branches to be up to date before merging. 62 | # strict: false 63 | # contexts: ["CI", "Spell Check with Typos"] 64 | # enforce_admins: false 65 | # restrictions: null 66 | -------------------------------------------------------------------------------- /crates/snapbox/src/data/filters.rs: -------------------------------------------------------------------------------- 1 | use crate::data::DataFormat; 2 | 3 | #[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] 4 | pub(crate) struct FilterSet { 5 | flags: usize, 6 | against: Option, 7 | } 8 | 9 | impl FilterSet { 10 | pub(crate) fn new() -> Self { 11 | Self::empty().redactions().newlines().paths() 12 | } 13 | 14 | pub(crate) const fn empty() -> Self { 15 | Self { 16 | flags: 0, 17 | against: None, 18 | } 19 | } 20 | 21 | pub(crate) fn redactions(mut self) -> Self { 22 | self.set(Self::REDACTIONS); 23 | self 24 | } 25 | 26 | pub(crate) fn newlines(mut self) -> Self { 27 | self.set(Self::NEWLINES); 28 | self 29 | } 30 | 31 | pub(crate) fn paths(mut self) -> Self { 32 | self.set(Self::PATHS); 33 | self 34 | } 35 | 36 | pub(crate) fn unordered(mut self) -> Self { 37 | self.set(Self::UNORDERED); 38 | self 39 | } 40 | 41 | pub(crate) fn against(mut self, format: DataFormat) -> Self { 42 | self.against = Some(format); 43 | self 44 | } 45 | 46 | pub(crate) const fn is_redaction_set(&self) -> bool { 47 | self.is_set(Self::REDACTIONS) 48 | } 49 | 50 | pub(crate) const fn is_newlines_set(&self) -> bool { 51 | self.is_set(Self::NEWLINES) 52 | } 53 | 54 | pub(crate) const fn is_paths_set(&self) -> bool { 55 | self.is_set(Self::PATHS) 56 | } 57 | 58 | pub(crate) const fn is_unordered_set(&self) -> bool { 59 | self.is_set(Self::UNORDERED) 60 | } 61 | 62 | pub(crate) const fn get_against(&self) -> Option { 63 | self.against 64 | } 65 | } 66 | 67 | impl FilterSet { 68 | const REDACTIONS: usize = 1 << 0; 69 | const NEWLINES: usize = 1 << 1; 70 | const PATHS: usize = 1 << 2; 71 | const UNORDERED: usize = 1 << 3; 72 | 73 | fn set(&mut self, flag: usize) -> &mut Self { 74 | self.flags |= flag; 75 | self 76 | } 77 | 78 | const fn is_set(&self, flag: usize) -> bool { 79 | self.flags & flag != 0 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /crates/trycmd/README.md: -------------------------------------------------------------------------------- 1 | # trycmd 2 | 3 | > Treat your tests like cattle, instead of [pets](https://docs.rs/snapbox) 4 | 5 | [![Documentation](https://img.shields.io/badge/docs-master-blue.svg)][Documentation] 6 | ![License](https://img.shields.io/crates/l/trycmd.svg) 7 | [![Crates Status](https://img.shields.io/crates/v/trycmd.svg)][Crates.io] 8 | 9 | `trycmd` is a test harness that will enumerate test case files and run them to verify the 10 | results, taking inspiration from 11 | [trybuild](https://crates.io/crates/trybuild) and [cram](https://bitheap.org/cram/). 12 | 13 | ## Example 14 | 15 | To create a minimal setup, create a `tests/cli_tests.rs` with 16 | ```rust,no_run 17 | #[test] 18 | fn cli_tests() { 19 | trycmd::TestCases::new() 20 | .case("tests/cmd/*.toml") 21 | .case("README.md"); 22 | } 23 | ``` 24 | and write out your test cases in `.toml` files along with examples in your `README.md`. 25 | 26 | Run this with `cargo test` like normal. `TestCases` will enumerate all test case files and 27 | run the contained commands, verifying they run as expected. 28 | 29 | See the [docs](http://docs.rs/trycmd) for more. 30 | 31 | ## Users 32 | 33 | - [typos](https://github.com/crate-ci/typos) (source code spell checker) 34 | - See [port from `assert_cmd`](https://github.com/crate-ci/typos/compare/a8ae8a5..cdfdc4084c928423211c6a80acbd24dbed7108f6) 35 | - [cargo-edit](https://github.com/killercup/cargo-edit) (`Cargo.toml` editor) 36 | - [clap](https://github.com/clap-rs/clap/) (CLI parser) to test examples 37 | 38 | ## License 39 | 40 | Licensed under either of 41 | 42 | * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or ) 43 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or ) 44 | 45 | at your option. 46 | 47 | ### Contribution 48 | 49 | Unless you explicitly state otherwise, any contribution intentionally 50 | submitted for inclusion in the work by you, as defined in the Apache-2.0 51 | license, shall be dual-licensed as above, without any additional terms or 52 | conditions. 53 | 54 | [Crates.io]: https://crates.io/crates/trycmd 55 | [Documentation]: https://docs.rs/trycmd 56 | -------------------------------------------------------------------------------- /crates/trycmd/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "trycmd" 3 | version = "0.15.11" 4 | description = "Snapshot testing for a herd of CLI tests" 5 | categories = ["development-tools::testing"] 6 | keywords = ["cli", "test", "assert", "command", "duct"] 7 | repository.workspace = true 8 | license.workspace = true 9 | edition.workspace = true 10 | rust-version.workspace = true 11 | include.workspace = true 12 | 13 | [package.metadata.docs.rs] 14 | all-features = true 15 | rustdoc-args = ["--generate-link-to-definition"] 16 | 17 | [package.metadata.release] 18 | pre-release-replacements = [ 19 | {file="CHANGELOG.md", search="Unreleased", replace="{{version}}", min=1}, 20 | {file="CHANGELOG.md", search="\\.\\.\\.HEAD", replace="...{{tag_name}}", exactly=1}, 21 | {file="CHANGELOG.md", search="ReleaseDate", replace="{{date}}", min=1}, 22 | {file="CHANGELOG.md", search="", replace="\n## [Unreleased] - ReleaseDate\n", exactly=1}, 23 | {file="CHANGELOG.md", search="", replace="\n[Unreleased]: https://github.com/assert-rs/trycmd/compare/{{tag_name}}...HEAD", exactly=1}, 24 | ] 25 | 26 | [features] 27 | default = ["color-auto", "filesystem", "diff"] 28 | color = ["snapbox/color", "dep:anstream"] 29 | color-auto = ["color", "snapbox/color-auto"] 30 | diff = ["snapbox/diff"] 31 | filesystem = ["snapbox/dir"] 32 | 33 | schema = ["dep:schemars", "dep:serde_json"] 34 | examples = ["snapbox/examples"] 35 | debug = ["snapbox/debug"] 36 | 37 | [[bin]] 38 | name = "bin-fixture" 39 | 40 | [[bin]] 41 | name = "trycmd-schema" 42 | required-features = ["schema"] 43 | 44 | [dependencies] 45 | automod = "1.0.15" 46 | snapbox = { path = "../snapbox", version = "0.6.23", default-features = false, features = ["cmd"] } 47 | anstream = { version = "0.6.20", optional = true } 48 | 49 | glob = "0.3.3" 50 | rayon = "1.10.0" 51 | 52 | serde = { version = "1.0", features = ["derive"] } 53 | shlex = "1.3.0" 54 | humantime = "2" 55 | humantime-serde = "1" 56 | toml_edit = { version = "0.23.5", features = ["serde"] } 57 | escargot = { version = "0.5.14", optional = true } 58 | 59 | schemars = { version = "0.8.22", features = ["preserve_order"], optional = true } 60 | serde_json = { version = "1.0", optional = true } 61 | 62 | [lints] 63 | workspace = true 64 | -------------------------------------------------------------------------------- /crates/snapbox/src/report/color.rs: -------------------------------------------------------------------------------- 1 | #[derive(Copy, Clone, Debug, Default)] 2 | pub struct Palette { 3 | pub(crate) info: Style, 4 | pub(crate) warn: Style, 5 | pub(crate) error: Style, 6 | pub(crate) hint: Style, 7 | pub(crate) expected: Style, 8 | pub(crate) actual: Style, 9 | } 10 | 11 | impl Palette { 12 | pub fn color() -> Self { 13 | if cfg!(feature = "color") { 14 | Self { 15 | info: anstyle::AnsiColor::Green.on_default(), 16 | warn: anstyle::AnsiColor::Yellow.on_default(), 17 | error: anstyle::AnsiColor::Red.on_default(), 18 | hint: anstyle::Effects::DIMMED.into(), 19 | expected: anstyle::AnsiColor::Red.on_default() | anstyle::Effects::UNDERLINE, 20 | actual: anstyle::AnsiColor::Green.on_default() | anstyle::Effects::UNDERLINE, 21 | } 22 | } else { 23 | Self::plain() 24 | } 25 | } 26 | 27 | pub fn plain() -> Self { 28 | Self::default() 29 | } 30 | 31 | pub fn info(self, item: D) -> Styled { 32 | Styled::new(item, self.info) 33 | } 34 | 35 | pub fn warn(self, item: D) -> Styled { 36 | Styled::new(item, self.warn) 37 | } 38 | 39 | pub fn error(self, item: D) -> Styled { 40 | Styled::new(item, self.error) 41 | } 42 | 43 | pub fn hint(self, item: D) -> Styled { 44 | Styled::new(item, self.hint) 45 | } 46 | 47 | pub fn expected(self, item: D) -> Styled { 48 | Styled::new(item, self.expected) 49 | } 50 | 51 | pub fn actual(self, item: D) -> Styled { 52 | Styled::new(item, self.actual) 53 | } 54 | } 55 | 56 | pub(crate) use anstyle::Style; 57 | 58 | #[derive(Debug)] 59 | pub struct Styled { 60 | display: D, 61 | style: Style, 62 | } 63 | 64 | impl Styled { 65 | pub(crate) fn new(display: D, style: Style) -> Self { 66 | Self { display, style } 67 | } 68 | } 69 | 70 | impl std::fmt::Display for Styled { 71 | #[inline] 72 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 73 | write!(f, "{}", self.style.render())?; 74 | self.display.fmt(f)?; 75 | write!(f, "{}", self.style.render_reset())?; 76 | Ok(()) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /crates/snapbox/src/assert/error.rs: -------------------------------------------------------------------------------- 1 | pub type Result = std::result::Result; 2 | 3 | #[derive(Clone, Debug)] 4 | pub struct Error { 5 | inner: String, 6 | backtrace: Option, 7 | } 8 | 9 | impl Error { 10 | pub fn new(inner: impl std::fmt::Display) -> Self { 11 | Self::with_string(inner.to_string()) 12 | } 13 | 14 | fn with_string(inner: String) -> Self { 15 | Self { 16 | inner, 17 | backtrace: Backtrace::new(), 18 | } 19 | } 20 | 21 | #[track_caller] 22 | pub(crate) fn panic(self) -> ! { 23 | panic!("{self}") 24 | } 25 | } 26 | 27 | impl PartialEq for Error { 28 | fn eq(&self, other: &Self) -> bool { 29 | self.inner == other.inner 30 | } 31 | } 32 | 33 | impl Eq for Error {} 34 | 35 | impl std::fmt::Display for Error { 36 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 37 | writeln!(f, "{}", self.inner)?; 38 | if let Some(backtrace) = self.backtrace.as_ref() { 39 | writeln!(f)?; 40 | writeln!(f, "Backtrace:")?; 41 | writeln!(f, "{backtrace}")?; 42 | } 43 | Ok(()) 44 | } 45 | } 46 | 47 | impl std::error::Error for Error {} 48 | 49 | impl<'s> From<&'s str> for Error { 50 | fn from(other: &'s str) -> Self { 51 | Self::with_string(other.to_owned()) 52 | } 53 | } 54 | 55 | impl<'s> From<&'s String> for Error { 56 | fn from(other: &'s String) -> Self { 57 | Self::with_string(other.clone()) 58 | } 59 | } 60 | 61 | impl From for Error { 62 | fn from(other: String) -> Self { 63 | Self::with_string(other) 64 | } 65 | } 66 | 67 | #[cfg(feature = "debug")] 68 | #[derive(Debug, Clone)] 69 | struct Backtrace(backtrace::Backtrace); 70 | 71 | #[cfg(feature = "debug")] 72 | impl Backtrace { 73 | fn new() -> Option { 74 | Some(Self(backtrace::Backtrace::new())) 75 | } 76 | } 77 | 78 | #[cfg(feature = "debug")] 79 | impl std::fmt::Display for Backtrace { 80 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 81 | // `backtrace::Backtrace` uses `Debug` instead of `Display` 82 | write!(f, "{:?}", self.0) 83 | } 84 | } 85 | 86 | #[cfg(not(feature = "debug"))] 87 | #[derive(Debug, Copy, Clone)] 88 | struct Backtrace; 89 | 90 | #[cfg(not(feature = "debug"))] 91 | impl Backtrace { 92 | fn new() -> Option { 93 | None 94 | } 95 | } 96 | 97 | #[cfg(not(feature = "debug"))] 98 | impl std::fmt::Display for Backtrace { 99 | fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 100 | Ok(()) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["crates/*"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | repository = "https://github.com/assert-rs/snapbox/" 7 | license = "MIT OR Apache-2.0" 8 | edition = "2021" 9 | rust-version = "1.66" # MSRV 10 | include = [ 11 | "build.rs", 12 | "src/**/*", 13 | "Cargo.toml", 14 | "LICENSE*", 15 | "README.md", 16 | "examples/**/*" 17 | ] 18 | 19 | [workspace.lints.rust] 20 | rust_2018_idioms = { level = "warn", priority = -1 } 21 | unnameable_types = "warn" 22 | unreachable_pub = "warn" 23 | unsafe_op_in_unsafe_fn = "warn" 24 | unused_lifetimes = "warn" 25 | unused_macro_rules = "warn" 26 | unused_qualifications = "warn" 27 | 28 | [workspace.lints.clippy] 29 | bool_assert_comparison = "allow" 30 | branches_sharing_code = "allow" 31 | checked_conversions = "warn" 32 | collapsible_else_if = "allow" 33 | create_dir = "warn" 34 | dbg_macro = "warn" 35 | debug_assert_with_mut_call = "warn" 36 | doc_markdown = "warn" 37 | empty_enum = "warn" 38 | enum_glob_use = "warn" 39 | expl_impl_clone_on_copy = "warn" 40 | explicit_deref_methods = "warn" 41 | explicit_into_iter_loop = "warn" 42 | fallible_impl_from = "warn" 43 | filter_map_next = "warn" 44 | flat_map_option = "warn" 45 | float_cmp_const = "warn" 46 | fn_params_excessive_bools = "warn" 47 | from_iter_instead_of_collect = "warn" 48 | if_same_then_else = "allow" 49 | implicit_clone = "warn" 50 | imprecise_flops = "warn" 51 | inconsistent_struct_constructor = "warn" 52 | inefficient_to_string = "warn" 53 | infinite_loop = "warn" 54 | invalid_upcast_comparisons = "warn" 55 | large_digit_groups = "warn" 56 | large_stack_arrays = "warn" 57 | large_types_passed_by_value = "warn" 58 | let_and_return = "allow" # sometimes good to name what you are returning 59 | linkedlist = "warn" 60 | lossy_float_literal = "warn" 61 | macro_use_imports = "warn" 62 | mem_forget = "warn" 63 | mutex_integer = "warn" 64 | needless_continue = "allow" 65 | needless_for_each = "warn" 66 | negative_feature_names = "warn" 67 | path_buf_push_overwrite = "warn" 68 | ptr_as_ptr = "warn" 69 | rc_mutex = "warn" 70 | redundant_feature_names = "warn" 71 | ref_option_ref = "warn" 72 | rest_pat_in_fully_bound_structs = "warn" 73 | result_large_err = "allow" 74 | same_functions_in_if_condition = "warn" 75 | self_named_module_files = "warn" 76 | semicolon_if_nothing_returned = "warn" 77 | str_to_string = "warn" 78 | string_add = "warn" 79 | string_add_assign = "warn" 80 | string_lit_as_bytes = "warn" 81 | string_to_string = "warn" 82 | todo = "warn" 83 | trait_duplication_in_bounds = "warn" 84 | uninlined_format_args = "warn" 85 | verbose_file_reads = "warn" 86 | wildcard_imports = "warn" 87 | zero_sized_map_values = "warn" 88 | 89 | [profile.dev] 90 | panic = "abort" 91 | 92 | [profile.release] 93 | panic = "abort" 94 | codegen-units = 1 95 | lto = true 96 | # debug = "line-tables-only" # requires Cargo 1.71 97 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | schedule: [ 3 | 'before 5am on the first day of the month', 4 | ], 5 | semanticCommits: 'enabled', 6 | commitMessageLowerCase: 'never', 7 | configMigration: true, 8 | dependencyDashboard: true, 9 | customManagers: [ 10 | { 11 | customType: 'regex', 12 | managerFilePatterns: [ 13 | '/^rust-toolchain\\.toml$/', 14 | '/Cargo.toml$/', 15 | '/clippy.toml$/', 16 | '/\\.clippy.toml$/', 17 | '/^\\.github/workflows/ci.yml$/', 18 | '/^\\.github/workflows/rust-next.yml$/', 19 | ], 20 | matchStrings: [ 21 | 'STABLE.*?(?\\d+\\.\\d+(\\.\\d+)?)', 22 | '(?\\d+\\.\\d+(\\.\\d+)?).*?STABLE', 23 | ], 24 | depNameTemplate: 'STABLE', 25 | packageNameTemplate: 'rust-lang/rust', 26 | datasourceTemplate: 'github-releases', 27 | }, 28 | ], 29 | packageRules: [ 30 | { 31 | commitMessageTopic: 'Rust Stable', 32 | matchManagers: [ 33 | 'custom.regex', 34 | ], 35 | matchDepNames: [ 36 | 'STABLE', 37 | ], 38 | extractVersion: '^(?\\d+\\.\\d+)', // Drop the patch version 39 | schedule: [ 40 | '* * * * *', 41 | ], 42 | automerge: true, 43 | }, 44 | // Goals: 45 | // - Keep version reqs low, ignoring compatible normal/build dependencies 46 | // - Take advantage of latest dev-dependencies 47 | // - Rollup safe upgrades to reduce CI runner load 48 | // - Help keep number of versions down by always using latest breaking change 49 | // - Have lockfile and manifest in-sync 50 | { 51 | matchManagers: [ 52 | 'cargo', 53 | ], 54 | matchDepTypes: [ 55 | 'build-dependencies', 56 | 'dependencies', 57 | ], 58 | matchCurrentVersion: '>=0.1.0', 59 | matchUpdateTypes: [ 60 | 'patch', 61 | ], 62 | enabled: false, 63 | }, 64 | { 65 | matchManagers: [ 66 | 'cargo', 67 | ], 68 | matchDepTypes: [ 69 | 'build-dependencies', 70 | 'dependencies', 71 | ], 72 | matchCurrentVersion: '>=1.0.0', 73 | matchUpdateTypes: [ 74 | 'minor', 75 | 'patch', 76 | ], 77 | enabled: false, 78 | }, 79 | { 80 | matchManagers: [ 81 | 'cargo', 82 | ], 83 | matchDepTypes: [ 84 | 'dev-dependencies', 85 | ], 86 | matchCurrentVersion: '>=0.1.0', 87 | matchUpdateTypes: [ 88 | 'patch', 89 | ], 90 | automerge: true, 91 | groupName: 'compatible (dev)', 92 | }, 93 | { 94 | matchManagers: [ 95 | 'cargo', 96 | ], 97 | matchDepTypes: [ 98 | 'dev-dependencies', 99 | ], 100 | matchCurrentVersion: '>=1.0.0', 101 | matchUpdateTypes: [ 102 | 'minor', 103 | 'patch', 104 | ], 105 | automerge: true, 106 | groupName: 'compatible (dev)', 107 | }, 108 | ], 109 | } 110 | -------------------------------------------------------------------------------- /crates/snapbox/src/data/source.rs: -------------------------------------------------------------------------------- 1 | /// Origin of a snapshot so it can be updated 2 | #[derive(Clone, Debug, PartialEq, Eq)] 3 | pub struct DataSource { 4 | pub(crate) inner: DataSourceInner, 5 | } 6 | 7 | #[derive(Clone, Debug, PartialEq, Eq)] 8 | pub(crate) enum DataSourceInner { 9 | Path(std::path::PathBuf), 10 | Inline(Inline), 11 | } 12 | 13 | impl DataSource { 14 | pub fn path(path: impl Into) -> Self { 15 | Self { 16 | inner: DataSourceInner::Path(path.into()), 17 | } 18 | } 19 | 20 | pub fn is_path(&self) -> bool { 21 | self.as_path().is_some() 22 | } 23 | 24 | pub fn as_path(&self) -> Option<&std::path::Path> { 25 | match &self.inner { 26 | DataSourceInner::Path(value) => Some(value.as_ref()), 27 | _ => None, 28 | } 29 | } 30 | 31 | pub fn is_inline(&self) -> bool { 32 | self.as_inline().is_some() 33 | } 34 | 35 | pub fn as_inline(&self) -> Option<&Inline> { 36 | match &self.inner { 37 | DataSourceInner::Inline(value) => Some(value), 38 | _ => None, 39 | } 40 | } 41 | } 42 | 43 | impl From<&'_ std::path::Path> for DataSource { 44 | fn from(value: &'_ std::path::Path) -> Self { 45 | Self::path(value) 46 | } 47 | } 48 | 49 | impl From for DataSource { 50 | fn from(value: std::path::PathBuf) -> Self { 51 | Self::path(value) 52 | } 53 | } 54 | 55 | impl From for DataSource { 56 | fn from(inline: Inline) -> Self { 57 | Self { 58 | inner: DataSourceInner::Inline(inline), 59 | } 60 | } 61 | } 62 | 63 | impl std::fmt::Display for DataSource { 64 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 65 | match &self.inner { 66 | DataSourceInner::Path(value) => crate::dir::display_relpath(value).fmt(f), 67 | DataSourceInner::Inline(value) => value.fmt(f), 68 | } 69 | } 70 | } 71 | 72 | /// Output of [`str!`][crate::str!] 73 | #[derive(Clone, Debug, PartialEq, Eq)] 74 | pub struct Inline { 75 | #[doc(hidden)] 76 | pub position: Position, 77 | #[doc(hidden)] 78 | pub data: &'static str, 79 | } 80 | 81 | impl Inline { 82 | pub(crate) fn trimmed(&self) -> String { 83 | let mut data = self.data; 84 | if data.contains('\n') { 85 | data = data.strip_prefix('\n').unwrap_or(data); 86 | data = data.strip_suffix('\n').unwrap_or(data); 87 | } 88 | data.to_owned() 89 | } 90 | } 91 | 92 | impl std::fmt::Display for Inline { 93 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 94 | self.position.fmt(f) 95 | } 96 | } 97 | 98 | /// Position within Rust source code, see [`Inline`] 99 | #[doc(hidden)] 100 | #[derive(Clone, Debug, PartialEq, Eq)] 101 | pub struct Position { 102 | #[doc(hidden)] 103 | pub file: std::path::PathBuf, 104 | #[doc(hidden)] 105 | pub line: u32, 106 | #[doc(hidden)] 107 | pub column: u32, 108 | } 109 | 110 | impl std::fmt::Display for Position { 111 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 112 | write!( 113 | f, 114 | "{}:{}:{}", 115 | crate::dir::display_relpath(&self.file), 116 | self.line, 117 | self.column 118 | ) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /crates/snapbox/src/dir/root.rs: -------------------------------------------------------------------------------- 1 | /// Working directory for tests 2 | #[derive(Debug)] 3 | pub struct DirRoot(DirRootInner); 4 | 5 | #[derive(Debug)] 6 | enum DirRootInner { 7 | None, 8 | Immutable(std::path::PathBuf), 9 | #[cfg(feature = "dir")] 10 | MutablePath(std::path::PathBuf), 11 | #[cfg(feature = "dir")] 12 | MutableTemp { 13 | temp: tempfile::TempDir, 14 | path: std::path::PathBuf, 15 | }, 16 | } 17 | 18 | impl DirRoot { 19 | pub fn none() -> Self { 20 | Self(DirRootInner::None) 21 | } 22 | 23 | pub fn immutable(target: &std::path::Path) -> Self { 24 | Self(DirRootInner::Immutable(target.to_owned())) 25 | } 26 | 27 | #[cfg(feature = "dir")] 28 | pub fn mutable_temp() -> Result { 29 | let temp = tempfile::tempdir().map_err(|e| e.to_string())?; 30 | // We need to get the `/private` prefix on Mac so variable substitutions work 31 | // correctly 32 | let path = crate::dir::canonicalize(temp.path()) 33 | .map_err(|e| format!("Failed to canonicalize {}: {}", temp.path().display(), e))?; 34 | Ok(Self(DirRootInner::MutableTemp { temp, path })) 35 | } 36 | 37 | #[cfg(feature = "dir")] 38 | pub fn mutable_at(target: &std::path::Path) -> Result { 39 | let _ = std::fs::remove_dir_all(target); 40 | std::fs::create_dir_all(target) 41 | .map_err(|e| format!("Failed to create {}: {}", target.display(), e))?; 42 | Ok(Self(DirRootInner::MutablePath(target.to_owned()))) 43 | } 44 | 45 | #[cfg(feature = "dir")] 46 | pub fn with_template(self, template: &F) -> Result 47 | where 48 | F: crate::dir::DirFixture + ?Sized, 49 | { 50 | match &self.0 { 51 | DirRootInner::None | DirRootInner::Immutable(_) => { 52 | return Err("Sandboxing is disabled".into()); 53 | } 54 | DirRootInner::MutablePath(path) | DirRootInner::MutableTemp { path, .. } => { 55 | crate::debug!("Initializing {} from {:?}", path.display(), template); 56 | template.write_to_path(path)?; 57 | } 58 | } 59 | 60 | Ok(self) 61 | } 62 | 63 | pub fn is_mutable(&self) -> bool { 64 | match &self.0 { 65 | DirRootInner::None | DirRootInner::Immutable(_) => false, 66 | #[cfg(feature = "dir")] 67 | DirRootInner::MutablePath(_) => true, 68 | #[cfg(feature = "dir")] 69 | DirRootInner::MutableTemp { .. } => true, 70 | } 71 | } 72 | 73 | pub fn path(&self) -> Option<&std::path::Path> { 74 | match &self.0 { 75 | DirRootInner::None => None, 76 | DirRootInner::Immutable(path) => Some(path.as_path()), 77 | #[cfg(feature = "dir")] 78 | DirRootInner::MutablePath(path) => Some(path.as_path()), 79 | #[cfg(feature = "dir")] 80 | DirRootInner::MutableTemp { path, .. } => Some(path.as_path()), 81 | } 82 | } 83 | 84 | /// Explicitly close to report errors 85 | pub fn close(self) -> Result<(), std::io::Error> { 86 | match self.0 { 87 | DirRootInner::None | DirRootInner::Immutable(_) => Ok(()), 88 | #[cfg(feature = "dir")] 89 | DirRootInner::MutablePath(_) => Ok(()), 90 | #[cfg(feature = "dir")] 91 | DirRootInner::MutableTemp { temp, .. } => temp.close(), 92 | } 93 | } 94 | } 95 | 96 | impl Default for DirRoot { 97 | fn default() -> Self { 98 | Self::none() 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /crates/snapbox/src/filter/test.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "json")] 2 | use serde_json::json; 3 | 4 | #[cfg(feature = "json")] 5 | use super::*; 6 | 7 | // Tests for normalization on json 8 | #[test] 9 | #[cfg(feature = "json")] 10 | fn json_normalize_paths_and_lines_string() { 11 | let json = json!({"name": "John\\Doe\r\n"}); 12 | let data = Data::json(json); 13 | let data = FilterPaths.filter(data); 14 | assert_eq!(Data::json(json!({"name": "John/Doe\r\n"})), data); 15 | let data = FilterNewlines.filter(data); 16 | assert_eq!(Data::json(json!({"name": "John/Doe\n"})), data); 17 | } 18 | 19 | #[test] 20 | #[cfg(feature = "json")] 21 | fn json_normalize_paths_and_lines_nested_string() { 22 | let json = json!({ 23 | "person": { 24 | "name": "John\\Doe\r\n", 25 | "nickname": "Jo\\hn\r\n", 26 | } 27 | }); 28 | let data = Data::json(json); 29 | let data = FilterPaths.filter(data); 30 | let assert = json!({ 31 | "person": { 32 | "name": "John/Doe\r\n", 33 | "nickname": "Jo/hn\r\n", 34 | } 35 | }); 36 | assert_eq!(Data::json(assert), data); 37 | let data = FilterNewlines.filter(data); 38 | let assert = json!({ 39 | "person": { 40 | "name": "John/Doe\n", 41 | "nickname": "Jo/hn\n", 42 | } 43 | }); 44 | assert_eq!(Data::json(assert), data); 45 | } 46 | 47 | #[test] 48 | #[cfg(feature = "json")] 49 | fn json_normalize_paths_and_lines_obj_key() { 50 | let json = json!({ 51 | "person": { 52 | "John\\Doe\r\n": "name", 53 | "Jo\\hn\r\n": "nickname", 54 | } 55 | }); 56 | let data = Data::json(json); 57 | let data = FilterPaths.filter(data); 58 | let assert = json!({ 59 | "person": { 60 | "John/Doe\r\n": "name", 61 | "Jo/hn\r\n": "nickname", 62 | } 63 | }); 64 | assert_eq!(Data::json(assert), data); 65 | let data = FilterNewlines.filter(data); 66 | let assert = json!({ 67 | "person": { 68 | "John/Doe\n": "name", 69 | "Jo/hn\n": "nickname", 70 | } 71 | }); 72 | assert_eq!(Data::json(assert), data); 73 | } 74 | 75 | #[test] 76 | #[cfg(feature = "json")] 77 | fn json_normalize_paths_and_lines_array() { 78 | let json = json!({"people": ["John\\Doe\r\n", "Jo\\hn\r\n"]}); 79 | let data = Data::json(json); 80 | let data = FilterPaths.filter(data); 81 | let paths = json!({"people": ["John/Doe\r\n", "Jo/hn\r\n"]}); 82 | assert_eq!(Data::json(paths), data); 83 | let data = FilterNewlines.filter(data); 84 | let new_lines = json!({"people": ["John/Doe\n", "Jo/hn\n"]}); 85 | assert_eq!(Data::json(new_lines), data); 86 | } 87 | 88 | #[test] 89 | #[cfg(feature = "json")] 90 | fn json_normalize_paths_and_lines_array_obj() { 91 | let json = json!({ 92 | "people": [ 93 | { 94 | "name": "John\\Doe\r\n", 95 | "nickname": "Jo\\hn\r\n", 96 | } 97 | ] 98 | }); 99 | let data = Data::json(json); 100 | let data = FilterPaths.filter(data); 101 | let paths = json!({ 102 | "people": [ 103 | { 104 | "name": "John/Doe\r\n", 105 | "nickname": "Jo/hn\r\n", 106 | } 107 | ] 108 | }); 109 | assert_eq!(Data::json(paths), data); 110 | let data = FilterNewlines.filter(data); 111 | let new_lines = json!({ 112 | "people": [ 113 | { 114 | "name": "John/Doe\n", 115 | "nickname": "Jo/hn\n", 116 | } 117 | ] 118 | }); 119 | assert_eq!(Data::json(new_lines), data); 120 | } 121 | -------------------------------------------------------------------------------- /crates/snapbox/src/data/format.rs: -------------------------------------------------------------------------------- 1 | /// Describes the structure of [`Data`][crate::Data] 2 | #[derive(Clone, Debug, PartialEq, Eq, Copy, Hash, Default)] 3 | #[non_exhaustive] 4 | pub enum DataFormat { 5 | /// Processing of the [`Data`][crate::Data] failed 6 | Error, 7 | /// Non-textual, opaque data 8 | Binary, 9 | #[default] 10 | Text, 11 | #[cfg(feature = "json")] 12 | Json, 13 | /// Streamed JSON output according to 14 | #[cfg(feature = "json")] 15 | JsonLines, 16 | /// [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code#DOS_and_Windows) 17 | /// rendered as [svg](https://docs.rs/anstyle-svg) 18 | #[cfg(feature = "term-svg")] 19 | TermSvg, 20 | } 21 | 22 | impl DataFormat { 23 | /// Assumed file extension for the format 24 | pub fn ext(self) -> &'static str { 25 | match self { 26 | Self::Error => "txt", 27 | Self::Binary => "bin", 28 | Self::Text => "txt", 29 | #[cfg(feature = "json")] 30 | Self::Json => "json", 31 | #[cfg(feature = "json")] 32 | Self::JsonLines => "jsonl", 33 | #[cfg(feature = "term-svg")] 34 | Self::TermSvg => "term.svg", 35 | } 36 | } 37 | } 38 | 39 | impl From<&std::path::Path> for DataFormat { 40 | fn from(path: &std::path::Path) -> Self { 41 | let file_name = path 42 | .file_name() 43 | .and_then(|e| e.to_str()) 44 | .unwrap_or_default(); 45 | let mut ext = file_name.strip_prefix('.').unwrap_or(file_name); 46 | while let Some((_, new_ext)) = ext.split_once('.') { 47 | ext = new_ext; 48 | match ext { 49 | #[cfg(feature = "json")] 50 | "json" => { 51 | return DataFormat::Json; 52 | } 53 | #[cfg(feature = "json")] 54 | "jsonl" => { 55 | return DataFormat::JsonLines; 56 | } 57 | #[cfg(feature = "term-svg")] 58 | "term.svg" => { 59 | return Self::TermSvg; 60 | } 61 | _ => {} 62 | } 63 | } 64 | DataFormat::Text 65 | } 66 | } 67 | 68 | #[cfg(test)] 69 | mod test { 70 | use super::*; 71 | 72 | #[test] 73 | fn combos() { 74 | #[cfg(feature = "json")] 75 | let json = DataFormat::Json; 76 | #[cfg(not(feature = "json"))] 77 | let json = DataFormat::Text; 78 | #[cfg(feature = "json")] 79 | let jsonl = DataFormat::JsonLines; 80 | #[cfg(not(feature = "json"))] 81 | let jsonl = DataFormat::Text; 82 | #[cfg(feature = "term-svg")] 83 | let term_svg = DataFormat::TermSvg; 84 | #[cfg(not(feature = "term-svg"))] 85 | let term_svg = DataFormat::Text; 86 | let cases = [ 87 | ("foo", DataFormat::Text), 88 | (".foo", DataFormat::Text), 89 | ("foo.txt", DataFormat::Text), 90 | (".foo.txt", DataFormat::Text), 91 | ("foo.stdout.txt", DataFormat::Text), 92 | ("foo.json", json), 93 | ("foo.stdout.json", json), 94 | (".foo.json", json), 95 | ("foo.jsonl", jsonl), 96 | ("foo.stdout.jsonl", jsonl), 97 | (".foo.jsonl", jsonl), 98 | ("foo.term.svg", term_svg), 99 | ("foo.stdout.term.svg", term_svg), 100 | (".foo.term.svg", term_svg), 101 | ]; 102 | for (input, output) in cases { 103 | let input = std::path::Path::new(input); 104 | assert_eq!(DataFormat::from(input), output, "for `{}`", input.display()); 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /crates/snapbox/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "snapbox" 3 | version = "0.6.23" 4 | description = "Snapshot testing toolbox" 5 | categories = ["development-tools::testing"] 6 | keywords = ["cli", "test", "assert", "command"] 7 | repository.workspace = true 8 | license.workspace = true 9 | edition.workspace = true 10 | rust-version.workspace = true 11 | include.workspace = true 12 | 13 | [package.metadata.docs.rs] 14 | all-features = true 15 | rustdoc-args = ["--generate-link-to-definition"] 16 | 17 | [package.metadata.release] 18 | pre-release-replacements = [ 19 | {file="CHANGELOG.md", search="Unreleased", replace="{{version}}", min=1}, 20 | {file="CHANGELOG.md", search="\\.\\.\\.HEAD", replace="...{{tag_name}}", exactly=1}, 21 | {file="CHANGELOG.md", search="ReleaseDate", replace="{{date}}", min=1}, 22 | {file="CHANGELOG.md", search="", replace="\n## [Unreleased] - ReleaseDate\n", exactly=1}, 23 | {file="CHANGELOG.md", search="", replace="\n[Unreleased]: https://github.com/assert-rs/trycmd/compare/{{tag_name}}...HEAD", exactly=1}, 24 | ] 25 | 26 | [features] 27 | default = ["color-auto", "diff"] 28 | 29 | #! Feature Flags 30 | 31 | ## Smarter binary file detection 32 | detect-encoding = ["dep:content_inspector"] 33 | ## Snapshotting of directories 34 | dir = ["dep:tempfile", "dep:walkdir", "dep:dunce", "detect-encoding", "dep:filetime"] 35 | ## Deprecated since 0.5.11, replaced with `dir` 36 | path = ["dir"] 37 | ## Snapshotting of commands 38 | cmd = ["dep:os_pipe", "dep:wait-timeout", "dep:libc", "dep:windows-sys"] 39 | ## Building of examples for snapshotting 40 | examples = ["dep:escargot"] 41 | ## Regex text substitutions 42 | regex = ["dep:regex"] 43 | 44 | ## Snapshotting of json 45 | json = ["structured-data", "dep:serde_json", "dep:serde"] 46 | ## Snapshotting of term styling 47 | term-svg = ["structured-data", "dep:anstyle-svg"] 48 | ## Snapshotting of structured data 49 | structured-data = ["dep:serde_json"] 50 | 51 | ## Extra debugging information 52 | debug = ["snapbox-macros/debug", "dep:backtrace"] 53 | 54 | #! Default Feature Flags 55 | 56 | ## Fancy diffs on failure 57 | diff = ["dep:similar"] 58 | ## Colored output support 59 | color = ["dep:anstream", "snapbox-macros/color"] 60 | ## Auto-detect whether to use colors 61 | color-auto = ["color"] 62 | 63 | [[bin]] 64 | name = "snap-fixture" # For `snapbox`s tests only 65 | 66 | [dependencies] 67 | normalize-line-endings = "0.3.0" 68 | snapbox-macros = { path = "../snapbox-macros", version = "0.4.0" } 69 | 70 | content_inspector = { version = "0.2.4", optional = true } 71 | 72 | tempfile = { version = "3.23", optional = true } 73 | walkdir = { version = "2.5.0", optional = true } 74 | dunce = { version = "1.0", optional = true } 75 | filetime = { version = "0.2.26", optional = true } 76 | 77 | os_pipe = { version = "1.2", optional = true } 78 | wait-timeout = { version = "0.2.1", optional = true } 79 | escargot = { version = "0.5.14", optional = true } 80 | 81 | backtrace = { version = "0.3", optional = true } 82 | 83 | similar = { version = "2.7.0", features = ["inline"], optional = true } 84 | 85 | anstyle = "1.0.13" 86 | anstream = { version = "0.6.20", optional = true } 87 | 88 | document-features = { version = "0.2.11", optional = true } 89 | 90 | serde_json = { version = "1.0.145", optional = true} 91 | anstyle-svg = { version = "0.1.11", optional = true } 92 | serde = { version = "1.0.228", optional = true } 93 | regex = { version = "1.11.3", optional = true, default-features = false, features = ["std"] } 94 | 95 | [target.'cfg(windows)'.dependencies] 96 | windows-sys = { version = "0.60.2", features = ["Win32_Foundation"], optional = true } 97 | 98 | [target.'cfg(unix)'.dependencies] 99 | libc = { version = "0.2.176", optional = true } 100 | 101 | [dev-dependencies] 102 | automod = "1.0.15" 103 | 104 | [[example]] 105 | name = "diff" 106 | required-features = ["diff"] 107 | 108 | [lints] 109 | workspace = true 110 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to trycmd 2 | 3 | Thanks for wanting to contribute! There are many ways to contribute and we 4 | appreciate any level you're willing to do. 5 | 6 | ## Feature Requests 7 | 8 | Need some new functionality to help? You can let us know by opening an 9 | [issue][new issue]. It's helpful to look through [all issues][all issues] in 10 | case it's already being talked about. 11 | 12 | ## Bug Reports 13 | 14 | Please let us know about what problems you run into, whether in behavior or 15 | ergonomics of API. You can do this by opening an [issue][new issue]. It's 16 | helpful to look through [all issues][all issues] in case it's already being 17 | talked about. 18 | 19 | ## Pull Requests 20 | 21 | Looking for an idea? Check our [issues][issues]. If the issue looks open ended, 22 | it is probably best to post on the issue how you are thinking of resolving the 23 | issue so you can get feedback early in the process. We want you to be 24 | successful and it can be discouraging to find out a lot of re-work is needed. 25 | 26 | Already have an idea? It might be good to first [create an issue][new issue] 27 | to propose it so we can make sure we are aligned and lower the risk of having 28 | to re-work some of it and the discouragement that goes along with that. 29 | 30 | ### Process 31 | 32 | As a heads up, we'll be running your PR through the following gauntlet: 33 | - warnings turned to compile errors 34 | - `cargo test` 35 | - `rustfmt` 36 | - `clippy` 37 | - `rustdoc` 38 | - [`committed`](https://github.com/crate-ci/committed) as we use [Conventional](https://www.conventionalcommits.org) commit style 39 | - [`typos`](https://github.com/crate-ci/typos) to check spelling 40 | 41 | Not everything can be checked automatically though. 42 | 43 | We request that the commit history gets cleaned up. 44 | 45 | We ask that commits are atomic, meaning they are complete and have a single responsibility. 46 | A complete commit should build, pass tests, update documentation and tests, and not have dead code. 47 | 48 | PRs should tell a cohesive story, with refactor and test commits that keep the 49 | fix or feature commits simple and clear. 50 | 51 | Specifically, we would encourage 52 | - File renames be isolated into their own commit 53 | - Add tests in a commit before their feature or fix, showing the current behavior (i.e. they should pass). 54 | The diff for the feature/fix commit will then show how the behavior changed, 55 | making the commit's intent clearer to reviewers and the community, and showing people that the 56 | test is verifying the expected state. 57 | - e.g. [clap#5520](https://github.com/clap-rs/clap/pull/5520) 58 | 59 | Note that we are talking about ideals. 60 | We understand having a clean history requires more advanced git skills; 61 | feel free to ask us for help! 62 | We might even suggest where it would work to be lax. 63 | We also understand that editing some early commits may cause a lot of churn 64 | with merge conflicts which can make it not worth editing all of the history. 65 | 66 | For code organization, we recommend 67 | - Grouping `impl` blocks next to their type (or trait) 68 | - Grouping private items after the `pub` item that uses them. 69 | - The intent is to help people quickly find the "relevant" details, allowing them to "dig deeper" as needed. Or put another way, the `pub` items serve as a table-of-contents. 70 | - The exact order is fuzzy; do what makes sense 71 | 72 | ## Releasing 73 | 74 | Pre-requisites 75 | - Running `cargo login` 76 | - Push permission to the repo 77 | - [`cargo-release`](https://github.com/crate-ci/cargo-release/) 78 | 79 | When we're ready to release, a project owner should do the following 80 | 1. Update the changelog (see `cargo release changes` for ideas) 81 | 2. Determine what the next version is, according to semver 82 | 3. Run [`cargo release -x `](https://github.com/crate-ci/cargo-release) 83 | 84 | [issues]: https://github.com/assert-rs/trycmd/issues 85 | [new issue]: https://github.com/assert-rs/trycmd/issues/new 86 | [all issues]: https://github.com/assert-rs/trycmd/issues?utf8=%E2%9C%93&q=is%3Aissue 87 | -------------------------------------------------------------------------------- /crates/snapbox/src/dir/fixture.rs: -------------------------------------------------------------------------------- 1 | /// Collection of files 2 | pub trait DirFixture: std::fmt::Debug { 3 | /// Initialize a test fixture directory `root` 4 | fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error>; 5 | } 6 | 7 | #[cfg(feature = "dir")] // for documentation purposes only 8 | impl DirFixture for std::path::Path { 9 | fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { 10 | super::copy_template(self, root) 11 | } 12 | } 13 | 14 | #[cfg(feature = "dir")] // for documentation purposes only 15 | impl DirFixture for &'_ std::path::Path { 16 | fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { 17 | std::path::Path::new(self).write_to_path(root) 18 | } 19 | } 20 | 21 | #[cfg(feature = "dir")] // for documentation purposes only 22 | impl DirFixture for &'_ std::path::PathBuf { 23 | fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { 24 | std::path::Path::new(self).write_to_path(root) 25 | } 26 | } 27 | 28 | #[cfg(feature = "dir")] // for documentation purposes only 29 | impl DirFixture for std::path::PathBuf { 30 | fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { 31 | std::path::Path::new(self).write_to_path(root) 32 | } 33 | } 34 | 35 | #[cfg(feature = "dir")] // for documentation purposes only 36 | impl DirFixture for str { 37 | fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { 38 | std::path::Path::new(self).write_to_path(root) 39 | } 40 | } 41 | 42 | #[cfg(feature = "dir")] // for documentation purposes only 43 | impl DirFixture for &'_ str { 44 | fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { 45 | std::path::Path::new(self).write_to_path(root) 46 | } 47 | } 48 | 49 | #[cfg(feature = "dir")] // for documentation purposes only 50 | impl DirFixture for &'_ String { 51 | fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { 52 | std::path::Path::new(self).write_to_path(root) 53 | } 54 | } 55 | 56 | #[cfg(feature = "dir")] // for documentation purposes only 57 | impl DirFixture for String { 58 | fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { 59 | std::path::Path::new(self).write_to_path(root) 60 | } 61 | } 62 | 63 | impl DirFixture for &[(P, S)] 64 | where 65 | P: AsRef, 66 | P: std::fmt::Debug, 67 | S: AsRef<[u8]>, 68 | S: std::fmt::Debug, 69 | { 70 | fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { 71 | let root = super::ops::canonicalize(root) 72 | .map_err(|e| format!("Failed to canonicalize {}: {}", root.display(), e))?; 73 | 74 | for (path, content) in self.iter() { 75 | let rel_path = path.as_ref(); 76 | let path = root.join(rel_path); 77 | let path = super::ops::normalize_path(&path); 78 | if !path.starts_with(&root) { 79 | return Err(crate::assert::Error::new(format!( 80 | "Fixture {} is for outside of the target root", 81 | rel_path.display(), 82 | ))); 83 | } 84 | 85 | let content = content.as_ref(); 86 | 87 | if let Some(dir) = path.parent() { 88 | std::fs::create_dir_all(dir).map_err(|e| { 89 | format!( 90 | "Failed to create fixture directory {}: {}", 91 | dir.display(), 92 | e 93 | ) 94 | })?; 95 | } 96 | std::fs::write(&path, content) 97 | .map_err(|e| format!("Failed to write fixture {}: {}", path.display(), e))?; 98 | } 99 | Ok(()) 100 | } 101 | } 102 | 103 | impl DirFixture for [(P, S); N] 104 | where 105 | P: AsRef, 106 | P: std::fmt::Debug, 107 | S: AsRef<[u8]>, 108 | S: std::fmt::Debug, 109 | { 110 | fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { 111 | let s: &[(P, S)] = self; 112 | s.write_to_path(root) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /crates/snapbox/src/macros.rs: -------------------------------------------------------------------------------- 1 | /// Check if a value is the same as an expected value 2 | /// 3 | /// By default [`filters`][crate::filter] are applied, including: 4 | /// - `...` is a line-wildcard when on a line by itself 5 | /// - `[..]` is a character-wildcard when inside a line 6 | /// - `[EXE]` matches `.exe` on Windows 7 | /// - `"{...}"` is a JSON value wildcard 8 | /// - `"...": "{...}"` is a JSON key-value wildcard 9 | /// - `\` to `/` 10 | /// - Newlines 11 | /// 12 | /// To limit this to newline normalization for text, call [`Data::raw`][crate::Data] on `expected`. 13 | /// 14 | /// # Effective signature 15 | /// 16 | /// ```rust 17 | /// # use snapbox::IntoData; 18 | /// fn assert_data_eq(actual: impl IntoData, expected: impl IntoData) { 19 | /// // ... 20 | /// } 21 | /// ``` 22 | /// 23 | /// # Examples 24 | /// 25 | /// ```rust 26 | /// # use snapbox::assert_data_eq; 27 | /// let output = "something"; 28 | /// let expected = "so[..]g"; 29 | /// assert_data_eq!(output, expected); 30 | /// ``` 31 | /// 32 | /// Can combine this with [`file!`] 33 | /// ```rust,no_run 34 | /// # use snapbox::assert_data_eq; 35 | /// # use snapbox::file; 36 | /// let actual = "something"; 37 | /// assert_data_eq!(actual, file!["output.txt"]); 38 | /// ``` 39 | #[macro_export] 40 | macro_rules! assert_data_eq { 41 | ($actual: expr, $expected: expr $(,)?) => {{ 42 | let actual = $crate::IntoData::into_data($actual); 43 | let expected = $crate::IntoData::into_data($expected); 44 | $crate::Assert::new() 45 | .action_env($crate::assert::DEFAULT_ACTION_ENV) 46 | .eq(actual, expected); 47 | }}; 48 | } 49 | 50 | /// Find the directory for your source file 51 | #[doc(hidden)] // forced to be visible in intended location 52 | #[macro_export] 53 | macro_rules! current_dir { 54 | () => {{ 55 | let root = $crate::utils::cargo_rustc_current_dir!(); 56 | let file = ::std::file!(); 57 | let rel_path = ::std::path::Path::new(file).parent().unwrap(); 58 | root.join(rel_path) 59 | }}; 60 | } 61 | 62 | /// Find the directory for your source file 63 | #[doc(hidden)] // forced to be visible in intended location 64 | #[macro_export] 65 | macro_rules! current_rs { 66 | () => {{ 67 | let root = $crate::utils::cargo_rustc_current_dir!(); 68 | let file = ::std::file!(); 69 | let rel_path = ::std::path::Path::new(file); 70 | root.join(rel_path) 71 | }}; 72 | } 73 | 74 | /// Find the base directory for [`std::file!`] 75 | #[doc(hidden)] // forced to be visible in intended location 76 | #[macro_export] 77 | macro_rules! cargo_rustc_current_dir { 78 | () => {{ 79 | if let Some(rustc_root) = ::std::option_env!("CARGO_RUSTC_CURRENT_DIR") { 80 | ::std::path::Path::new(rustc_root) 81 | } else { 82 | let manifest_dir = ::std::path::Path::new(::std::env!("CARGO_MANIFEST_DIR")); 83 | manifest_dir 84 | .ancestors() 85 | .filter(|it| it.join("Cargo.toml").exists()) 86 | .last() 87 | .unwrap() 88 | } 89 | }}; 90 | } 91 | 92 | /// Path to the current function 93 | /// 94 | /// Closures are ignored 95 | #[doc(hidden)] 96 | #[macro_export] 97 | macro_rules! fn_path { 98 | () => {{ 99 | fn f() {} 100 | fn type_name_of_val(_: T) -> &'static str { 101 | std::any::type_name::() 102 | } 103 | let mut name = type_name_of_val(f).strip_suffix("::f").unwrap_or(""); 104 | while let Some(rest) = name.strip_suffix("::{{closure}}") { 105 | name = rest; 106 | } 107 | name 108 | }}; 109 | } 110 | 111 | /// The absolute path to a binary target's executable. 112 | /// 113 | /// The `bin_target_name` is the name of the binary 114 | /// target, exactly as-is. 115 | /// 116 | /// **NOTE:** This is only set when building an integration test or benchmark. 117 | /// 118 | /// ## Example 119 | /// 120 | /// ```rust,no_run 121 | /// #[test] 122 | /// fn cli_tests() { 123 | /// trycmd::TestCases::new() 124 | /// .default_bin_path(trycmd::cargo_bin!("bin-fixture")) 125 | /// .case("tests/cmd/*.trycmd"); 126 | /// } 127 | /// ``` 128 | #[macro_export] 129 | #[doc(hidden)] 130 | macro_rules! cargo_bin { 131 | () => { 132 | $crate::cmd::cargo_bin!(env!("CARGO_PKG_NAME")) 133 | }; 134 | ($bin_target_name:expr) => { 135 | ::std::path::Path::new(env!(concat!("CARGO_BIN_EXE_", $bin_target_name))) 136 | }; 137 | } 138 | 139 | #[cfg(test)] 140 | mod test { 141 | #[test] 142 | fn direct_fn_path() { 143 | assert_eq!(fn_path!(), "snapbox::macros::test::direct_fn_path"); 144 | } 145 | 146 | #[test] 147 | #[allow(clippy::redundant_closure_call)] 148 | fn closure_fn_path() { 149 | (|| { 150 | assert_eq!(fn_path!(), "snapbox::macros::test::closure_fn_path"); 151 | })(); 152 | } 153 | 154 | #[test] 155 | fn nested_fn_path() { 156 | fn nested() { 157 | assert_eq!(fn_path!(), "snapbox::macros::test::nested_fn_path::nested"); 158 | } 159 | nested(); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /crates/snapbox/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # Snapshot testing toolbox 2 | //! 3 | //! > When you have to treat your tests like pets, instead of [cattle][trycmd] 4 | //! 5 | //! `snapbox` is a snapshot-testing toolbox that is ready to use for verifying output from 6 | //! - Function return values 7 | //! - CLI stdout/stderr 8 | //! - Filesystem changes 9 | //! 10 | //! It is also flexible enough to build your own test harness like [trycmd](https://crates.io/crates/trycmd). 11 | //! 12 | //! ## Which tool is right 13 | //! 14 | //! - [cram](https://bitheap.org/cram/): End-to-end CLI snapshotting agnostic of any programming language 15 | //! - See also [scrut](https://github.com/facebookincubator/scrut) 16 | //! - [trycmd](https://crates.io/crates/trycmd): For running a lot of blunt tests (limited test predicates) 17 | //! - Particular attention is given to allow the test data to be pulled into documentation, like 18 | //! with [mdbook](https://rust-lang.github.io/mdBook/) 19 | //! - [tryfn](https://crates.io/crates/tryfn): For running a lot of simple input/output tests 20 | //! - `snapbox`: When you want something like `trycmd` in one off 21 | //! cases or you need to customize `trycmd`s behavior. 22 | //! - [assert_cmd](https://crates.io/crates/assert_cmd) + 23 | //! [assert_fs](https://crates.io/crates/assert_fs): Test cases follow a certain pattern but 24 | //! special attention is needed in how to verify the results. 25 | //! - Hand-written test cases: for peculiar circumstances 26 | //! 27 | //! ## Getting Started 28 | //! 29 | //! Testing Functions: 30 | //! - [`assert_data_eq!`] for quick and dirty snapshotting 31 | //! 32 | //! Testing Commands: 33 | //! - [`cmd::Command`]: Process spawning for testing of non-interactive commands 34 | //! - [`cmd::OutputAssert`]: Assert the state of a [`Command`][cmd::Command]'s 35 | //! [`Output`][std::process::Output]. 36 | //! 37 | //! Testing Filesystem Interactions: 38 | //! - [`dir::DirRoot`]: Working directory for tests 39 | //! - [`Assert`]: Diff a directory against files present in a pattern directory 40 | //! 41 | //! You can also build your own version of these with the lower-level building blocks these are 42 | //! made of. 43 | //! 44 | #![cfg_attr(feature = "document-features", doc = document_features::document_features!())] 45 | //! 46 | //! # Examples 47 | //! 48 | //! [`assert_data_eq!`] 49 | //! ```rust 50 | //! snapbox::assert_data_eq!("Hello many people!", "Hello [..] people!"); 51 | //! ``` 52 | //! 53 | //! [`Assert`] 54 | //! ```rust,no_run 55 | //! let actual = "..."; 56 | //! snapbox::Assert::new() 57 | //! .action_env("SNAPSHOTS") 58 | //! .eq(actual, snapbox::file!["help_output_is_clean.txt"]); 59 | //! ``` 60 | //! 61 | //! [trycmd]: https://docs.rs/trycmd 62 | 63 | #![cfg_attr(docsrs, feature(doc_cfg))] 64 | #![warn(clippy::print_stderr)] 65 | #![warn(clippy::print_stdout)] 66 | 67 | mod macros; 68 | 69 | pub mod assert; 70 | pub mod cmd; 71 | pub mod data; 72 | pub mod dir; 73 | pub mod filter; 74 | pub mod report; 75 | pub mod utils; 76 | 77 | pub use assert::Assert; 78 | pub use data::Data; 79 | pub use data::IntoData; 80 | #[cfg(feature = "json")] 81 | pub use data::IntoJson; 82 | pub use data::ToDebug; 83 | pub use filter::RedactedValue; 84 | pub use filter::Redactions; 85 | #[doc(hidden)] 86 | pub use snapbox_macros::debug; 87 | 88 | /// Easier access to common traits 89 | pub mod prelude { 90 | pub use crate::IntoData; 91 | #[cfg(feature = "json")] 92 | pub use crate::IntoJson; 93 | pub use crate::ToDebug; 94 | } 95 | 96 | /// Check if a path matches the content of another path, recursively 97 | /// 98 | /// When the content is text, newlines are normalized. 99 | /// 100 | /// ```rust,no_run 101 | /// let output_root = "..."; 102 | /// let expected_root = "tests/snapshots/output.txt"; 103 | /// snapbox::assert_subset_eq(expected_root, output_root); 104 | /// ``` 105 | #[cfg(feature = "dir")] 106 | #[track_caller] 107 | pub fn assert_subset_eq( 108 | expected_root: impl Into, 109 | actual_root: impl Into, 110 | ) { 111 | Assert::new() 112 | .action_env(assert::DEFAULT_ACTION_ENV) 113 | .subset_eq(expected_root, actual_root); 114 | } 115 | 116 | /// Check if a path matches the pattern of another path, recursively 117 | /// 118 | /// Pattern syntax: 119 | /// - `...` is a line-wildcard when on a line by itself 120 | /// - `[..]` is a character-wildcard when inside a line 121 | /// - `[EXE]` matches `.exe` on Windows 122 | /// 123 | /// Normalization: 124 | /// - Newlines 125 | /// - `\` to `/` 126 | /// 127 | /// ```rust,no_run 128 | /// let output_root = "..."; 129 | /// let expected_root = "tests/snapshots/output.txt"; 130 | /// snapbox::assert_subset_matches(expected_root, output_root); 131 | /// ``` 132 | #[cfg(feature = "dir")] 133 | #[track_caller] 134 | pub fn assert_subset_matches( 135 | pattern_root: impl Into, 136 | actual_root: impl Into, 137 | ) { 138 | Assert::new() 139 | .action_env(assert::DEFAULT_ACTION_ENV) 140 | .subset_matches(pattern_root, actual_root); 141 | } 142 | 143 | #[doc = include_str!("../README.md")] 144 | #[cfg(doctest)] 145 | pub struct ReadmeDoctests; 146 | -------------------------------------------------------------------------------- /crates/trycmd/src/spec.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | 3 | #[derive(Debug)] 4 | pub(crate) struct RunnerSpec { 5 | cases: Vec, 6 | include: Option>, 7 | default_bin: Option, 8 | timeout: Option, 9 | env: crate::schema::Env, 10 | } 11 | 12 | impl RunnerSpec { 13 | pub(crate) fn new() -> Self { 14 | Self { 15 | cases: Default::default(), 16 | include: None, 17 | default_bin: None, 18 | timeout: Default::default(), 19 | env: Default::default(), 20 | } 21 | } 22 | 23 | pub(crate) fn case( 24 | &mut self, 25 | glob: &std::path::Path, 26 | #[cfg_attr(miri, allow(unused_variables))] expected: Option, 27 | ) { 28 | self.cases.push(CaseSpec { 29 | glob: glob.into(), 30 | #[cfg(not(miri))] 31 | expected, 32 | #[cfg(miri)] 33 | expected: Some(crate::schema::CommandStatus::Skipped), 34 | }); 35 | } 36 | 37 | pub(crate) fn include(&mut self, include: Option>) { 38 | self.include = include; 39 | } 40 | 41 | pub(crate) fn default_bin(&mut self, bin: Option) { 42 | self.default_bin = bin; 43 | } 44 | 45 | pub(crate) fn timeout(&mut self, time: Option) { 46 | self.timeout = time; 47 | } 48 | 49 | pub(crate) fn env(&mut self, key: impl Into, value: impl Into) { 50 | self.env.add.insert(key.into(), value.into()); 51 | } 52 | 53 | pub(crate) fn prepare(&mut self) -> crate::Runner { 54 | let mut runner = crate::Runner::new(); 55 | 56 | // Both sort and let the last writer win to allow overriding specific cases within a glob 57 | let mut cases: BTreeMap = BTreeMap::new(); 58 | 59 | for spec in &self.cases { 60 | if let Some(glob) = get_glob(&spec.glob) { 61 | match ::glob::glob(glob) { 62 | Ok(paths) => { 63 | for path in paths { 64 | match path { 65 | Ok(path) => { 66 | cases.insert( 67 | path.clone(), 68 | crate::Case { 69 | path, 70 | expected: spec.expected, 71 | default_bin: self.default_bin.clone(), 72 | timeout: self.timeout, 73 | env: self.env.clone(), 74 | error: None, 75 | }, 76 | ); 77 | } 78 | Err(err) => { 79 | let path = err.path().to_owned(); 80 | let err = crate::Error::new(err.into_error().to_string()); 81 | cases.insert(path.clone(), crate::Case::with_error(path, err)); 82 | } 83 | } 84 | } 85 | } 86 | Err(err) => { 87 | let err = crate::Error::new(err.to_string()); 88 | cases.insert( 89 | spec.glob.clone(), 90 | crate::Case::with_error(spec.glob.clone(), err), 91 | ); 92 | } 93 | } 94 | } else { 95 | let path = spec.glob.as_path(); 96 | cases.insert( 97 | path.into(), 98 | crate::Case { 99 | path: path.into(), 100 | expected: spec.expected, 101 | default_bin: self.default_bin.clone(), 102 | timeout: self.timeout, 103 | env: self.env.clone(), 104 | error: None, 105 | }, 106 | ); 107 | } 108 | } 109 | 110 | for case in cases.into_values() { 111 | if self.is_included(&case) { 112 | runner.case(case); 113 | } 114 | } 115 | 116 | runner 117 | } 118 | 119 | fn is_included(&self, case: &crate::Case) -> bool { 120 | if let Some(include) = self.include.as_deref() { 121 | include 122 | .iter() 123 | .any(|i| case.path.to_string_lossy().contains(i)) 124 | } else { 125 | true 126 | } 127 | } 128 | } 129 | 130 | impl Default for RunnerSpec { 131 | fn default() -> Self { 132 | Self::new() 133 | } 134 | } 135 | 136 | #[derive(Debug)] 137 | struct CaseSpec { 138 | glob: std::path::PathBuf, 139 | expected: Option, 140 | } 141 | 142 | fn get_glob(path: &std::path::Path) -> Option<&str> { 143 | if let Some(utf8) = path.to_str() { 144 | if utf8.contains('*') { 145 | return Some(utf8); 146 | } 147 | } 148 | 149 | None 150 | } 151 | -------------------------------------------------------------------------------- /crates/snapbox/src/filter/test_unordered.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | #[cfg(feature = "json")] 4 | use serde_json::json; 5 | 6 | use super::*; 7 | use crate::prelude::*; 8 | 9 | #[test] 10 | fn str_normalize_empty() { 11 | let input = ""; 12 | let pattern = ""; 13 | let expected = ""; 14 | let actual = NormalizeToExpected::new() 15 | .unordered() 16 | .normalize(input.into_data(), &pattern.into_data()); 17 | assert_eq!(actual, expected.into_data()); 18 | } 19 | 20 | #[test] 21 | fn str_normalize_same_order() { 22 | let input = "1 23 | 2 24 | 3 25 | "; 26 | let pattern = "1 27 | 2 28 | 3 29 | "; 30 | let expected = "1 31 | 2 32 | 3 33 | "; 34 | let actual = NormalizeToExpected::new() 35 | .unordered() 36 | .normalize(input.into_data(), &pattern.into_data()); 37 | assert_eq!(actual, expected.into_data()); 38 | } 39 | 40 | #[test] 41 | fn str_normalize_reverse_order() { 42 | let input = "1 43 | 2 44 | 3 45 | "; 46 | let pattern = "3 47 | 2 48 | 1 49 | "; 50 | let expected = "3 51 | 2 52 | 1 53 | "; 54 | let actual = NormalizeToExpected::new() 55 | .unordered() 56 | .normalize(input.into_data(), &pattern.into_data()); 57 | assert_eq!(actual, expected.into_data()); 58 | } 59 | 60 | #[test] 61 | fn str_normalize_actual_missing() { 62 | let input = "1 63 | 3 64 | "; 65 | let pattern = "1 66 | 2 67 | 3 68 | "; 69 | let expected = "1 70 | 3 71 | "; 72 | let actual = NormalizeToExpected::new() 73 | .unordered() 74 | .normalize(input.into_data(), &pattern.into_data()); 75 | assert_eq!(actual, expected.into_data()); 76 | } 77 | 78 | #[test] 79 | fn str_normalize_expected_missing() { 80 | let input = "1 81 | 2 82 | 3 83 | "; 84 | let pattern = "1 85 | 3 86 | "; 87 | let expected = "1 88 | 3 89 | 2 90 | "; 91 | let actual = NormalizeToExpected::new() 92 | .unordered() 93 | .normalize(input.into_data(), &pattern.into_data()); 94 | assert_eq!(actual, expected.into_data()); 95 | } 96 | 97 | #[test] 98 | fn str_normalize_actual_duplicated() { 99 | let input = "1 100 | 2 101 | 2 102 | 3 103 | "; 104 | let pattern = "1 105 | 2 106 | 3 107 | "; 108 | let expected = "1 109 | 2 110 | 3 111 | 2 112 | "; 113 | let actual = NormalizeToExpected::new() 114 | .unordered() 115 | .normalize(input.into_data(), &pattern.into_data()); 116 | assert_eq!(actual, expected.into_data()); 117 | } 118 | 119 | #[test] 120 | fn str_normalize_expected_duplicated() { 121 | let input = "1 122 | 2 123 | 3 124 | "; 125 | let pattern = "1 126 | 2 127 | 2 128 | 3 129 | "; 130 | let expected = "1 131 | 2 132 | 3 133 | "; 134 | let actual = NormalizeToExpected::new() 135 | .unordered() 136 | .normalize(input.into_data(), &pattern.into_data()); 137 | assert_eq!(actual, expected.into_data()); 138 | } 139 | 140 | #[test] 141 | #[cfg(feature = "json")] 142 | fn json_normalize_empty() { 143 | let input = json!([]); 144 | let pattern = json!([]); 145 | let expected = json!([]); 146 | let actual = NormalizeToExpected::new() 147 | .unordered() 148 | .normalize(Data::json(input), &Data::json(pattern)); 149 | assert_eq!(actual, Data::json(expected)); 150 | } 151 | 152 | #[test] 153 | #[cfg(feature = "json")] 154 | fn json_normalize_same_order() { 155 | let input = json!([1, 2, 3]); 156 | let pattern = json!([1, 2, 3]); 157 | let expected = json!([1, 2, 3]); 158 | let actual = NormalizeToExpected::new() 159 | .unordered() 160 | .normalize(Data::json(input), &Data::json(pattern)); 161 | assert_eq!(actual, Data::json(expected)); 162 | } 163 | 164 | #[test] 165 | #[cfg(feature = "json")] 166 | fn json_normalize_reverse_order() { 167 | let input = json!([1, 2, 3]); 168 | let pattern = json!([3, 2, 1]); 169 | let expected = json!([3, 2, 1]); 170 | let actual = NormalizeToExpected::new() 171 | .unordered() 172 | .normalize(Data::json(input), &Data::json(pattern)); 173 | assert_eq!(actual, Data::json(expected)); 174 | } 175 | 176 | #[test] 177 | #[cfg(feature = "json")] 178 | fn json_normalize_actual_missing() { 179 | let input = json!([1, 3]); 180 | let pattern = json!([1, 2, 3]); 181 | let expected = json!([1, 3]); 182 | let actual = NormalizeToExpected::new() 183 | .unordered() 184 | .normalize(Data::json(input), &Data::json(pattern)); 185 | assert_eq!(actual, Data::json(expected)); 186 | } 187 | 188 | #[test] 189 | #[cfg(feature = "json")] 190 | fn json_normalize_expected_missing() { 191 | let input = json!([1, 2, 3]); 192 | let pattern = json!([1, 3]); 193 | let expected = json!([1, 3, 2]); 194 | let actual = NormalizeToExpected::new() 195 | .unordered() 196 | .normalize(Data::json(input), &Data::json(pattern)); 197 | assert_eq!(actual, Data::json(expected)); 198 | } 199 | 200 | #[test] 201 | #[cfg(feature = "json")] 202 | fn json_normalize_actual_duplicated() { 203 | let input = json!([1, 2, 2, 3]); 204 | let pattern = json!([1, 2, 3]); 205 | let expected = json!([1, 2, 3, 2]); 206 | let actual = NormalizeToExpected::new() 207 | .unordered() 208 | .normalize(Data::json(input), &Data::json(pattern)); 209 | assert_eq!(actual, Data::json(expected)); 210 | } 211 | 212 | #[test] 213 | #[cfg(feature = "json")] 214 | fn json_normalize_expected_duplicated() { 215 | let input = json!([1, 2, 3]); 216 | let pattern = json!([1, 2, 2, 3]); 217 | let expected = json!([1, 2, 3]); 218 | let actual = NormalizeToExpected::new() 219 | .unordered() 220 | .normalize(Data::json(input), &Data::json(pattern)); 221 | assert_eq!(actual, Data::json(expected)); 222 | } 223 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | pull_request: 8 | push: 9 | branches: 10 | - main 11 | 12 | env: 13 | RUST_BACKTRACE: 1 14 | CARGO_TERM_COLOR: always 15 | CLICOLOR: 1 16 | 17 | concurrency: 18 | group: "${{ github.workflow }}-${{ github.ref }}" 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | ci: 23 | permissions: 24 | contents: none 25 | name: CI 26 | needs: [test, msrv, lockfile, docs, rustfmt, clippy, minimal-versions] 27 | runs-on: ubuntu-latest 28 | if: "always()" 29 | steps: 30 | - name: Failed 31 | run: exit 1 32 | if: "contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')" 33 | test: 34 | name: Test 35 | strategy: 36 | matrix: 37 | os: ["ubuntu-latest", "windows-latest", "macos-latest"] 38 | rust: ["stable"] 39 | continue-on-error: ${{ matrix.rust != 'stable' }} 40 | runs-on: ${{ matrix.os }} 41 | env: 42 | # Reduce amount of data cached 43 | CARGO_PROFILE_DEV_DEBUG: line-tables-only 44 | steps: 45 | - name: Checkout repository 46 | uses: actions/checkout@v6 47 | - name: Install Rust 48 | uses: dtolnay/rust-toolchain@stable 49 | with: 50 | toolchain: ${{ matrix.rust }} 51 | - uses: Swatinem/rust-cache@v2 52 | - uses: taiki-e/install-action@cargo-hack 53 | - name: Build 54 | run: cargo test --workspace --no-run 55 | - name: Test 56 | run: cargo hack test --each-feature --workspace 57 | msrv: 58 | name: "Check MSRV" 59 | runs-on: ubuntu-latest 60 | steps: 61 | - name: Checkout repository 62 | uses: actions/checkout@v6 63 | - name: Install Rust 64 | uses: dtolnay/rust-toolchain@stable 65 | with: 66 | toolchain: stable 67 | - uses: Swatinem/rust-cache@v2 68 | - uses: taiki-e/install-action@cargo-hack 69 | - name: Default features 70 | run: cargo hack check --each-feature --locked --rust-version --ignore-private --workspace --all-targets --keep-going 71 | minimal-versions: 72 | name: Minimal versions 73 | runs-on: ubuntu-latest 74 | steps: 75 | - name: Checkout repository 76 | uses: actions/checkout@v6 77 | - name: Install stable Rust 78 | uses: dtolnay/rust-toolchain@stable 79 | with: 80 | toolchain: stable 81 | - name: Install nightly Rust 82 | uses: dtolnay/rust-toolchain@stable 83 | with: 84 | toolchain: nightly 85 | - name: Downgrade dependencies to minimal versions 86 | run: cargo +nightly generate-lockfile -Z minimal-versions 87 | - name: Compile with minimal versions 88 | run: cargo +stable check --workspace --all-features --locked --keep-going 89 | lockfile: 90 | runs-on: ubuntu-latest 91 | steps: 92 | - name: Checkout repository 93 | uses: actions/checkout@v6 94 | - name: Install Rust 95 | uses: dtolnay/rust-toolchain@stable 96 | with: 97 | toolchain: stable 98 | - uses: Swatinem/rust-cache@v2 99 | - name: "Is lockfile updated?" 100 | run: cargo update --workspace --locked 101 | docs: 102 | name: Docs 103 | runs-on: ubuntu-latest 104 | steps: 105 | - name: Checkout repository 106 | uses: actions/checkout@v6 107 | - name: Install Rust 108 | uses: dtolnay/rust-toolchain@stable 109 | with: 110 | toolchain: "1.92" # STABLE 111 | - uses: Swatinem/rust-cache@v2 112 | - name: Check documentation 113 | env: 114 | RUSTDOCFLAGS: -D warnings 115 | run: cargo doc --workspace --all-features --no-deps --document-private-items --keep-going 116 | rustfmt: 117 | name: rustfmt 118 | runs-on: ubuntu-latest 119 | steps: 120 | - name: Checkout repository 121 | uses: actions/checkout@v6 122 | - name: Install Rust 123 | uses: dtolnay/rust-toolchain@stable 124 | with: 125 | toolchain: "1.92" # STABLE 126 | components: rustfmt 127 | - uses: Swatinem/rust-cache@v2 128 | - name: Check formatting 129 | run: cargo fmt --all -- --check 130 | clippy: 131 | name: clippy 132 | runs-on: ubuntu-latest 133 | permissions: 134 | security-events: write # to upload sarif results 135 | steps: 136 | - name: Checkout repository 137 | uses: actions/checkout@v6 138 | - name: Install Rust 139 | uses: dtolnay/rust-toolchain@stable 140 | with: 141 | toolchain: "1.92" # STABLE 142 | components: clippy 143 | - uses: Swatinem/rust-cache@v2 144 | - name: Install SARIF tools 145 | run: cargo install clippy-sarif --locked 146 | - name: Install SARIF tools 147 | run: cargo install sarif-fmt --locked 148 | - name: Check 149 | run: > 150 | cargo clippy --workspace --all-features --all-targets --message-format=json 151 | | clippy-sarif 152 | | tee clippy-results.sarif 153 | | sarif-fmt 154 | continue-on-error: true 155 | - name: Upload 156 | uses: github/codeql-action/upload-sarif@v4 157 | with: 158 | sarif_file: clippy-results.sarif 159 | wait-for-processing: true 160 | - name: Report status 161 | run: cargo clippy --workspace --all-features --all-targets --keep-going -- -D warnings --allow deprecated 162 | coverage: 163 | name: Coverage 164 | runs-on: ubuntu-latest 165 | steps: 166 | - name: Checkout repository 167 | uses: actions/checkout@v6 168 | - name: Install Rust 169 | uses: dtolnay/rust-toolchain@stable 170 | with: 171 | toolchain: stable 172 | - uses: Swatinem/rust-cache@v2 173 | - name: Install cargo-tarpaulin 174 | run: cargo install cargo-tarpaulin 175 | - name: Gather coverage 176 | run: cargo tarpaulin --output-dir coverage --out lcov 177 | - name: Publish to Coveralls 178 | uses: coverallsapp/github-action@master 179 | with: 180 | github-token: ${{ secrets.GITHUB_TOKEN }} 181 | -------------------------------------------------------------------------------- /crates/trycmd/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "OneShot", 4 | "description": "Top-level data in `cmd.toml` files", 5 | "type": "object", 6 | "properties": { 7 | "bin": { 8 | "anyOf": [ 9 | { 10 | "$ref": "#/definitions/Bin" 11 | }, 12 | { 13 | "type": "null" 14 | } 15 | ] 16 | }, 17 | "args": { 18 | "default": [], 19 | "allOf": [ 20 | { 21 | "$ref": "#/definitions/Args" 22 | } 23 | ] 24 | }, 25 | "env": { 26 | "default": { 27 | "add": {}, 28 | "inherit": null, 29 | "remove": [] 30 | }, 31 | "allOf": [ 32 | { 33 | "$ref": "#/definitions/Env" 34 | } 35 | ] 36 | }, 37 | "stdin": { 38 | "default": null, 39 | "type": [ 40 | "string", 41 | "null" 42 | ] 43 | }, 44 | "stdout": { 45 | "default": null, 46 | "type": [ 47 | "string", 48 | "null" 49 | ] 50 | }, 51 | "stderr": { 52 | "default": null, 53 | "type": [ 54 | "string", 55 | "null" 56 | ] 57 | }, 58 | "stderr-to-stdout": { 59 | "default": false, 60 | "type": "boolean" 61 | }, 62 | "status": { 63 | "anyOf": [ 64 | { 65 | "$ref": "#/definitions/CommandStatus" 66 | }, 67 | { 68 | "type": "null" 69 | } 70 | ] 71 | }, 72 | "binary": { 73 | "default": false, 74 | "type": "boolean" 75 | }, 76 | "timeout": { 77 | "default": null, 78 | "anyOf": [ 79 | { 80 | "$ref": "#/definitions/Duration" 81 | }, 82 | { 83 | "type": "null" 84 | } 85 | ] 86 | }, 87 | "fs": { 88 | "default": { 89 | "base": null, 90 | "cwd": null, 91 | "sandbox": null 92 | }, 93 | "allOf": [ 94 | { 95 | "$ref": "#/definitions/Filesystem" 96 | } 97 | ] 98 | } 99 | }, 100 | "definitions": { 101 | "Bin": { 102 | "description": "Target under test", 103 | "oneOf": [ 104 | { 105 | "type": "string", 106 | "enum": [ 107 | "ignore" 108 | ] 109 | }, 110 | { 111 | "type": "object", 112 | "required": [ 113 | "path" 114 | ], 115 | "properties": { 116 | "path": { 117 | "type": "string" 118 | } 119 | }, 120 | "additionalProperties": false 121 | }, 122 | { 123 | "type": "object", 124 | "required": [ 125 | "name" 126 | ], 127 | "properties": { 128 | "name": { 129 | "type": "string" 130 | } 131 | }, 132 | "additionalProperties": false 133 | } 134 | ] 135 | }, 136 | "Args": { 137 | "anyOf": [ 138 | { 139 | "$ref": "#/definitions/JoinedArgs" 140 | }, 141 | { 142 | "type": "array", 143 | "items": { 144 | "type": "string" 145 | } 146 | } 147 | ] 148 | }, 149 | "JoinedArgs": { 150 | "type": "object", 151 | "required": [ 152 | "inner" 153 | ], 154 | "properties": { 155 | "inner": { 156 | "type": "array", 157 | "items": { 158 | "type": "string" 159 | } 160 | } 161 | } 162 | }, 163 | "Env": { 164 | "description": "Describe command's environment", 165 | "type": "object", 166 | "properties": { 167 | "inherit": { 168 | "default": null, 169 | "type": [ 170 | "boolean", 171 | "null" 172 | ] 173 | }, 174 | "add": { 175 | "default": {}, 176 | "type": "object", 177 | "additionalProperties": { 178 | "type": "string" 179 | } 180 | }, 181 | "remove": { 182 | "default": [], 183 | "type": "array", 184 | "items": { 185 | "type": "string" 186 | } 187 | } 188 | } 189 | }, 190 | "CommandStatus": { 191 | "description": "Expected status for command", 192 | "oneOf": [ 193 | { 194 | "type": "string", 195 | "enum": [ 196 | "success", 197 | "failed", 198 | "interrupted", 199 | "skipped" 200 | ] 201 | }, 202 | { 203 | "type": "object", 204 | "required": [ 205 | "code" 206 | ], 207 | "properties": { 208 | "code": { 209 | "type": "integer", 210 | "format": "int32" 211 | } 212 | }, 213 | "additionalProperties": false 214 | } 215 | ] 216 | }, 217 | "Duration": { 218 | "type": "object", 219 | "required": [ 220 | "nanos", 221 | "secs" 222 | ], 223 | "properties": { 224 | "secs": { 225 | "type": "integer", 226 | "format": "uint64", 227 | "minimum": 0.0 228 | }, 229 | "nanos": { 230 | "type": "integer", 231 | "format": "uint32", 232 | "minimum": 0.0 233 | } 234 | } 235 | }, 236 | "Filesystem": { 237 | "description": "Describe the command's filesystem context", 238 | "type": "object", 239 | "properties": { 240 | "cwd": { 241 | "type": [ 242 | "string", 243 | "null" 244 | ] 245 | }, 246 | "base": { 247 | "description": "Sandbox base", 248 | "type": [ 249 | "string", 250 | "null" 251 | ] 252 | }, 253 | "sandbox": { 254 | "type": [ 255 | "boolean", 256 | "null" 257 | ] 258 | } 259 | } 260 | } 261 | } 262 | } -------------------------------------------------------------------------------- /crates/snapbox/src/filter/mod.rs: -------------------------------------------------------------------------------- 1 | //! Filter `actual` or `expected` [`Data`] 2 | //! 3 | //! This can be done for 4 | //! - Making snapshots consistent across platforms or conditional compilation 5 | //! - Focusing snapshots on the characteristics of the data being tested 6 | 7 | mod pattern; 8 | mod redactions; 9 | #[cfg(test)] 10 | mod test; 11 | #[cfg(test)] 12 | mod test_redactions; 13 | #[cfg(test)] 14 | mod test_unordered_redactions; 15 | 16 | use crate::data::DataInner; 17 | use crate::Data; 18 | 19 | pub use pattern::NormalizeToExpected; 20 | pub use redactions::RedactedValue; 21 | pub use redactions::Redactions; 22 | 23 | pub trait Filter { 24 | fn filter(&self, data: Data) -> Data; 25 | } 26 | 27 | pub struct FilterNewlines; 28 | impl Filter for FilterNewlines { 29 | fn filter(&self, data: Data) -> Data { 30 | let source = data.source; 31 | let filters = data.filters; 32 | let inner = match data.inner { 33 | DataInner::Error(err) => DataInner::Error(err), 34 | DataInner::Binary(bin) => DataInner::Binary(bin), 35 | DataInner::Text(text) => { 36 | let lines = normalize_lines(&text); 37 | DataInner::Text(lines) 38 | } 39 | #[cfg(feature = "json")] 40 | DataInner::Json(value) => { 41 | let mut value = value; 42 | normalize_json_string(&mut value, &normalize_lines); 43 | DataInner::Json(value) 44 | } 45 | #[cfg(feature = "json")] 46 | DataInner::JsonLines(value) => { 47 | let mut value = value; 48 | normalize_json_string(&mut value, &normalize_lines); 49 | DataInner::JsonLines(value) 50 | } 51 | #[cfg(feature = "term-svg")] 52 | DataInner::TermSvg(text) => { 53 | let lines = normalize_lines(&text); 54 | DataInner::TermSvg(lines) 55 | } 56 | }; 57 | Data { 58 | inner, 59 | source, 60 | filters, 61 | } 62 | } 63 | } 64 | 65 | /// Normalize line endings 66 | pub fn normalize_lines(data: &str) -> String { 67 | normalize_lines_chars(data.chars()).collect() 68 | } 69 | 70 | fn normalize_lines_chars(data: impl Iterator) -> impl Iterator { 71 | normalize_line_endings::normalized(data) 72 | } 73 | 74 | pub struct FilterPaths; 75 | impl Filter for FilterPaths { 76 | fn filter(&self, data: Data) -> Data { 77 | let source = data.source; 78 | let filters = data.filters; 79 | let inner = match data.inner { 80 | DataInner::Error(err) => DataInner::Error(err), 81 | DataInner::Binary(bin) => DataInner::Binary(bin), 82 | DataInner::Text(text) => { 83 | let lines = normalize_paths(&text); 84 | DataInner::Text(lines) 85 | } 86 | #[cfg(feature = "json")] 87 | DataInner::Json(value) => { 88 | let mut value = value; 89 | normalize_json_string(&mut value, &normalize_paths); 90 | DataInner::Json(value) 91 | } 92 | #[cfg(feature = "json")] 93 | DataInner::JsonLines(value) => { 94 | let mut value = value; 95 | normalize_json_string(&mut value, &normalize_paths); 96 | DataInner::JsonLines(value) 97 | } 98 | #[cfg(feature = "term-svg")] 99 | DataInner::TermSvg(text) => { 100 | let lines = normalize_paths(&text); 101 | DataInner::TermSvg(lines) 102 | } 103 | }; 104 | Data { 105 | inner, 106 | source, 107 | filters, 108 | } 109 | } 110 | } 111 | 112 | /// Normalize path separators 113 | /// 114 | /// [`std::path::MAIN_SEPARATOR`] can vary by platform, so make it consistent 115 | /// 116 | /// Note: this cannot distinguish between when a character is being used as a path separator or not 117 | /// and can "normalize" unrelated data 118 | pub fn normalize_paths(data: &str) -> String { 119 | normalize_paths_chars(data.chars()).collect() 120 | } 121 | 122 | fn normalize_paths_chars(data: impl Iterator) -> impl Iterator { 123 | data.map(|c| if c == '\\' { '/' } else { c }) 124 | } 125 | 126 | struct NormalizeRedactions<'r> { 127 | redactions: &'r Redactions, 128 | } 129 | impl Filter for NormalizeRedactions<'_> { 130 | fn filter(&self, data: Data) -> Data { 131 | let source = data.source; 132 | let filters = data.filters; 133 | let inner = match data.inner { 134 | DataInner::Error(err) => DataInner::Error(err), 135 | DataInner::Binary(bin) => DataInner::Binary(bin), 136 | DataInner::Text(text) => { 137 | let lines = self.redactions.redact(&text); 138 | DataInner::Text(lines) 139 | } 140 | #[cfg(feature = "json")] 141 | DataInner::Json(value) => { 142 | let mut value = value; 143 | normalize_json_string(&mut value, &|s| self.redactions.redact(s)); 144 | DataInner::Json(value) 145 | } 146 | #[cfg(feature = "json")] 147 | DataInner::JsonLines(value) => { 148 | let mut value = value; 149 | normalize_json_string(&mut value, &|s| self.redactions.redact(s)); 150 | DataInner::JsonLines(value) 151 | } 152 | #[cfg(feature = "term-svg")] 153 | DataInner::TermSvg(text) => { 154 | let lines = self.redactions.redact(&text); 155 | DataInner::TermSvg(lines) 156 | } 157 | }; 158 | Data { 159 | inner, 160 | source, 161 | filters, 162 | } 163 | } 164 | } 165 | 166 | #[cfg(feature = "structured-data")] 167 | fn normalize_json_string(value: &mut serde_json::Value, op: &dyn Fn(&str) -> String) { 168 | match value { 169 | serde_json::Value::String(str) => { 170 | *str = op(str); 171 | } 172 | serde_json::Value::Array(arr) => { 173 | for value in arr.iter_mut() { 174 | normalize_json_string(value, op); 175 | } 176 | } 177 | serde_json::Value::Object(obj) => { 178 | for (key, mut value) in std::mem::replace(obj, serde_json::Map::new()) { 179 | let key = op(&key); 180 | normalize_json_string(&mut value, op); 181 | obj.insert(key, value); 182 | } 183 | } 184 | _ => {} 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /crates/tryfn/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! [`Harness`] for discovering test inputs and asserting against snapshot files 2 | //! 3 | //! This is a custom test harness and should be put in its own test binary with 4 | //! [`test.harness = false`](https://doc.rust-lang.org/stable/cargo/reference/cargo-targets.html#the-harness-field). 5 | //! 6 | //! # Examples 7 | //! 8 | //! ```rust,no_run 9 | //! fn some_func(num: usize) -> usize { 10 | //! // ... 11 | //! # 10 12 | //! } 13 | //! 14 | //! tryfn::Harness::new( 15 | //! "tests/fixtures/invalid", 16 | //! setup, 17 | //! test, 18 | //! ) 19 | //! .select(["tests/cases/*.in"]) 20 | //! .test(); 21 | //! 22 | //! fn setup(input_path: std::path::PathBuf) -> tryfn::Case { 23 | //! let name = input_path.file_name().unwrap().to_str().unwrap().to_owned(); 24 | //! let expected = tryfn::Data::read_from(&input_path.with_extension("out"), None); 25 | //! tryfn::Case { 26 | //! name, 27 | //! fixture: input_path, 28 | //! expected, 29 | //! } 30 | //! } 31 | //! 32 | //! fn test(input_path: &std::path::Path) -> Result> { 33 | //! let raw = std::fs::read_to_string(input_path)?; 34 | //! let num = raw.parse::()?; 35 | //! 36 | //! let actual = some_func(num); 37 | //! 38 | //! Ok(actual) 39 | //! } 40 | //! ``` 41 | 42 | #![cfg_attr(docsrs, feature(doc_cfg))] 43 | #![warn(clippy::print_stderr)] 44 | #![warn(clippy::print_stdout)] 45 | 46 | use libtest_mimic::Trial; 47 | 48 | pub use snapbox::data::DataFormat; 49 | pub use snapbox::Data; 50 | 51 | /// [`Harness`] for discovering test inputs and asserting against snapshot files 52 | pub struct Harness { 53 | root: std::path::PathBuf, 54 | overrides: Option, 55 | setup: S, 56 | test: T, 57 | config: snapbox::Assert, 58 | test_output: std::marker::PhantomData, 59 | test_error: std::marker::PhantomData, 60 | } 61 | 62 | impl Harness 63 | where 64 | S: Setup + Send + Sync + 'static, 65 | T: Test + Clone + Send + Sync + 'static, 66 | I: std::fmt::Display, 67 | E: std::fmt::Display, 68 | { 69 | /// Specify where the test scenarios 70 | /// 71 | /// - `input_root`: where to find the files. See [`Self::select`] for restricting what files 72 | /// are considered 73 | /// - `setup`: Given a path, choose the test name and the output location 74 | /// - `test`: Given a path, return the actual output value 75 | /// 76 | /// By default filters are applied, including: 77 | /// - `...` is a line-wildcard when on a line by itself 78 | /// - `[..]` is a character-wildcard when inside a line 79 | /// - `[EXE]` matches `.exe` on Windows 80 | /// - `"{...}"` is a JSON value wildcard 81 | /// - `"...": "{...}"` is a JSON key-value wildcard 82 | /// - `\` to `/` 83 | /// - Newlines 84 | /// 85 | /// To limit this to newline normalization for text, have [`Setup`] call [`Data::raw`][snapbox::Data::raw] on `expected`. 86 | pub fn new(input_root: impl Into, setup: S, test: T) -> Self { 87 | Self { 88 | root: input_root.into(), 89 | overrides: None, 90 | setup, 91 | test, 92 | config: snapbox::Assert::new().action_env(snapbox::assert::DEFAULT_ACTION_ENV), 93 | test_output: Default::default(), 94 | test_error: Default::default(), 95 | } 96 | } 97 | 98 | /// Path patterns for selecting input files 99 | /// 100 | /// This uses gitignore syntax 101 | pub fn select<'p>(mut self, patterns: impl IntoIterator) -> Self { 102 | let mut overrides = ignore::overrides::OverrideBuilder::new(&self.root); 103 | for line in patterns { 104 | overrides.add(line).unwrap(); 105 | } 106 | self.overrides = Some(overrides.build().unwrap()); 107 | self 108 | } 109 | 110 | /// Customize the assertion behavior 111 | /// 112 | /// Includes 113 | /// - Configuring redactions 114 | /// - Override updating environment vaeiable 115 | pub fn with_assert(mut self, config: snapbox::Assert) -> Self { 116 | self.config = config; 117 | self 118 | } 119 | 120 | /// Run tests 121 | pub fn test(self) -> ! { 122 | let mut walk = ignore::WalkBuilder::new(&self.root); 123 | walk.standard_filters(false); 124 | let tests = walk.build().filter_map(|entry| { 125 | let entry = entry.unwrap(); 126 | let is_dir = entry.file_type().map(|f| f.is_dir()).unwrap_or(false); 127 | let path = entry.into_path(); 128 | if let Some(overrides) = &self.overrides { 129 | overrides 130 | .matched(&path, is_dir) 131 | .is_whitelist() 132 | .then_some(path) 133 | } else { 134 | Some(path) 135 | } 136 | }); 137 | 138 | let shared_config = std::sync::Arc::new(self.config); 139 | let tests: Vec<_> = tests 140 | .into_iter() 141 | .map(|path| { 142 | let case = self.setup.setup(path); 143 | assert!( 144 | case.expected.source().map(|s| s.is_path()).unwrap_or(false), 145 | "`Case::expected` must be from a file" 146 | ); 147 | let test = self.test.clone(); 148 | let config = shared_config.clone(); 149 | Trial::test(case.name.clone(), move || { 150 | let actual = test.run(&case.fixture)?; 151 | let actual = actual.to_string(); 152 | let actual = snapbox::Data::text(actual); 153 | config.try_eq(Some(&case.name), actual, case.expected.clone())?; 154 | Ok(()) 155 | }) 156 | .with_ignored_flag( 157 | shared_config.selected_action() == snapbox::assert::Action::Ignore, 158 | ) 159 | }) 160 | .collect(); 161 | 162 | let args = libtest_mimic::Arguments::from_args(); 163 | libtest_mimic::run(&args, tests).exit() 164 | } 165 | } 166 | 167 | /// Function signature for generating a test [`Case`] from a path fixture 168 | pub trait Setup { 169 | fn setup(&self, fixture: std::path::PathBuf) -> Case; 170 | } 171 | 172 | impl Setup for F 173 | where 174 | F: Fn(std::path::PathBuf) -> Case, 175 | { 176 | fn setup(&self, fixture: std::path::PathBuf) -> Case { 177 | (self)(fixture) 178 | } 179 | } 180 | 181 | /// Function signature for running a test [`Case`] 182 | pub trait Test 183 | where 184 | S: std::fmt::Display, 185 | E: std::fmt::Display, 186 | { 187 | fn run(&self, fixture: &std::path::Path) -> Result; 188 | } 189 | 190 | impl Test for F 191 | where 192 | F: Fn(&std::path::Path) -> Result, 193 | S: std::fmt::Display, 194 | E: std::fmt::Display, 195 | { 196 | fn run(&self, fixture: &std::path::Path) -> Result { 197 | (self)(fixture) 198 | } 199 | } 200 | 201 | /// A test case enumerated by the [`Harness`] with data from the [`Setup`] function 202 | pub struct Case { 203 | /// Display name 204 | pub name: String, 205 | /// Input for the test 206 | pub fixture: std::path::PathBuf, 207 | /// What the actual output should be compared against or updated 208 | /// 209 | /// Generally derived from `fixture` and loaded with [`Data::read_from`] 210 | pub expected: Data, 211 | } 212 | 213 | #[doc = include_str!("../README.md")] 214 | #[cfg(doctest)] 215 | pub struct ReadmeDoctests; 216 | -------------------------------------------------------------------------------- /crates/snapbox/src/data/tests.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "json")] 2 | use serde_json::json; 3 | 4 | use super::*; 5 | 6 | #[test] 7 | #[cfg(feature = "term-svg")] 8 | fn term_svg_eq() { 9 | let left = Data::with_inner(DataInner::TermSvg( 10 | " 11 | irrelevant 12 | relevant 13 | 14 | 15 | irrelevant" 16 | .to_owned(), 17 | )); 18 | let right = Data::with_inner(DataInner::TermSvg( 19 | " 20 | irrelevant 21 | relevant 22 | 23 | 24 | irrelevant" 25 | .to_owned(), 26 | )); 27 | assert_eq!(left, right); 28 | 29 | let left = Data::with_inner(DataInner::TermSvg( 30 | " 31 | irrelevant 1 32 | relevant 33 | 34 | 35 | irrelevant 1" 36 | .to_owned(), 37 | )); 38 | let right = Data::with_inner(DataInner::TermSvg( 39 | " 40 | irrelevant 2 41 | relevant 42 | 43 | 44 | irrelevant 2" 45 | .to_owned(), 46 | )); 47 | assert_eq!(left, right); 48 | } 49 | 50 | #[test] 51 | #[cfg(feature = "term-svg")] 52 | fn term_svg_ne() { 53 | let left = Data::with_inner(DataInner::TermSvg( 54 | " 55 | irrelevant 1 56 | relevant 1 57 | 58 | 59 | irrelevant 1" 60 | .to_owned(), 61 | )); 62 | let right = Data::with_inner(DataInner::TermSvg( 63 | " 64 | irrelevant 2 65 | relevant 2 66 | 67 | 68 | irrelevant 2" 69 | .to_owned(), 70 | )); 71 | assert_ne!(left, right); 72 | } 73 | 74 | // Tests for checking to_bytes and render produce the same results 75 | #[test] 76 | fn text_to_bytes_render() { 77 | let d = Data::text(String::from("test")); 78 | let bytes = d.to_bytes().unwrap(); 79 | let bytes = String::from_utf8(bytes).unwrap(); 80 | let rendered = d.render().unwrap(); 81 | assert_eq!(bytes, rendered); 82 | } 83 | 84 | #[test] 85 | #[cfg(feature = "json")] 86 | fn json_to_bytes_render() { 87 | let d = Data::json(json!({"name": "John\\Doe\r\n"})); 88 | let bytes = d.to_bytes().unwrap(); 89 | let bytes = String::from_utf8(bytes).unwrap(); 90 | let rendered = d.render().unwrap(); 91 | assert_eq!(bytes, rendered); 92 | } 93 | 94 | // Tests for checking all types are coercible to each other and 95 | // for when the coercion should fail 96 | #[test] 97 | fn binary_to_text() { 98 | let binary = String::from("test").into_bytes(); 99 | let d = Data::binary(binary); 100 | let text = d.coerce_to(DataFormat::Text); 101 | assert_eq!(DataFormat::Text, text.format()); 102 | } 103 | 104 | #[test] 105 | fn binary_to_text_not_utf8() { 106 | let binary = b"\xFF\xE0\x00\x10\x4A\x46\x49\x46\x00".to_vec(); 107 | let d = Data::binary(binary); 108 | let d = d.coerce_to(DataFormat::Text); 109 | assert_ne!(DataFormat::Text, d.format()); 110 | assert_eq!(DataFormat::Binary, d.format()); 111 | } 112 | 113 | #[test] 114 | #[cfg(feature = "json")] 115 | fn binary_to_json() { 116 | let value = json!({"name": "John\\Doe\r\n"}); 117 | let binary = serde_json::to_vec_pretty(&value).unwrap(); 118 | let d = Data::binary(binary); 119 | let json = d.coerce_to(DataFormat::Json); 120 | assert_eq!(DataFormat::Json, json.format()); 121 | } 122 | 123 | #[test] 124 | #[cfg(feature = "json")] 125 | fn binary_to_json_not_utf8() { 126 | let binary = b"\xFF\xE0\x00\x10\x4A\x46\x49\x46\x00".to_vec(); 127 | let d = Data::binary(binary); 128 | let d = d.coerce_to(DataFormat::Json); 129 | assert_ne!(DataFormat::Json, d.format()); 130 | assert_eq!(DataFormat::Binary, d.format()); 131 | } 132 | 133 | #[test] 134 | #[cfg(feature = "json")] 135 | fn binary_to_json_not_json() { 136 | let binary = String::from("test").into_bytes(); 137 | let d = Data::binary(binary); 138 | let d = d.coerce_to(DataFormat::Json); 139 | assert_ne!(DataFormat::Json, d.format()); 140 | assert_eq!(DataFormat::Binary, d.format()); 141 | } 142 | 143 | #[test] 144 | fn text_to_binary() { 145 | let text = String::from("test"); 146 | let d = Data::text(text); 147 | let binary = d.coerce_to(DataFormat::Binary); 148 | assert_eq!(DataFormat::Binary, binary.format()); 149 | } 150 | 151 | #[test] 152 | #[cfg(feature = "json")] 153 | fn text_to_json() { 154 | let value = json!({"name": "John\\Doe\r\n"}); 155 | let text = serde_json::to_string_pretty(&value).unwrap(); 156 | let d = Data::text(text); 157 | let json = d.coerce_to(DataFormat::Json); 158 | assert_eq!(DataFormat::Json, json.format()); 159 | } 160 | 161 | #[test] 162 | #[cfg(feature = "json")] 163 | fn text_to_json_not_json() { 164 | let text = String::from("test"); 165 | let d = Data::text(text); 166 | let json = d.coerce_to(DataFormat::Json); 167 | assert_eq!(DataFormat::Text, json.format()); 168 | } 169 | 170 | #[test] 171 | #[cfg(feature = "json")] 172 | fn json_to_binary() { 173 | let value = json!({"name": "John\\Doe\r\n"}); 174 | let d = Data::json(value); 175 | let binary = d.coerce_to(DataFormat::Binary); 176 | assert_eq!(DataFormat::Binary, binary.format()); 177 | } 178 | 179 | #[test] 180 | #[cfg(feature = "json")] 181 | fn json_to_text() { 182 | let value = json!({"name": "John\\Doe\r\n"}); 183 | let d = Data::json(value); 184 | let text = d.coerce_to(DataFormat::Text); 185 | assert_eq!(DataFormat::Text, text.format()); 186 | } 187 | 188 | // Tests for coercible conversions create the same output as to_bytes/render 189 | // 190 | // render does not need to be checked against bin -> text since render 191 | // outputs None for binary 192 | #[test] 193 | fn text_to_bin_coerce_equals_to_bytes() { 194 | let text = String::from("test"); 195 | let d = Data::text(text); 196 | let binary = d.clone().coerce_to(DataFormat::Binary); 197 | assert_eq!(Data::binary(d.to_bytes().unwrap()), binary); 198 | } 199 | 200 | #[test] 201 | #[cfg(feature = "json")] 202 | fn json_to_bin_coerce_equals_to_bytes() { 203 | let json = json!({"name": "John\\Doe\r\n"}); 204 | let d = Data::json(json); 205 | let binary = d.clone().coerce_to(DataFormat::Binary); 206 | assert_eq!(Data::binary(d.to_bytes().unwrap()), binary); 207 | } 208 | 209 | #[test] 210 | #[cfg(feature = "json")] 211 | fn json_to_text_coerce_equals_render() { 212 | let json = json!({"name": "John\\Doe\r\n"}); 213 | let d = Data::json(json); 214 | let text = d.clone().coerce_to(DataFormat::Text); 215 | assert_eq!(Data::text(d.render().unwrap()), text); 216 | } 217 | 218 | #[cfg(feature = "term-svg")] 219 | mod term_svg_body { 220 | use super::super::*; 221 | 222 | #[test] 223 | fn empty() { 224 | let input = ""; 225 | let expected = None; 226 | let actual = term_svg_body(input); 227 | assert_eq!(expected, actual); 228 | } 229 | 230 | #[test] 231 | fn no_open_tag() { 232 | let input = "hello 233 | 234 | world!"; 235 | let expected = None; 236 | let actual = term_svg_body(input); 237 | assert_eq!(expected, actual); 238 | } 239 | 240 | #[test] 241 | fn unclosed_open_text() { 242 | let input = " 243 | Hello 244 | 261 | world 262 | 263 | ", 264 | ); 265 | let actual = term_svg_body(input); 266 | assert_eq!(expected, actual); 267 | } 268 | 269 | #[test] 270 | fn no_end_tag() { 271 | let input = " 272 | Hello 273 | 274 | world"; 275 | let expected = Some( 276 | " 277 | world", 278 | ); 279 | let actual = term_svg_body(input); 280 | assert_eq!(expected, actual); 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /crates/snapbox/src/dir/ops.rs: -------------------------------------------------------------------------------- 1 | /// Recursively walk a path 2 | /// 3 | /// Note: Ignores `.keep` files 4 | #[cfg(feature = "dir")] 5 | pub struct Walk { 6 | inner: walkdir::IntoIter, 7 | } 8 | 9 | #[cfg(feature = "dir")] 10 | impl Walk { 11 | pub fn new(path: &std::path::Path) -> Self { 12 | Self { 13 | inner: walkdir::WalkDir::new(path).into_iter(), 14 | } 15 | } 16 | } 17 | 18 | #[cfg(feature = "dir")] 19 | impl Iterator for Walk { 20 | type Item = Result; 21 | 22 | fn next(&mut self) -> Option { 23 | while let Some(entry) = self.inner.next().map(|e| { 24 | e.map(walkdir::DirEntry::into_path) 25 | .map_err(std::io::Error::from) 26 | }) { 27 | if entry.as_ref().ok().and_then(|e| e.file_name()) 28 | != Some(std::ffi::OsStr::new(".keep")) 29 | { 30 | return Some(entry); 31 | } 32 | } 33 | None 34 | } 35 | } 36 | 37 | /// Copy a template into a [`DirRoot`][super::DirRoot] 38 | /// 39 | /// Note: Generally you'll use [`DirRoot::with_template`][super::DirRoot::with_template] instead. 40 | /// 41 | /// Note: Ignores `.keep` files 42 | #[cfg(feature = "dir")] 43 | pub fn copy_template( 44 | source: impl AsRef, 45 | dest: impl AsRef, 46 | ) -> Result<(), crate::assert::Error> { 47 | let source = source.as_ref(); 48 | let dest = dest.as_ref(); 49 | let source = canonicalize(source) 50 | .map_err(|e| format!("Failed to canonicalize {}: {}", source.display(), e))?; 51 | std::fs::create_dir_all(dest) 52 | .map_err(|e| format!("Failed to create {}: {}", dest.display(), e))?; 53 | let dest = canonicalize(dest) 54 | .map_err(|e| format!("Failed to canonicalize {}: {}", dest.display(), e))?; 55 | 56 | for current in Walk::new(&source) { 57 | let current = current.map_err(|e| e.to_string())?; 58 | let rel = current.strip_prefix(&source).unwrap(); 59 | let target = dest.join(rel); 60 | 61 | shallow_copy(¤t, &target)?; 62 | } 63 | 64 | Ok(()) 65 | } 66 | 67 | /// Copy a file system entry, without recursing 68 | pub(crate) fn shallow_copy( 69 | source: &std::path::Path, 70 | dest: &std::path::Path, 71 | ) -> Result<(), crate::assert::Error> { 72 | let meta = source 73 | .symlink_metadata() 74 | .map_err(|e| format!("Failed to read metadata from {}: {}", source.display(), e))?; 75 | if meta.is_dir() { 76 | std::fs::create_dir_all(dest) 77 | .map_err(|e| format!("Failed to create {}: {}", dest.display(), e))?; 78 | } else if meta.is_file() { 79 | std::fs::copy(source, dest).map_err(|e| { 80 | format!( 81 | "Failed to copy {} to {}: {}", 82 | source.display(), 83 | dest.display(), 84 | e 85 | ) 86 | })?; 87 | // Avoid a mtime check race where: 88 | // - Copy files 89 | // - Test checks mtime 90 | // - Test writes 91 | // - Test checks mtime 92 | // 93 | // If all of this happens too close to each other, then the second mtime check will think 94 | // nothing was written by the test. 95 | // 96 | // Instead of just setting 1s in the past, we'll just respect the existing mtime. 97 | copy_stats(&meta, dest).map_err(|e| { 98 | format!( 99 | "Failed to copy {} metadata to {}: {}", 100 | source.display(), 101 | dest.display(), 102 | e 103 | ) 104 | })?; 105 | } else if let Ok(target) = std::fs::read_link(source) { 106 | symlink_to_file(dest, &target) 107 | .map_err(|e| format!("Failed to create symlink {}: {}", dest.display(), e))?; 108 | } 109 | 110 | Ok(()) 111 | } 112 | 113 | #[cfg(feature = "dir")] 114 | fn copy_stats( 115 | source_meta: &std::fs::Metadata, 116 | dest: &std::path::Path, 117 | ) -> Result<(), std::io::Error> { 118 | let src_mtime = filetime::FileTime::from_last_modification_time(source_meta); 119 | filetime::set_file_mtime(dest, src_mtime)?; 120 | 121 | Ok(()) 122 | } 123 | 124 | #[cfg(not(feature = "dir"))] 125 | fn copy_stats( 126 | _source_meta: &std::fs::Metadata, 127 | _dest: &std::path::Path, 128 | ) -> Result<(), std::io::Error> { 129 | Ok(()) 130 | } 131 | 132 | #[cfg(windows)] 133 | fn symlink_to_file(link: &std::path::Path, target: &std::path::Path) -> Result<(), std::io::Error> { 134 | std::os::windows::fs::symlink_file(target, link) 135 | } 136 | 137 | #[cfg(not(windows))] 138 | fn symlink_to_file(link: &std::path::Path, target: &std::path::Path) -> Result<(), std::io::Error> { 139 | std::os::unix::fs::symlink(target, link) 140 | } 141 | 142 | pub fn resolve_dir( 143 | path: impl AsRef, 144 | ) -> Result { 145 | let path = path.as_ref(); 146 | let meta = std::fs::symlink_metadata(path)?; 147 | if meta.is_dir() { 148 | canonicalize(path) 149 | } else if meta.is_file() { 150 | // Git might checkout symlinks as files 151 | let target = std::fs::read_to_string(path)?; 152 | let target_path = path.parent().unwrap().join(target); 153 | resolve_dir(target_path) 154 | } else { 155 | canonicalize(path) 156 | } 157 | } 158 | 159 | pub(crate) fn canonicalize(path: &std::path::Path) -> Result { 160 | #[cfg(feature = "dir")] 161 | { 162 | dunce::canonicalize(path) 163 | } 164 | #[cfg(not(feature = "dir"))] 165 | { 166 | // Hope for the best 167 | Ok(strip_trailing_slash(path).to_owned()) 168 | } 169 | } 170 | 171 | pub fn strip_trailing_slash(path: &std::path::Path) -> &std::path::Path { 172 | path.components().as_path() 173 | } 174 | 175 | /// Normalize a path, removing things like `.` and `..`. 176 | /// 177 | /// CAUTION: This does not resolve symlinks (unlike 178 | /// [`std::fs::canonicalize`]). This may cause incorrect or surprising 179 | /// behavior at times. This should be used carefully. Unfortunately, 180 | /// [`std::fs::canonicalize`] can be hard to use correctly, since it can often 181 | /// fail, or on Windows returns annoying device paths. This is a problem Cargo 182 | /// needs to improve on. 183 | pub(crate) fn normalize_path(path: &std::path::Path) -> std::path::PathBuf { 184 | use std::path::Component; 185 | 186 | let mut components = path.components().peekable(); 187 | let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() { 188 | components.next(); 189 | std::path::PathBuf::from(c.as_os_str()) 190 | } else { 191 | std::path::PathBuf::new() 192 | }; 193 | 194 | for component in components { 195 | match component { 196 | Component::Prefix(..) => unreachable!(), 197 | Component::RootDir => { 198 | ret.push(Component::RootDir); 199 | } 200 | Component::CurDir => {} 201 | Component::ParentDir => { 202 | if ret.ends_with(Component::ParentDir) { 203 | ret.push(Component::ParentDir); 204 | } else { 205 | let popped = ret.pop(); 206 | if !popped && !ret.has_root() { 207 | ret.push(Component::ParentDir); 208 | } 209 | } 210 | } 211 | Component::Normal(c) => { 212 | ret.push(c); 213 | } 214 | } 215 | } 216 | ret 217 | } 218 | 219 | pub(crate) fn display_relpath(path: impl AsRef) -> String { 220 | let path = path.as_ref(); 221 | let relpath = if let Ok(cwd) = std::env::current_dir() { 222 | match path.strip_prefix(cwd) { 223 | Ok(path) => path, 224 | Err(_) => path, 225 | } 226 | } else { 227 | path 228 | }; 229 | relpath.display().to_string() 230 | } 231 | -------------------------------------------------------------------------------- /crates/trycmd/src/cases.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | /// Entry point for running tests 4 | #[derive(Debug, Default)] 5 | pub struct TestCases { 6 | runner: std::cell::RefCell, 7 | bins: std::cell::RefCell, 8 | substitutions: std::cell::RefCell, 9 | has_run: std::cell::Cell, 10 | } 11 | 12 | impl TestCases { 13 | pub fn new() -> Self { 14 | let s = Self::default(); 15 | s.runner 16 | .borrow_mut() 17 | .include(parse_include(std::env::args_os())); 18 | s 19 | } 20 | 21 | /// Load tests from `glob` 22 | pub fn case(&self, glob: impl AsRef) -> &Self { 23 | self.runner.borrow_mut().case(glob.as_ref(), None); 24 | self 25 | } 26 | 27 | /// Overwrite expected status for a test 28 | pub fn pass(&self, glob: impl AsRef) -> &Self { 29 | self.runner 30 | .borrow_mut() 31 | .case(glob.as_ref(), Some(crate::schema::CommandStatus::Success)); 32 | self 33 | } 34 | 35 | /// Overwrite expected status for a test 36 | pub fn fail(&self, glob: impl AsRef) -> &Self { 37 | self.runner 38 | .borrow_mut() 39 | .case(glob.as_ref(), Some(crate::schema::CommandStatus::Failed)); 40 | self 41 | } 42 | 43 | /// Overwrite expected status for a test 44 | pub fn interrupted(&self, glob: impl AsRef) -> &Self { 45 | self.runner.borrow_mut().case( 46 | glob.as_ref(), 47 | Some(crate::schema::CommandStatus::Interrupted), 48 | ); 49 | self 50 | } 51 | 52 | /// Overwrite expected status for a test 53 | pub fn skip(&self, glob: impl AsRef) -> &Self { 54 | self.runner 55 | .borrow_mut() 56 | .case(glob.as_ref(), Some(crate::schema::CommandStatus::Skipped)); 57 | self 58 | } 59 | 60 | /// Set default bin, by path, for commands 61 | pub fn default_bin_path(&self, path: impl AsRef) -> &Self { 62 | let bin = Some(crate::schema::Bin::Path(path.as_ref().into())); 63 | self.runner.borrow_mut().default_bin(bin); 64 | self 65 | } 66 | 67 | /// Set default bin, by name, for commands 68 | pub fn default_bin_name(&self, name: impl AsRef) -> &Self { 69 | let bin = Some(crate::schema::Bin::Name(name.as_ref().into())); 70 | self.runner.borrow_mut().default_bin(bin); 71 | self 72 | } 73 | 74 | /// Set default timeout for commands 75 | pub fn timeout(&self, time: std::time::Duration) -> &Self { 76 | self.runner.borrow_mut().timeout(Some(time)); 77 | self 78 | } 79 | 80 | /// Set default environment variable 81 | pub fn env(&self, key: impl Into, value: impl Into) -> &Self { 82 | self.runner.borrow_mut().env(key, value); 83 | self 84 | } 85 | 86 | /// Add a bin to the "PATH" for cases to use 87 | pub fn register_bin( 88 | &self, 89 | name: impl Into, 90 | path: impl Into, 91 | ) -> &Self { 92 | self.bins 93 | .borrow_mut() 94 | .register_bin(name.into(), path.into()); 95 | self 96 | } 97 | 98 | /// Add a series of bins to the "PATH" for cases to use 99 | pub fn register_bins, B: Into>( 100 | &self, 101 | bins: impl IntoIterator, 102 | ) -> &Self { 103 | self.bins 104 | .borrow_mut() 105 | .register_bins(bins.into_iter().map(|(n, b)| (n.into(), b.into()))); 106 | self 107 | } 108 | 109 | /// Add a variable for normalizing output 110 | /// 111 | /// Variable names must be 112 | /// - Surrounded by `[]` 113 | /// - Consist of uppercase letters 114 | /// 115 | /// Variables will be preserved through `TRYCMD=overwrite` / `TRYCMD=dump`. 116 | /// 117 | /// **NOTE:** We do basic search/replaces so new any new output will blindly be replaced. 118 | /// 119 | /// Reserved names: 120 | /// - `[..]` 121 | /// - `[EXE]` 122 | /// - `[CWD]` 123 | /// - `[ROOT]` 124 | /// 125 | /// ## Example 126 | /// 127 | /// ```rust,no_run 128 | /// #[test] 129 | /// fn cli_tests() { 130 | /// trycmd::TestCases::new() 131 | /// .case("tests/cmd/*.trycmd") 132 | /// .insert_var("[VAR]", "value"); 133 | /// } 134 | /// ``` 135 | pub fn insert_var( 136 | &self, 137 | var: &'static str, 138 | value: impl Into>, 139 | ) -> Result<&Self, crate::Error> { 140 | let value = value.into(); 141 | let value = snapbox::filter::normalize_paths(&snapbox::filter::normalize_lines(&value)); 142 | self.substitutions.borrow_mut().insert(var, value)?; 143 | Ok(self) 144 | } 145 | 146 | /// Batch add variables for normalizing output 147 | /// 148 | /// See [`TestCases::insert_var`]. 149 | pub fn extend_vars( 150 | &self, 151 | vars: impl IntoIterator>)>, 152 | ) -> Result<&Self, crate::Error> { 153 | self.substitutions 154 | .borrow_mut() 155 | .extend(vars.into_iter().map(|(var, value)| { 156 | let value = value.into(); 157 | let value = 158 | snapbox::filter::normalize_paths(&snapbox::filter::normalize_lines(&value)); 159 | (var, value) 160 | }))?; 161 | Ok(self) 162 | } 163 | 164 | /// Remove an existing var values 165 | /// 166 | /// See [`TestCases::insert_var`]. 167 | pub fn clear_var(&self, var: &'static str) -> Result<&Self, crate::Error> { 168 | self.substitutions.borrow_mut().remove(var)?; 169 | Ok(self) 170 | } 171 | 172 | /// Run tests 173 | /// 174 | /// This will happen on `drop` if not done explicitly 175 | pub fn run(&self) { 176 | self.has_run.set(true); 177 | 178 | let mode = parse_mode(std::env::var_os("TRYCMD").as_deref()); 179 | mode.initialize().unwrap(); 180 | 181 | let runner = self.runner.borrow_mut().prepare(); 182 | runner.run(&mode, &self.bins.borrow(), &self.substitutions.borrow()); 183 | } 184 | } 185 | 186 | impl std::panic::RefUnwindSafe for TestCases {} 187 | 188 | #[doc(hidden)] 189 | impl Drop for TestCases { 190 | fn drop(&mut self) { 191 | if !self.has_run.get() && !std::thread::panicking() { 192 | self.run(); 193 | } 194 | } 195 | } 196 | 197 | // Filter which test cases are run by trybuild. 198 | // 199 | // $ cargo test -- ui trybuild=tuple_structs.rs 200 | // 201 | // The first argument after `--` must be the trybuild test name i.e. the name of 202 | // the function that has the #[test] attribute and calls trybuild. That's to get 203 | // Cargo to run the test at all. The next argument starting with `trybuild=` 204 | // provides a filename filter. Only test cases whose filename contains the 205 | // filter string will be run. 206 | #[allow(clippy::needless_collect)] // false positive https://github.com/rust-lang/rust-clippy/issues/5991 207 | fn parse_include(args: impl IntoIterator) -> Option> { 208 | let filters = args 209 | .into_iter() 210 | .flat_map(std::ffi::OsString::into_string) 211 | .filter_map(|arg| { 212 | const PREFIX: &str = "trycmd="; 213 | if let Some(remainder) = arg.strip_prefix(PREFIX) { 214 | if remainder.is_empty() { 215 | None 216 | } else { 217 | Some(remainder.to_owned()) 218 | } 219 | } else { 220 | None 221 | } 222 | }) 223 | .collect::>(); 224 | 225 | if filters.is_empty() { 226 | None 227 | } else { 228 | Some(filters) 229 | } 230 | } 231 | 232 | fn parse_mode(var: Option<&std::ffi::OsStr>) -> crate::Mode { 233 | if var == Some(std::ffi::OsStr::new("overwrite")) { 234 | crate::Mode::Overwrite 235 | } else if var == Some(std::ffi::OsStr::new("dump")) { 236 | crate::Mode::Dump("dump".into()) 237 | } else { 238 | crate::Mode::Fail 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /crates/trycmd/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # Snapshot testing for a herd of CLI tests 2 | //! 3 | //! > Treat your tests like cattle, instead of [pets](https://docs.rs/snapbox) 4 | //! 5 | //! `trycmd` is a test harness that will enumerate test case files and run them to verify the 6 | //! results, taking inspiration from 7 | //! [trybuild](https://crates.io/crates/trybuild) and [cram](https://bitheap.org/cram/). 8 | //! 9 | //! ## Which tool is right 10 | //! 11 | //! - [cram](https://bitheap.org/cram/): End-to-end CLI snapshotting agnostic of any programming language 12 | //! - `trycmd`: For running a lot of blunt tests (limited test predicates) 13 | //! - Particular attention is given to allow the test data to be pulled into documentation, like 14 | //! with [mdbook](https://rust-lang.github.io/mdBook/) 15 | //! - [snapbox](https://crates.io/crates/snapbox): When you want something like `trycmd` in one off 16 | //! cases or you need to customize `trycmd`s behavior. 17 | //! - [assert_cmd](https://crates.io/crates/assert_cmd) + 18 | //! [assert_fs](https://crates.io/crates/assert_fs): Test cases follow a certain pattern but 19 | //! special attention is needed in how to verify the results. 20 | //! - Hand-written test cases: for peculiar circumstances 21 | //! 22 | //! ## Getting Started 23 | //! 24 | //! To create a minimal setup, create a `tests/cli_tests.rs` with 25 | //! ```rust,no_run 26 | //! #[test] 27 | //! fn cli_tests() { 28 | //! trycmd::TestCases::new() 29 | //! .case("tests/cmd/*.toml") 30 | //! .case("README.md"); 31 | //! } 32 | //! ``` 33 | //! and write out your test cases in your `.toml` files along with examples in your `README.md`. 34 | //! 35 | //! Run this with `cargo test` like normal. [`TestCases`] will enumerate all test case files and 36 | //! run the contained commands, verifying they run as expected. 37 | //! 38 | //! To temporarily override the results, you can do: 39 | //! ```rust,no_run 40 | //! #[test] 41 | //! fn cli_tests() { 42 | //! trycmd::TestCases::new() 43 | //! .case("tests/cmd/*.toml") 44 | //! .case("README.md") 45 | //! // See Issue #314 46 | //! .fail("tests/cmd/buggy-case.toml"); 47 | //! } 48 | //! ``` 49 | //! 50 | //! ## Workflow 51 | //! 52 | //! To generate snapshots, run 53 | //! ```console 54 | //! $ TRYCMD=dump cargo test --test cli_tests 55 | //! ``` 56 | //! This will write all of the `.stdout` and `.stderr` files in a `dump/` directory. 57 | //! 58 | //! You can then copy over to `tests/cmd` the cases you want to test 59 | //! 60 | //! To update snapshots, run 61 | //! ```console 62 | //! $ TRYCMD=overwrite cargo test --test cli_tests 63 | //! ``` 64 | //! This will overwrite any existing `.stdout` and `.stderr` file in `tests/cmd` 65 | //! 66 | //! To filter the tests to those with `name1`, `name2`, etc in their file names, you can run: 67 | //! ```console 68 | //! cargo test --test cli_tests -- cli_tests trycmd=name1 trycmd=name2... 69 | //! ``` 70 | //! 71 | //! To debug what `trycmd` is doing, run `cargo test -F trycmd/debug`. 72 | //! 73 | //! ## File Formats 74 | //! 75 | //! For `tests/cmd/help.trycmd`, `trycmd` will look for: 76 | //! - `tests/cmd/help.in/` 77 | //! - `tests/cmd/help.out/` 78 | //! 79 | //! Say you have `tests/cmd/help.toml`, `trycmd` will look for: 80 | //! - `tests/cmd/help.stdin` 81 | //! - `tests/cmd/help.stdout` 82 | //! - `tests/cmd/help.stderr` 83 | //! - `tests/cmd/help.in/` 84 | //! - `tests/cmd/help.out/` 85 | //! 86 | //! ### `*.trycmd` 87 | //! 88 | //! `*.trycmd` / `*.md` files are literate test cases good for: 89 | //! - Markdown-compatible syntax for directly rendering them 90 | //! - Terminal-like appearance for extracting subsections into documentation 91 | //! - Reducing the proliferation of files 92 | //! - Running multiple commands within the same temp dir (if a `*.out/` directory is present) 93 | //! 94 | //! The syntax is: 95 | //! - Test cases live inside of ` ``` ` fenced code blocks 96 | //! - Everything out of them is ignored 97 | //! - Blocks with info strings with an unsupported language (not `trycmd`, `console`) or the 98 | //! `ignore` attribute are ignored 99 | //! - "`$ `" line prefix starts a new command 100 | //! - "`> `" line prefix appends to the prior command 101 | //! - "`? `" line indicates the exit code (like `echo "? $?"`) and `` can be 102 | //! - An exit code 103 | //! - `success` *(default)*, `failed`, `interrupted`, `skipped` 104 | //! - All following lines are treated as stdout + stderr 105 | //! 106 | //! The command is then split with [shlex](https://crates.io/crates/shlex), allowing quoted content 107 | //! to allow spaces. The first argument is the program to run which maps to `bin.name` in the 108 | //! `.toml` file. 109 | //! 110 | //! Example: 111 | //! 112 | //! With a `[[bin]]` like: 113 | //! ```rust,ignore 114 | //! fn main() { 115 | //! println!("Hello world"); 116 | //! } 117 | //! ``` 118 | //! 119 | //! You can verify a code block like: 120 | //! ~~~md 121 | //! ```console 122 | //! $ my-cmd 123 | //! Hello world 124 | //! 125 | //! ``` 126 | //! ~~~ 127 | //! 128 | //! For a more complete example, see: 129 | //! . 130 | //! 131 | //! ### `*.toml` 132 | //! 133 | //! As an alternative to `.trycmd`, the `toml` are good for: 134 | //! - Precise control over current dir, stdin/stdout/stderr (including binary support) 135 | //! - 1-to-1 with dumped results 136 | //! - `TRYCMD=overwrite` support 137 | //! 138 | //! [See full schema](https://github.com/assert-rs/snapbox/blob/main/crates/trycmd/schema.json): 139 | //! Basic parameters: 140 | //! - `bin.name`: The name of the binary target from `Cargo.toml` to be used to find the file path 141 | //! - `args`: the arguments (including flags and option) passed to the binary 142 | //! 143 | //! #### `*.stdin` 144 | //! 145 | //! Data to pass to `stdin`. 146 | //! - If not present, nothing will be written to `stdin` 147 | //! - If `binary = false` in `*.toml` (the default), newlines and path separators will be normalized. 148 | //! 149 | //! #### `*.stdout` and `*.stderr` 150 | //! 151 | //! Expected results for `stdout` or `stderr`. 152 | //! - If not present, we'll not verify the output 153 | //! - If `binary = false` in `*.toml` (the default), newlines and path separators will be normalized before comparing 154 | //! 155 | //! **Eliding Content** 156 | //! 157 | //! Sometimes the output either includes: 158 | //! - Content that changes from run-to-run (like time) 159 | //! - Content out of scope of your tests and you want to exclude it to reduce brittleness 160 | //! 161 | //! To elide a section of content: 162 | //! - `...` as its own line: match all lines until the next one. This is equivalent of 163 | //! `\n(([^\n]*\n)*)?`. 164 | //! - `[..]` as part of a line: match any characters. This is equivalent of `[^\n]*?`. 165 | //! - `[EXE]` as part of the line: On Windows, matches `.exe`, ignored otherwise 166 | //! - `[ROOT]` as part of the line: The root directory for where the test is running 167 | //! - `[CWD]` as part of the line: The current working directory within the root 168 | //! - `[YOUR_NAME_HERE]` as part of the line: See [`TestCases::insert_var`] 169 | //! 170 | //! We will preserve these with `TRYCMD=dump` and will make a best-effort at preserving them with 171 | //! `TRYCMD=overwrite`. 172 | //! 173 | //! ### `*.in/` 174 | //! 175 | //! When present, this will automatically be picked as the CWD for the command. 176 | //! 177 | //! `.keep` files will be ignored but their parent directories will be created. 178 | //! 179 | //! Tests are assumed to not modify files in `*.in/` unless an `*.out/` is provided or 180 | //! `fs.sandbox = true` is set in the `.toml` file. 181 | //! 182 | //! ### `*.out/` 183 | //! 184 | //! When present, each file in this directory will be compared to generated or modified files. 185 | //! 186 | //! See also "Eliding Content" for `.stdout` 187 | //! 188 | //! `.keep` files will be ignored. 189 | //! 190 | //! Note: This implies `fs.sandbox = true`. 191 | //! 192 | //! ## Examples 193 | //! 194 | //! - Simple cargo binary: [trycmd's integration tests](https://github.com/assert-rs/snapbox/blob/main/crates/trycmd/tests/cli_tests.rs) 195 | //! - Simple example: [trycmd's integration tests](https://github.com/assert-rs/snapbox/blob/main/crates/trycmd/tests/example_tests.rs) 196 | //! - [typos](https://github.com/crate-ci/typos) (source code spell checker) 197 | //! - [clap](https://github.com/clap-rs/clap/) (CLI parser) to test examples 198 | //! 199 | //! ## Related crates 200 | //! 201 | //! For testing command line programs. 202 | //! - [escargot][escargot] for more control over configuring the crate's binary. 203 | //! - [duct][duct] for orchestrating multiple processes. 204 | //! - or [commandspec] for easier writing of commands 205 | //! - [`assert_cmd`][assert_cmd] for test cases that are individual pets, rather than herd of cattle 206 | //! - [`assert_fs`][assert_fs] for filesystem fixtures and assertions. 207 | //! - or [tempfile][tempfile] for scratchpad directories. 208 | //! - [rexpect][rexpect] for testing interactive programs. 209 | //! - [dir-diff][dir-diff] for testing file side-effects. 210 | //! 211 | //! For snapshot testing: 212 | //! - [insta](https://crates.io/crates/insta) 213 | //! - [fn-fixture](https://crates.io/crates/fn-fixture) 214 | //! - [runt](https://crates.io/crates/runt) 215 | //! - [turnt](https://github.com/cucapra/turnt) 216 | //! - [cram](https://bitheap.org/cram/) 217 | //! - [term-transcript](https://crates.io/crates/term-transcript): CLI snapshot testing, including colors 218 | //! 219 | //! [escargot]: http://docs.rs/escargot 220 | //! [rexpect]: https://crates.io/crates/rexpect 221 | //! [dir-diff]: https://crates.io/crates/dir-diff 222 | //! [tempfile]: https://crates.io/crates/tempfile 223 | //! [duct]: https://crates.io/crates/duct 224 | //! [assert_fs]: https://crates.io/crates/assert_fs 225 | //! [assert_cmd]: https://crates.io/crates/assert_cmd 226 | //! [commandspec]: https://crates.io/crates/commandspec 227 | 228 | #![cfg_attr(docsrs, feature(doc_cfg))] 229 | #![warn(clippy::print_stderr)] 230 | #![warn(clippy::print_stdout)] 231 | 232 | pub mod cargo; 233 | pub mod schema; 234 | 235 | mod cases; 236 | mod registry; 237 | mod runner; 238 | mod spec; 239 | 240 | pub use cases::TestCases; 241 | pub use snapbox::assert::Error; 242 | 243 | pub(crate) use registry::BinRegistry; 244 | pub(crate) use runner::{Case, Mode, Runner}; 245 | pub(crate) use spec::RunnerSpec; 246 | 247 | pub(crate) use snapbox::Data; 248 | 249 | #[doc = include_str!("../README.md")] 250 | #[cfg(doctest)] 251 | pub struct ReadmeDoctests; 252 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # Note that all fields that take a lint level have these possible values: 2 | # * deny - An error will be produced and the check will fail 3 | # * warn - A warning will be produced, but the check will not fail 4 | # * allow - No warning or error will be produced, though in some cases a note 5 | # will be 6 | 7 | # Root options 8 | 9 | # The graph table configures how the dependency graph is constructed and thus 10 | # which crates the checks are performed against 11 | [graph] 12 | # If 1 or more target triples (and optionally, target_features) are specified, 13 | # only the specified targets will be checked when running `cargo deny check`. 14 | # This means, if a particular package is only ever used as a target specific 15 | # dependency, such as, for example, the `nix` crate only being used via the 16 | # `target_family = "unix"` configuration, that only having windows targets in 17 | # this list would mean the nix crate, as well as any of its exclusive 18 | # dependencies not shared by any other crates, would be ignored, as the target 19 | # list here is effectively saying which targets you are building for. 20 | targets = [ 21 | # The triple can be any string, but only the target triples built in to 22 | # rustc (as of 1.40) can be checked against actual config expressions 23 | #"x86_64-unknown-linux-musl", 24 | # You can also specify which target_features you promise are enabled for a 25 | # particular target. target_features are currently not validated against 26 | # the actual valid features supported by the target architecture. 27 | #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, 28 | ] 29 | # When creating the dependency graph used as the source of truth when checks are 30 | # executed, this field can be used to prune crates from the graph, removing them 31 | # from the view of cargo-deny. This is an extremely heavy hammer, as if a crate 32 | # is pruned from the graph, all of its dependencies will also be pruned unless 33 | # they are connected to another crate in the graph that hasn't been pruned, 34 | # so it should be used with care. The identifiers are [Package ID Specifications] 35 | # (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) 36 | #exclude = [] 37 | # If true, metadata will be collected with `--all-features`. Note that this can't 38 | # be toggled off if true, if you want to conditionally enable `--all-features` it 39 | # is recommended to pass `--all-features` on the cmd line instead 40 | all-features = false 41 | # If true, metadata will be collected with `--no-default-features`. The same 42 | # caveat with `all-features` applies 43 | no-default-features = false 44 | # If set, these feature will be enabled when collecting metadata. If `--features` 45 | # is specified on the cmd line they will take precedence over this option. 46 | #features = [] 47 | 48 | # The output table provides options for how/if diagnostics are outputted 49 | [output] 50 | # When outputting inclusion graphs in diagnostics that include features, this 51 | # option can be used to specify the depth at which feature edges will be added. 52 | # This option is included since the graphs can be quite large and the addition 53 | # of features from the crate(s) to all of the graph roots can be far too verbose. 54 | # This option can be overridden via `--feature-depth` on the cmd line 55 | feature-depth = 1 56 | 57 | # This section is considered when running `cargo deny check advisories` 58 | # More documentation for the advisories section can be found here: 59 | # https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html 60 | [advisories] 61 | # The path where the advisory databases are cloned/fetched into 62 | #db-path = "$CARGO_HOME/advisory-dbs" 63 | # The url(s) of the advisory databases to use 64 | #db-urls = ["https://github.com/rustsec/advisory-db"] 65 | # A list of advisory IDs to ignore. Note that ignored advisories will still 66 | # output a note when they are encountered. 67 | ignore = [ 68 | #"RUSTSEC-0000-0000", 69 | #{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" }, 70 | #"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish 71 | #{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" }, 72 | ] 73 | # If this is true, then cargo deny will use the git executable to fetch advisory database. 74 | # If this is false, then it uses a built-in git library. 75 | # Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. 76 | # See Git Authentication for more information about setting up git authentication. 77 | #git-fetch-with-cli = true 78 | 79 | # This section is considered when running `cargo deny check licenses` 80 | # More documentation for the licenses section can be found here: 81 | # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html 82 | [licenses] 83 | # List of explicitly allowed licenses 84 | # See https://spdx.org/licenses/ for list of possible licenses 85 | # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. 86 | allow = [ 87 | "MIT", 88 | "MIT-0", 89 | "Apache-2.0", 90 | "BSD-2-Clause", 91 | "BSD-3-Clause", 92 | "MPL-2.0", 93 | "Unicode-DFS-2016", 94 | "Unicode-3.0", 95 | "CC0-1.0", 96 | "ISC", 97 | "OpenSSL", 98 | "Zlib", 99 | "NCSA", 100 | ] 101 | # The confidence threshold for detecting a license from license text. 102 | # The higher the value, the more closely the license text must be to the 103 | # canonical license text of a valid SPDX license file. 104 | # [possible values: any between 0.0 and 1.0]. 105 | confidence-threshold = 0.8 106 | # Allow 1 or more licenses on a per-crate basis, so that particular licenses 107 | # aren't accepted for every possible crate as with the normal allow list 108 | exceptions = [ 109 | # Each entry is the crate and version constraint, and its specific allow 110 | # list 111 | #{ allow = ["Zlib"], crate = "adler32" }, 112 | ] 113 | 114 | # Some crates don't have (easily) machine readable licensing information, 115 | # adding a clarification entry for it allows you to manually specify the 116 | # licensing information 117 | [[licenses.clarify]] 118 | # The package spec the clarification applies to 119 | crate = "ring" 120 | # The SPDX expression for the license requirements of the crate 121 | expression = "MIT AND ISC AND OpenSSL" 122 | # One or more files in the crate's source used as the "source of truth" for 123 | # the license expression. If the contents match, the clarification will be used 124 | # when running the license check, otherwise the clarification will be ignored 125 | # and the crate will be checked normally, which may produce warnings or errors 126 | # depending on the rest of your configuration 127 | license-files = [ 128 | # Each entry is a crate relative path, and the (opaque) hash of its contents 129 | { path = "LICENSE", hash = 0xbd0eed23 } 130 | ] 131 | 132 | [licenses.private] 133 | # If true, ignores workspace crates that aren't published, or are only 134 | # published to private registries. 135 | # To see how to mark a crate as unpublished (to the official registry), 136 | # visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. 137 | ignore = true 138 | # One or more private registries that you might publish crates to, if a crate 139 | # is only published to private registries, and ignore is true, the crate will 140 | # not have its license(s) checked 141 | registries = [ 142 | #"https://sekretz.com/registry 143 | ] 144 | 145 | # This section is considered when running `cargo deny check bans`. 146 | # More documentation about the 'bans' section can be found here: 147 | # https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html 148 | [bans] 149 | # Lint level for when multiple versions of the same crate are detected 150 | multiple-versions = "warn" 151 | # Lint level for when a crate version requirement is `*` 152 | wildcards = "allow" 153 | # The graph highlighting used when creating dotgraphs for crates 154 | # with multiple versions 155 | # * lowest-version - The path to the lowest versioned duplicate is highlighted 156 | # * simplest-path - The path to the version with the fewest edges is highlighted 157 | # * all - Both lowest-version and simplest-path are used 158 | highlight = "all" 159 | # The default lint level for `default` features for crates that are members of 160 | # the workspace that is being checked. This can be overridden by allowing/denying 161 | # `default` on a crate-by-crate basis if desired. 162 | workspace-default-features = "allow" 163 | # The default lint level for `default` features for external crates that are not 164 | # members of the workspace. This can be overridden by allowing/denying `default` 165 | # on a crate-by-crate basis if desired. 166 | external-default-features = "allow" 167 | # List of crates that are allowed. Use with care! 168 | allow = [ 169 | #"ansi_term@0.11.0", 170 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" }, 171 | ] 172 | # List of crates to deny 173 | deny = [ 174 | #"ansi_term@0.11.0", 175 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" }, 176 | # Wrapper crates can optionally be specified to allow the crate when it 177 | # is a direct dependency of the otherwise banned crate 178 | #{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] }, 179 | ] 180 | 181 | # List of features to allow/deny 182 | # Each entry the name of a crate and a version range. If version is 183 | # not specified, all versions will be matched. 184 | #[[bans.features]] 185 | #crate = "reqwest" 186 | # Features to not allow 187 | #deny = ["json"] 188 | # Features to allow 189 | #allow = [ 190 | # "rustls", 191 | # "__rustls", 192 | # "__tls", 193 | # "hyper-rustls", 194 | # "rustls", 195 | # "rustls-pemfile", 196 | # "rustls-tls-webpki-roots", 197 | # "tokio-rustls", 198 | # "webpki-roots", 199 | #] 200 | # If true, the allowed features must exactly match the enabled feature set. If 201 | # this is set there is no point setting `deny` 202 | #exact = true 203 | 204 | # Certain crates/versions that will be skipped when doing duplicate detection. 205 | skip = [ 206 | #"ansi_term@0.11.0", 207 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" }, 208 | ] 209 | # Similarly to `skip` allows you to skip certain crates during duplicate 210 | # detection. Unlike skip, it also includes the entire tree of transitive 211 | # dependencies starting at the specified crate, up to a certain depth, which is 212 | # by default infinite. 213 | skip-tree = [ 214 | #"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies 215 | #{ crate = "ansi_term@0.11.0", depth = 20 }, 216 | ] 217 | 218 | # This section is considered when running `cargo deny check sources`. 219 | # More documentation about the 'sources' section can be found here: 220 | # https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html 221 | [sources] 222 | # Lint level for what to happen when a crate from a crate registry that is not 223 | # in the allow list is encountered 224 | unknown-registry = "deny" 225 | # Lint level for what to happen when a crate from a git repository that is not 226 | # in the allow list is encountered 227 | unknown-git = "deny" 228 | # List of URLs for allowed crate registries. Defaults to the crates.io index 229 | # if not specified. If it is specified but empty, no registries are allowed. 230 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 231 | # List of URLs for allowed Git repositories 232 | allow-git = [] 233 | 234 | [sources.allow-org] 235 | # 1 or more github.com organizations to allow git sources for 236 | github = [] 237 | # 1 or more gitlab.com organizations to allow git sources for 238 | gitlab = [] 239 | # 1 or more bitbucket.org organizations to allow git sources for 240 | bitbucket = [] 241 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /crates/snapbox/src/filter/redactions.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::path::Path; 3 | use std::path::PathBuf; 4 | 5 | /// Replace data with placeholders 6 | /// 7 | /// This can be used for: 8 | /// - Handling test-run dependent data like temp directories or elapsed time 9 | /// - Making special characters more obvious (e.g. redacting a tab a `[TAB]`) 10 | /// - Normalizing platform-specific data like [`std::env::consts::EXE_SUFFIX`] 11 | /// 12 | /// # Examples 13 | /// 14 | /// ```rust 15 | /// let mut subst = snapbox::Redactions::new(); 16 | /// subst.insert("[LOCATION]", "World"); 17 | /// assert_eq!(subst.redact("Hello World!"), "Hello [LOCATION]!"); 18 | /// ``` 19 | #[derive(Default, Clone, Debug, PartialEq, Eq)] 20 | pub struct Redactions { 21 | vars: Option< 22 | std::collections::BTreeMap>, 23 | >, 24 | unused: Option>, 25 | } 26 | 27 | impl Redactions { 28 | pub const fn new() -> Self { 29 | Self { 30 | vars: None, 31 | unused: None, 32 | } 33 | } 34 | 35 | pub(crate) fn with_exe() -> Self { 36 | let mut redactions = Self::new(); 37 | redactions 38 | .insert("[EXE]", std::env::consts::EXE_SUFFIX) 39 | .unwrap(); 40 | redactions 41 | } 42 | 43 | /// Insert an additional match pattern 44 | /// 45 | /// `placeholder` must be enclosed in `[` and `]`. 46 | /// 47 | /// ```rust 48 | /// let mut subst = snapbox::Redactions::new(); 49 | /// subst.insert("[EXE]", std::env::consts::EXE_SUFFIX); 50 | /// ``` 51 | /// 52 | /// With the `regex` feature, you can define patterns using regexes. 53 | /// You can choose to replace a subset of the regex by giving it the named capture group 54 | /// `redacted`. 55 | /// 56 | /// ```rust 57 | /// # #[cfg(feature = "regex")] { 58 | /// let mut subst = snapbox::Redactions::new(); 59 | /// subst.insert("[OBJECT]", regex::Regex::new("(?(world|moon))").unwrap()); 60 | /// assert_eq!(subst.redact("Hello world!"), "Hello [OBJECT]!"); 61 | /// assert_eq!(subst.redact("Hello moon!"), "Hello [OBJECT]!"); 62 | /// assert_eq!(subst.redact("Hello other!"), "Hello other!"); 63 | /// # } 64 | /// ``` 65 | pub fn insert( 66 | &mut self, 67 | placeholder: &'static str, 68 | value: impl Into, 69 | ) -> crate::assert::Result<()> { 70 | let placeholder = validate_placeholder(placeholder)?; 71 | let value = value.into(); 72 | if let Some(value) = value.inner { 73 | self.vars 74 | .get_or_insert(std::collections::BTreeMap::new()) 75 | .entry(value) 76 | .or_default() 77 | .insert(placeholder); 78 | } else { 79 | self.unused 80 | .get_or_insert(std::collections::BTreeSet::new()) 81 | .insert(RedactedValueInner::Str(placeholder)); 82 | } 83 | Ok(()) 84 | } 85 | 86 | /// Insert additional match patterns 87 | /// 88 | /// Placeholders must be enclosed in `[` and `]`. 89 | pub fn extend( 90 | &mut self, 91 | vars: impl IntoIterator)>, 92 | ) -> crate::assert::Result<()> { 93 | for (placeholder, value) in vars { 94 | self.insert(placeholder, value)?; 95 | } 96 | Ok(()) 97 | } 98 | 99 | pub fn remove(&mut self, placeholder: &'static str) -> crate::assert::Result<()> { 100 | let placeholder = validate_placeholder(placeholder)?; 101 | self.vars 102 | .get_or_insert(std::collections::BTreeMap::new()) 103 | .retain(|_value, placeholders| { 104 | placeholders.retain(|p| *p != placeholder); 105 | !placeholders.is_empty() 106 | }); 107 | Ok(()) 108 | } 109 | 110 | /// Apply redaction only, no pattern-dependent globs 111 | /// 112 | /// # Examples 113 | /// 114 | /// ```rust 115 | /// let mut subst = snapbox::Redactions::new(); 116 | /// subst.insert("[LOCATION]", "World"); 117 | /// let output = subst.redact("Hello World!"); 118 | /// assert_eq!(output, "Hello [LOCATION]!"); 119 | /// ``` 120 | pub fn redact(&self, input: &str) -> String { 121 | let mut input = input.to_owned(); 122 | replace_many( 123 | &mut input, 124 | self.vars 125 | .iter() 126 | .flatten() 127 | .flat_map(|(value, placeholders)| { 128 | placeholders 129 | .iter() 130 | .map(move |placeholder| (value, *placeholder)) 131 | }), 132 | ); 133 | input 134 | } 135 | 136 | /// Clear unused redactions from expected data 137 | /// 138 | /// Some redactions can be conditionally present, like redacting [`std::env::consts::EXE_SUFFIX`]. 139 | /// When the redaction is not present, it needs to be removed from the expected data so it can 140 | /// be matched against the actual data. 141 | pub fn clear_unused<'v>(&self, pattern: &'v str) -> Cow<'v, str> { 142 | if !self.unused.as_ref().map(|s| s.is_empty()).unwrap_or(false) && pattern.contains('[') { 143 | let mut pattern = pattern.to_owned(); 144 | replace_many( 145 | &mut pattern, 146 | self.unused.iter().flatten().map(|var| (var, "")), 147 | ); 148 | Cow::Owned(pattern) 149 | } else { 150 | Cow::Borrowed(pattern) 151 | } 152 | } 153 | } 154 | 155 | #[derive(Clone)] 156 | pub struct RedactedValue { 157 | inner: Option, 158 | } 159 | 160 | #[derive(Clone, Debug)] 161 | enum RedactedValueInner { 162 | Str(&'static str), 163 | String(String), 164 | Path { 165 | native: String, 166 | normalized: String, 167 | }, 168 | #[cfg(feature = "regex")] 169 | Regex(regex::Regex), 170 | } 171 | 172 | impl RedactedValueInner { 173 | fn find_in(&self, buffer: &str) -> Option> { 174 | match self { 175 | Self::Str(s) => buffer.find(s).map(|offset| offset..(offset + s.len())), 176 | Self::String(s) => buffer.find(s).map(|offset| offset..(offset + s.len())), 177 | Self::Path { native, normalized } => { 178 | match (buffer.find(native), buffer.find(normalized)) { 179 | (Some(native_offset), Some(normalized_offset)) => { 180 | if native_offset <= normalized_offset { 181 | Some(native_offset..(native_offset + native.len())) 182 | } else { 183 | Some(normalized_offset..(normalized_offset + normalized.len())) 184 | } 185 | } 186 | (Some(offset), None) => Some(offset..(offset + native.len())), 187 | (None, Some(offset)) => Some(offset..(offset + normalized.len())), 188 | (None, None) => None, 189 | } 190 | } 191 | #[cfg(feature = "regex")] 192 | Self::Regex(r) => { 193 | let captures = r.captures(buffer)?; 194 | let m = captures.name("redacted").or_else(|| captures.get(0))?; 195 | Some(m.range()) 196 | } 197 | } 198 | } 199 | 200 | fn as_cmp(&self) -> (usize, std::cmp::Reverse, &str) { 201 | match self { 202 | Self::Str(s) => (0, std::cmp::Reverse(s.len()), s), 203 | Self::String(s) => (0, std::cmp::Reverse(s.len()), s), 204 | Self::Path { normalized: s, .. } => (0, std::cmp::Reverse(s.len()), s), 205 | #[cfg(feature = "regex")] 206 | Self::Regex(r) => { 207 | let s = r.as_str(); 208 | (1, std::cmp::Reverse(s.len()), s) 209 | } 210 | } 211 | } 212 | } 213 | 214 | impl From<&'static str> for RedactedValue { 215 | fn from(inner: &'static str) -> Self { 216 | if inner.is_empty() { 217 | Self { inner: None } 218 | } else { 219 | Self { 220 | inner: Some(RedactedValueInner::Str(inner)), 221 | } 222 | } 223 | } 224 | } 225 | 226 | impl From for RedactedValue { 227 | fn from(inner: String) -> Self { 228 | if inner.is_empty() { 229 | Self { inner: None } 230 | } else { 231 | Self { 232 | inner: Some(RedactedValueInner::String(inner)), 233 | } 234 | } 235 | } 236 | } 237 | 238 | impl From<&'_ String> for RedactedValue { 239 | fn from(inner: &'_ String) -> Self { 240 | inner.clone().into() 241 | } 242 | } 243 | 244 | impl From> for RedactedValue { 245 | fn from(inner: Cow<'static, str>) -> Self { 246 | match inner { 247 | Cow::Borrowed(s) => s.into(), 248 | Cow::Owned(s) => s.into(), 249 | } 250 | } 251 | } 252 | 253 | impl From<&'static Path> for RedactedValue { 254 | fn from(inner: &'static Path) -> Self { 255 | inner.to_owned().into() 256 | } 257 | } 258 | 259 | impl From for RedactedValue { 260 | fn from(inner: PathBuf) -> Self { 261 | if inner.as_os_str().is_empty() { 262 | Self { inner: None } 263 | } else { 264 | let native = match inner.into_os_string().into_string() { 265 | Ok(s) => s, 266 | Err(os) => PathBuf::from(os).display().to_string(), 267 | }; 268 | let normalized = crate::filter::normalize_paths(&native); 269 | Self { 270 | inner: Some(RedactedValueInner::Path { native, normalized }), 271 | } 272 | } 273 | } 274 | } 275 | 276 | impl From<&'_ PathBuf> for RedactedValue { 277 | fn from(inner: &'_ PathBuf) -> Self { 278 | inner.clone().into() 279 | } 280 | } 281 | 282 | #[cfg(feature = "regex")] 283 | impl From for RedactedValue { 284 | fn from(inner: regex::Regex) -> Self { 285 | Self { 286 | inner: Some(RedactedValueInner::Regex(inner)), 287 | } 288 | } 289 | } 290 | 291 | #[cfg(feature = "regex")] 292 | impl From<&'_ regex::Regex> for RedactedValue { 293 | fn from(inner: &'_ regex::Regex) -> Self { 294 | inner.clone().into() 295 | } 296 | } 297 | 298 | impl PartialOrd for RedactedValueInner { 299 | fn partial_cmp(&self, other: &Self) -> Option { 300 | Some(self.cmp(other)) 301 | } 302 | } 303 | 304 | impl Ord for RedactedValueInner { 305 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 306 | self.as_cmp().cmp(&other.as_cmp()) 307 | } 308 | } 309 | 310 | impl PartialEq for RedactedValueInner { 311 | fn eq(&self, other: &Self) -> bool { 312 | self.as_cmp().eq(&other.as_cmp()) 313 | } 314 | } 315 | 316 | impl Eq for RedactedValueInner {} 317 | 318 | /// Replacements is `(from, to)` 319 | fn replace_many<'a>( 320 | buffer: &mut String, 321 | replacements: impl IntoIterator, 322 | ) { 323 | for (var, replace) in replacements { 324 | let mut index = 0; 325 | while let Some(offset) = var.find_in(&buffer[index..]) { 326 | let old_range = (index + offset.start)..(index + offset.end); 327 | buffer.replace_range(old_range, replace); 328 | index += offset.start + replace.len(); 329 | } 330 | } 331 | } 332 | 333 | fn validate_placeholder(placeholder: &'static str) -> crate::assert::Result<&'static str> { 334 | if !placeholder.starts_with('[') || !placeholder.ends_with(']') { 335 | return Err(format!("Key `{placeholder}` is not enclosed in []").into()); 336 | } 337 | 338 | if placeholder[1..(placeholder.len() - 1)] 339 | .find(|c: char| !c.is_ascii_uppercase() && c != '_') 340 | .is_some() 341 | { 342 | return Err(format!("Key `{placeholder}` can only be A-Z but ").into()); 343 | } 344 | 345 | Ok(placeholder) 346 | } 347 | 348 | #[cfg(test)] 349 | mod test { 350 | use super::*; 351 | 352 | #[test] 353 | fn test_validate_placeholder() { 354 | let cases = [ 355 | ("[HELLO", false), 356 | ("HELLO]", false), 357 | ("[HELLO]", true), 358 | ("[HELLO_WORLD]", true), 359 | ("[hello]", false), 360 | ("[HE O]", false), 361 | ]; 362 | for (placeholder, expected) in cases { 363 | let actual = validate_placeholder(placeholder).is_ok(); 364 | assert_eq!(expected, actual, "placeholder={placeholder:?}"); 365 | } 366 | } 367 | } 368 | --------------------------------------------------------------------------------