snapbox-0.6.21/.cargo_vcs_info.json0000644000000001540000000000100125740ustar { "git": { "sha1": "9dce8ebfd1399c22b6f17ded4df28173c6e925f8" }, "path_in_vcs": "crates/snapbox" }snapbox-0.6.21/Cargo.lock0000644000000417160000000000100105600ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 3 [[package]] name = "addr2line" version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" dependencies = [ "gimli", ] [[package]] name = "adler" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] name = "aho-corasick" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] name = "anstream" version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" [[package]] name = "anstyle-lossy" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fcff6599f06e21b0165c85052ccd6e67dc388ddd1c516a9dc5f55dc8cacf004" dependencies = [ "anstyle", ] [[package]] name = "anstyle-parse" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a64c907d4e79225ac72e2a354c9ce84d50ebb4586dee56c82b3ee73004f537f5" dependencies = [ "windows-sys 0.52.0", ] [[package]] name = "anstyle-svg" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbf0bf947d663010f0b4132f28ca08da9151f3b9035fa7578a38de521c1d1aa" dependencies = [ "anstream", "anstyle", "anstyle-lossy", "html-escape", "unicode-width", ] [[package]] name = "anstyle-wincon" version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" dependencies = [ "anstyle", "windows-sys 0.52.0", ] [[package]] name = "automod" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edf3ee19dbc0a46d740f6f0926bde8c50f02bdbc7b536842da28f6ac56513a8b" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "backtrace" version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" dependencies = [ "addr2line", "cc", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", ] [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" [[package]] name = "cc" version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "099a5357d84c4c61eb35fc8eafa9a79a902c2f76911e5747ced4e032edd8d9b4" [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "colorchoice" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" [[package]] name = "content_inspector" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7bda66e858c683005a53a9a60c69a4aca7eeaa45d124526e389f7aec8e62f38" dependencies = [ "memchr", ] [[package]] name = "document-features" version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb6969eaabd2421f8a2775cfd2471a2b634372b4a25d41e3bd647b79912850a0" dependencies = [ "litrs", ] [[package]] name = "dunce" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" [[package]] name = "errno" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ "libc", "windows-sys 0.52.0", ] [[package]] name = "escargot" version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05a3ac187a16b5382fef8c69fd1bad123c67b7cf3932240a2d43dcdd32cded88" dependencies = [ "log", "once_cell", "serde", "serde_json", ] [[package]] name = "fastrand" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" [[package]] name = "filetime" version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" dependencies = [ "cfg-if", "libc", "redox_syscall", "windows-sys 0.52.0", ] [[package]] name = "gimli" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "hermit-abi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "html-escape" version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" dependencies = [ "utf8-width", ] [[package]] name = "is-terminal" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" dependencies = [ "hermit-abi", "libc", "windows-sys 0.52.0", ] [[package]] name = "is_terminal_polyfill" version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b52b2de84ed0341893ce61ca1af04fa54eea0a764ecc38c6855cc5db84dc1927" dependencies = [ "is-terminal", ] [[package]] name = "itoa" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "libc" version = "0.2.154" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346" [[package]] name = "linux-raw-sys" version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" [[package]] name = "litrs" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" [[package]] name = "log" version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" [[package]] name = "memchr" version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" [[package]] name = "miniz_oxide" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" dependencies = [ "adler", ] [[package]] name = "normalize-line-endings" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" [[package]] name = "object" version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" dependencies = [ "memchr", ] [[package]] name = "once_cell" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "os_pipe" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57119c3b893986491ec9aa85056780d3a0f3cf4da7cc09dd3650dbd6c6738fb9" dependencies = [ "libc", "windows-sys 0.52.0", ] [[package]] name = "proc-macro2" version = "1.0.82" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b" dependencies = [ "unicode-ident", ] [[package]] name = "quote" version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ "proc-macro2", ] [[package]] name = "redox_syscall" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" dependencies = [ "bitflags 1.3.2", ] [[package]] name = "regex" version = "1.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" [[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ "bitflags 2.5.0", "errno", "libc", "linux-raw-sys", "windows-sys 0.52.0", ] [[package]] name = "ryu" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ "winapi-util", ] [[package]] name = "serde" version = "1.0.202" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.202" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "serde_json" version = "1.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" dependencies = [ "itoa", "ryu", "serde", ] [[package]] name = "similar" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa42c91313f1d05da9b26f267f931cf178d4aba455b4c4622dd7355eb80c6640" [[package]] name = "snapbox" version = "0.6.21" dependencies = [ "anstream", "anstyle", "anstyle-svg", "automod", "backtrace", "content_inspector", "document-features", "dunce", "escargot", "filetime", "libc", "normalize-line-endings", "os_pipe", "regex", "serde", "serde_json", "similar", "snapbox-macros", "tempfile", "wait-timeout", "walkdir", "windows-sys 0.59.0", ] [[package]] name = "snapbox-macros" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16569f53ca23a41bb6f62e0a5084aa1661f4814a67fa33696a79073e03a664af" dependencies = [ "anstream", ] [[package]] name = "syn" version = "2.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf5be731623ca1a1fb7d8be6f261a3be6d3e2337b8a1f97be944d020c8fcb704" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "tempfile" version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ "cfg-if", "fastrand", "rustix", "windows-sys 0.52.0", ] [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unicode-width" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6" [[package]] name = "utf8-width" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" [[package]] name = "utf8parse" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "wait-timeout" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" dependencies = [ "libc", ] [[package]] name = "walkdir" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", "winapi-util", ] [[package]] name = "winapi-util" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" dependencies = [ "windows-sys 0.52.0", ] [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets", ] [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets", ] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" snapbox-0.6.21/Cargo.toml0000644000000144270000000000100106020ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" rust-version = "1.65" name = "snapbox" version = "0.6.21" build = false include = [ "build.rs", "src/**/*", "Cargo.toml", "LICENSE*", "README.md", "benches/**/*", "examples/**/*", ] autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "Snapshot testing toolbox" homepage = "https://github.com/assert-rs/trycmd/tree/main/crates/snapbox" documentation = "http://docs.rs/snapbox/" readme = "README.md" keywords = [ "cli", "test", "assert", "command", ] categories = ["development-tools::testing"] license = "MIT OR Apache-2.0" repository = "https://github.com/assert-rs/snapbox/" [package.metadata.docs.rs] all-features = true cargo-args = [ "-Zunstable-options", "-Zrustdoc-scrape-examples", ] rustdoc-args = [ "--cfg", "docsrs", ] [[package.metadata.release.pre-release-replacements]] file = "CHANGELOG.md" min = 1 replace = "{{version}}" search = "Unreleased" [[package.metadata.release.pre-release-replacements]] exactly = 1 file = "CHANGELOG.md" replace = "...{{tag_name}}" search = '\.\.\.HEAD' [[package.metadata.release.pre-release-replacements]] file = "CHANGELOG.md" min = 1 replace = "{{date}}" search = "ReleaseDate" [[package.metadata.release.pre-release-replacements]] exactly = 1 file = "CHANGELOG.md" replace = """ ## [Unreleased] - ReleaseDate """ search = "" [[package.metadata.release.pre-release-replacements]] exactly = 1 file = "CHANGELOG.md" replace = """ [Unreleased]: https://github.com/assert-rs/trycmd/compare/{{tag_name}}...HEAD""" search = "" [lib] name = "snapbox" path = "src/lib.rs" [[bin]] name = "snap-fixture" path = "src/bin/snap-fixture.rs" [[example]] name = "diff" path = "examples/diff.rs" required-features = ["diff"] [[example]] name = "snap-example-fixture" path = "examples/snap-example-fixture.rs" [dependencies.anstream] version = "0.6.7" optional = true [dependencies.anstyle] version = "1.0.0" [dependencies.anstyle-svg] version = "0.1.3" optional = true [dependencies.backtrace] version = "0.3" optional = true [dependencies.content_inspector] version = "0.2.4" optional = true [dependencies.document-features] version = "0.2.8" optional = true [dependencies.dunce] version = "1.0" optional = true [dependencies.escargot] version = "0.5.13" optional = true [dependencies.filetime] version = "0.2.8" optional = true [dependencies.normalize-line-endings] version = "0.3.0" [dependencies.os_pipe] version = "1.0" optional = true [dependencies.regex] version = "1.10.4" features = ["std"] optional = true default-features = false [dependencies.serde] version = "1.0.198" optional = true [dependencies.serde_json] version = "1.0.85" optional = true [dependencies.similar] version = "2.1.0" features = ["inline"] optional = true [dependencies.snapbox-macros] version = "0.3.10" [dependencies.tempfile] version = "3.0" optional = true [dependencies.wait-timeout] version = "0.2.0" optional = true [dependencies.walkdir] version = "2.3.2" optional = true [dev-dependencies.automod] version = "1.0.14" [features] cmd = [ "dep:os_pipe", "dep:wait-timeout", "dep:libc", "dep:windows-sys", ] color = [ "dep:anstream", "snapbox-macros/color", ] color-auto = ["color"] debug = [ "snapbox-macros/debug", "dep:backtrace", ] default = [ "color-auto", "diff", ] detect-encoding = ["dep:content_inspector"] diff = ["dep:similar"] dir = [ "dep:tempfile", "dep:walkdir", "dep:dunce", "detect-encoding", "dep:filetime", ] examples = ["dep:escargot"] json = [ "structured-data", "dep:serde_json", "dep:serde", ] path = ["dir"] regex = ["dep:regex"] structured-data = ["dep:serde_json"] term-svg = [ "structured-data", "dep:anstyle-svg", ] [target."cfg(unix)".dependencies.libc] version = "0.2.137" optional = true [target."cfg(windows)".dependencies.windows-sys] version = "0.59.0" features = ["Win32_Foundation"] optional = true [lints.clippy] bool_assert_comparison = "allow" branches_sharing_code = "allow" checked_conversions = "warn" collapsible_else_if = "allow" create_dir = "warn" dbg_macro = "warn" debug_assert_with_mut_call = "warn" doc_markdown = "warn" empty_enum = "warn" enum_glob_use = "warn" expl_impl_clone_on_copy = "warn" explicit_deref_methods = "warn" explicit_into_iter_loop = "warn" fallible_impl_from = "warn" filter_map_next = "warn" flat_map_option = "warn" float_cmp_const = "warn" fn_params_excessive_bools = "warn" from_iter_instead_of_collect = "warn" if_same_then_else = "allow" implicit_clone = "warn" imprecise_flops = "warn" inconsistent_struct_constructor = "warn" inefficient_to_string = "warn" infinite_loop = "warn" invalid_upcast_comparisons = "warn" large_digit_groups = "warn" large_stack_arrays = "warn" large_types_passed_by_value = "warn" let_and_return = "allow" linkedlist = "warn" lossy_float_literal = "warn" macro_use_imports = "warn" mem_forget = "warn" mutex_integer = "warn" needless_continue = "warn" needless_for_each = "warn" negative_feature_names = "warn" path_buf_push_overwrite = "warn" ptr_as_ptr = "warn" rc_mutex = "warn" redundant_feature_names = "warn" ref_option_ref = "warn" rest_pat_in_fully_bound_structs = "warn" same_functions_in_if_condition = "warn" self_named_module_files = "warn" semicolon_if_nothing_returned = "warn" str_to_string = "warn" string_add = "warn" string_add_assign = "warn" string_lit_as_bytes = "warn" string_to_string = "warn" todo = "warn" trait_duplication_in_bounds = "warn" uninlined_format_args = "warn" verbose_file_reads = "warn" wildcard_imports = "warn" zero_sized_map_values = "warn" [lints.rust] unreachable_pub = "warn" unsafe_op_in_unsafe_fn = "warn" unused_lifetimes = "warn" unused_macro_rules = "warn" unused_qualifications = "warn" [lints.rust.rust_2018_idioms] level = "warn" priority = -1 snapbox-0.6.21/Cargo.toml.orig000064400000000000000000000071411046102023000142560ustar 00000000000000[package] name = "snapbox" version = "0.6.21" description = "Snapshot testing toolbox" homepage = "https://github.com/assert-rs/trycmd/tree/main/crates/snapbox" documentation = "http://docs.rs/snapbox/" readme = "README.md" categories = ["development-tools::testing"] keywords = ["cli", "test", "assert", "command"] repository.workspace = true license.workspace = true edition.workspace = true rust-version.workspace = true include.workspace = true [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] [package.metadata.release] pre-release-replacements = [ {file="CHANGELOG.md", search="Unreleased", replace="{{version}}", min=1}, {file="CHANGELOG.md", search="\\.\\.\\.HEAD", replace="...{{tag_name}}", exactly=1}, {file="CHANGELOG.md", search="ReleaseDate", replace="{{date}}", min=1}, {file="CHANGELOG.md", search="", replace="\n## [Unreleased] - ReleaseDate\n", exactly=1}, {file="CHANGELOG.md", search="", replace="\n[Unreleased]: https://github.com/assert-rs/trycmd/compare/{{tag_name}}...HEAD", exactly=1}, ] [features] default = ["color-auto", "diff"] #! Feature Flags ## Smarter binary file detection detect-encoding = ["dep:content_inspector"] ## Snapshotting of directories dir = ["dep:tempfile", "dep:walkdir", "dep:dunce", "detect-encoding", "dep:filetime"] ## Deprecated since 0.5.11, replaced with `dir` path = ["dir"] ## Snapshotting of commands cmd = ["dep:os_pipe", "dep:wait-timeout", "dep:libc", "dep:windows-sys"] ## Building of examples for snapshotting examples = ["dep:escargot"] ## Regex text substitutions regex = ["dep:regex"] ## Snapshotting of json json = ["structured-data", "dep:serde_json", "dep:serde"] ## Snapshotting of term styling term-svg = ["structured-data", "dep:anstyle-svg"] ## Snapshotting of structured data structured-data = ["dep:serde_json"] ## Extra debugging information debug = ["snapbox-macros/debug", "dep:backtrace"] #! Default Feature Flags ## Fancy diffs on failure diff = ["dep:similar"] ## Colored output support color = ["dep:anstream", "snapbox-macros/color"] ## Auto-detect whether to use colors color-auto = ["color"] [[bin]] name = "snap-fixture" # For `snapbox`s tests only [dependencies] normalize-line-endings = "0.3.0" snapbox-macros = { path = "../snapbox-macros", version = "0.3.10" } content_inspector = { version = "0.2.4", optional = true } tempfile = { version = "3.0", optional = true } walkdir = { version = "2.3.2", optional = true } dunce = { version = "1.0", optional = true } filetime = { version = "0.2.8", optional = true } os_pipe = { version = "1.0", optional = true } wait-timeout = { version = "0.2.0", optional = true } escargot = { version = "0.5.13", optional = true } backtrace = { version = "0.3", optional = true } similar = { version = "2.1.0", features = ["inline"], optional = true } anstyle = "1.0.0" anstream = { version = "0.6.7", optional = true } document-features = { version = "0.2.8", optional = true } serde_json = { version = "1.0.85", optional = true} anstyle-svg = { version = "0.1.3", optional = true } serde = { version = "1.0.198", optional = true } regex = { version = "1.10.4", optional = true, default-features = false, features = ["std"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59.0", features = ["Win32_Foundation"], optional = true } [target.'cfg(unix)'.dependencies] libc = { version = "0.2.137", optional = true } [dev-dependencies] automod = "1.0.14" [[example]] name = "diff" required-features = ["diff"] [lints] workspace = true snapbox-0.6.21/LICENSE-APACHE000064400000000000000000000261361046102023000133200ustar 00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. snapbox-0.6.21/LICENSE-MIT000064400000000000000000000020461046102023000130220ustar 00000000000000Copyright (c) Individual contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. snapbox-0.6.21/README.md000064400000000000000000000023201046102023000126400ustar 00000000000000# snapbox > When you have to treat your tests like pets, instead of [cattle][trycmd] [![Documentation](https://img.shields.io/badge/docs-master-blue.svg)][Documentation] ![License](https://img.shields.io/crates/l/snapbox.svg) [![Crates Status](https://img.shields.io/crates/v/snapbox.svg)](https://crates.io/crates/snapbox) `snapbox` is a snapshot-testing toolbox that is ready to use for verifying output from - Function return values - CLI stdout/stderr - Filesystem changes It is also flexible enough to build your own test harness like [trycmd]. See the [docs](http://docs.rs/snapbox) for more. ## License Licensed under either of * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) at your option. ## Contribution Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. [Crates.io]: https://crates.io/crates/snapbox [Documentation]: https://docs.rs/snapbox [trycmd]: https://crates.io/crates/trycmd snapbox-0.6.21/examples/diff.rs000064400000000000000000000013721046102023000144630ustar 00000000000000fn main() { let mut args = std::env::args(); let _ = args.next().expect("expects `$ diff `"); let old_path = args.next().expect("expects `$ diff `"); let new_path = args.next().expect("expects `$ diff `"); if args.next().is_some() { panic!("expects `$ diff `"); } let old = snapbox::Data::text(std::fs::read_to_string(&old_path).unwrap()); let new = snapbox::Data::text(std::fs::read_to_string(&new_path).unwrap()); let mut output = String::new(); snapbox::report::write_diff( &mut output, &old, &new, Some(&old_path), Some(&new_path), snapbox::report::Palette::color(), ) .unwrap(); println!("{output}"); } snapbox-0.6.21/examples/snap-example-fixture.rs000064400000000000000000000027441046102023000176350ustar 00000000000000//! For `snapbox`s tests only use std::env; use std::error::Error; use std::io; use std::io::Write; use std::process; fn run() -> Result<(), Box> { if let Ok(text) = env::var("stdout") { println!("{text}"); } if let Ok(text) = env::var("stderr") { eprintln!("{text}"); } if env::var("echo_large").as_deref() == Ok("1") { for i in 0..(128 * 1024) { println!("{i}"); } } if env::var("echo_cwd").as_deref() == Ok("1") { if let Ok(cwd) = env::current_dir() { eprintln!("{}", cwd.display()); } } if let Ok(raw) = env::var("write") { let (path, text) = raw.split_once('=').unwrap_or((raw.as_str(), "")); std::fs::write(path.trim(), text.trim()).unwrap(); } if let Ok(path) = env::var("cat") { let text = std::fs::read_to_string(path).unwrap(); eprintln!("{text}"); } if let Some(timeout) = env::var("sleep").ok().and_then(|s| s.parse().ok()) { std::thread::sleep(std::time::Duration::from_secs(timeout)); } let code = env::var("exit") .ok() .map(|v| v.parse::()) .map(|r| r.map(Some)) .unwrap_or(Ok(None))? .unwrap_or(0); process::exit(code); } fn main() { let code = match run() { Ok(_) => 0, Err(ref e) => { write!(&mut io::stderr(), "{e}").expect("writing to stderr won't fail"); 1 } }; process::exit(code); } snapbox-0.6.21/src/assert/action.rs000064400000000000000000000017401046102023000153010ustar 00000000000000pub const DEFAULT_ACTION_ENV: &str = "SNAPSHOTS"; /// Test action, see [`Assert`][crate::Assert] #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum Action { /// Do not run the test Skip, /// Ignore test failures Ignore, /// Fail on mismatch Verify, /// Overwrite on mismatch Overwrite, } impl Action { pub fn with_env_var(var: impl AsRef) -> Option { let var = var.as_ref(); let value = std::env::var_os(var)?; Self::with_env_value(value) } pub fn with_env_value(value: impl AsRef) -> Option { let value = value.as_ref(); match value.to_str()? { "skip" => Some(Action::Skip), "ignore" => Some(Action::Ignore), "verify" => Some(Action::Verify), "overwrite" => Some(Action::Overwrite), _ => None, } } } impl Default for Action { fn default() -> Self { Self::Verify } } snapbox-0.6.21/src/assert/error.rs000064400000000000000000000042371046102023000151610ustar 00000000000000pub type Result = std::result::Result; #[derive(Clone, Debug)] pub struct Error { inner: String, backtrace: Option, } impl Error { pub fn new(inner: impl std::fmt::Display) -> Self { Self::with_string(inner.to_string()) } fn with_string(inner: String) -> Self { Self { inner, backtrace: Backtrace::new(), } } #[track_caller] pub(crate) fn panic(self) -> ! { panic!("{self}") } } impl PartialEq for Error { fn eq(&self, other: &Self) -> bool { self.inner == other.inner } } impl Eq for Error {} impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { writeln!(f, "{}", self.inner)?; if let Some(backtrace) = self.backtrace.as_ref() { writeln!(f)?; writeln!(f, "Backtrace:")?; writeln!(f, "{backtrace}")?; } Ok(()) } } impl std::error::Error for Error {} impl<'s> From<&'s str> for Error { fn from(other: &'s str) -> Self { Self::with_string(other.to_owned()) } } impl<'s> From<&'s String> for Error { fn from(other: &'s String) -> Self { Self::with_string(other.clone()) } } impl From for Error { fn from(other: String) -> Self { Self::with_string(other) } } #[cfg(feature = "debug")] #[derive(Debug, Clone)] struct Backtrace(backtrace::Backtrace); #[cfg(feature = "debug")] impl Backtrace { fn new() -> Option { Some(Self(backtrace::Backtrace::new())) } } #[cfg(feature = "debug")] impl std::fmt::Display for Backtrace { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // `backtrace::Backtrace` uses `Debug` instead of `Display` write!(f, "{:?}", self.0) } } #[cfg(not(feature = "debug"))] #[derive(Debug, Copy, Clone)] struct Backtrace; #[cfg(not(feature = "debug"))] impl Backtrace { fn new() -> Option { None } } #[cfg(not(feature = "debug"))] impl std::fmt::Display for Backtrace { fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { Ok(()) } } snapbox-0.6.21/src/assert/mod.rs000064400000000000000000000344061046102023000146100ustar 00000000000000mod action; mod error; #[cfg(feature = "color")] use anstream::panic; #[cfg(feature = "color")] use anstream::stderr; #[cfg(not(feature = "color"))] use std::io::stderr; use crate::filter::{Filter as _, FilterNewlines, FilterPaths, NormalizeToExpected}; use crate::IntoData; pub use action::Action; pub use action::DEFAULT_ACTION_ENV; pub use error::Error; pub use error::Result; /// Snapshot assertion against a file's contents /// /// Useful for one-off assertions with the snapshot stored in a file /// /// # Examples /// /// ```rust,no_run /// # use snapbox::Assert; /// # use snapbox::file; /// let actual = "something"; /// Assert::new().eq(actual, file!["output.txt"]); /// ``` #[derive(Clone, Debug)] pub struct Assert { pub(crate) action: Action, action_var: Option, normalize_paths: bool, substitutions: crate::Redactions, pub(crate) palette: crate::report::Palette, } /// # Assertions impl Assert { pub fn new() -> Self { Default::default() } /// Check if a value is the same as an expected value /// /// By default [`filters`][crate::filter] are applied, including: /// - `...` is a line-wildcard when on a line by itself /// - `[..]` is a character-wildcard when inside a line /// - `[EXE]` matches `.exe` on Windows /// - `"{...}"` is a JSON value wildcard /// - `"...": "{...}"` is a JSON key-value wildcard /// - `\` to `/` /// - Newlines /// /// To limit this to newline normalization for text, call [`Data::raw`][crate::Data::raw] on `expected`. /// /// # Examples /// /// ```rust /// # use snapbox::Assert; /// let actual = "something"; /// let expected = "so[..]g"; /// Assert::new().eq(actual, expected); /// ``` /// /// Can combine this with [`file!`][crate::file] /// ```rust,no_run /// # use snapbox::Assert; /// # use snapbox::file; /// let actual = "something"; /// Assert::new().eq(actual, file!["output.txt"]); /// ``` #[track_caller] pub fn eq(&self, actual: impl IntoData, expected: impl IntoData) { let expected = expected.into_data(); let actual = actual.into_data(); if let Err(err) = self.try_eq(Some(&"In-memory"), actual, expected) { err.panic(); } } #[track_caller] #[deprecated(since = "0.6.0", note = "Replaced with `Assert::eq`")] pub fn eq_(&self, actual: impl IntoData, expected: impl IntoData) { self.eq(actual, expected); } pub fn try_eq( &self, actual_name: Option<&dyn std::fmt::Display>, actual: crate::Data, expected: crate::Data, ) -> Result<()> { if expected.source().is_none() && actual.source().is_some() { panic!("received `(actual, expected)`, expected `(expected, actual)`"); } match self.action { Action::Skip => { return Ok(()); } Action::Ignore | Action::Verify | Action::Overwrite => {} } let (actual, expected) = self.normalize(actual, expected); self.do_action(actual_name, actual, expected) } pub fn normalize( &self, mut actual: crate::Data, mut expected: crate::Data, ) -> (crate::Data, crate::Data) { if expected.filters.is_newlines_set() { expected = FilterNewlines.filter(expected); } // On `expected` being an error, make a best guess actual = actual.coerce_to(expected.against_format()); actual = actual.coerce_to(expected.intended_format()); if self.normalize_paths && expected.filters.is_paths_set() { actual = FilterPaths.filter(actual); } if expected.filters.is_newlines_set() { actual = FilterNewlines.filter(actual); } let mut normalize = NormalizeToExpected::new(); if expected.filters.is_redaction_set() { normalize = normalize.redact_with(&self.substitutions); } if expected.filters.is_unordered_set() { normalize = normalize.unordered(); } actual = normalize.normalize(actual, &expected); (actual, expected) } fn do_action( &self, actual_name: Option<&dyn std::fmt::Display>, actual: crate::Data, expected: crate::Data, ) -> Result<()> { let result = self.try_verify(actual_name, &actual, &expected); let Err(err) = result else { return Ok(()); }; match self.action { Action::Skip => unreachable!("Bailed out earlier"), Action::Ignore => { use std::io::Write; let _ = writeln!( stderr(), "{}: {}", self.palette.warn("Ignoring failure"), err ); Ok(()) } Action::Verify => { let message = if expected.source().is_none() { crate::report::Styled::new(String::new(), Default::default()) } else if let Some(action_var) = self.action_var.as_deref() { self.palette .hint(format!("Update with {action_var}=overwrite")) } else { crate::report::Styled::new(String::new(), Default::default()) }; Err(Error::new(format_args!("{err}{message}"))) } Action::Overwrite => { use std::io::Write; if let Some(source) = expected.source() { if let Err(message) = actual.write_to(source) { Err(Error::new(format_args!("{err}Update failed: {message}"))) } else { let _ = writeln!(stderr(), "{}: {}", self.palette.warn("Fixing"), err); Ok(()) } } else { Err(Error::new(format_args!("{err}"))) } } } } fn try_verify( &self, actual_name: Option<&dyn std::fmt::Display>, actual: &crate::Data, expected: &crate::Data, ) -> Result<()> { if actual != expected { let mut buf = String::new(); crate::report::write_diff( &mut buf, expected, actual, expected.source().map(|s| s as &dyn std::fmt::Display), actual_name, self.palette, ) .map_err(|e| e.to_string())?; Err(buf.into()) } else { Ok(()) } } } /// # Directory Assertions #[cfg(feature = "dir")] impl Assert { #[track_caller] pub fn subset_eq( &self, expected_root: impl Into, actual_root: impl Into, ) { let expected_root = expected_root.into(); let actual_root = actual_root.into(); self.subset_eq_inner(expected_root, actual_root); } #[track_caller] fn subset_eq_inner(&self, expected_root: std::path::PathBuf, actual_root: std::path::PathBuf) { match self.action { Action::Skip => { return; } Action::Ignore | Action::Verify | Action::Overwrite => {} } let checks: Vec<_> = crate::dir::PathDiff::subset_eq_iter_inner(expected_root, actual_root).collect(); self.verify(checks); } #[track_caller] pub fn subset_matches( &self, pattern_root: impl Into, actual_root: impl Into, ) { let pattern_root = pattern_root.into(); let actual_root = actual_root.into(); self.subset_matches_inner(pattern_root, actual_root); } #[track_caller] fn subset_matches_inner( &self, expected_root: std::path::PathBuf, actual_root: std::path::PathBuf, ) { match self.action { Action::Skip => { return; } Action::Ignore | Action::Verify | Action::Overwrite => {} } let checks: Vec<_> = crate::dir::PathDiff::subset_matches_iter_inner( expected_root, actual_root, &self.substitutions, self.normalize_paths, ) .collect(); self.verify(checks); } #[track_caller] fn verify( &self, mut checks: Vec>, ) { if checks.iter().all(Result::is_ok) { for check in checks { let (_expected_path, _actual_path) = check.unwrap(); crate::debug!( "{}: is {}", _expected_path.display(), self.palette.info("good") ); } } else { checks.sort_by_key(|c| match c { Ok((expected_path, _actual_path)) => Some(expected_path.clone()), Err(diff) => diff.expected_path().map(|p| p.to_owned()), }); let mut buffer = String::new(); let mut ok = true; for check in checks { use std::fmt::Write; match check { Ok((expected_path, _actual_path)) => { let _ = writeln!( &mut buffer, "{}: is {}", expected_path.display(), self.palette.info("good"), ); } Err(diff) => { let _ = diff.write(&mut buffer, self.palette); match self.action { Action::Skip => unreachable!("Bailed out earlier"), Action::Ignore | Action::Verify => { ok = false; } Action::Overwrite => { if let Err(err) = diff.overwrite() { ok = false; let path = diff .expected_path() .expect("always present when overwrite can fail"); let _ = writeln!( &mut buffer, "{} to overwrite {}: {}", self.palette.error("Failed"), path.display(), err ); } } } } } } if ok { use std::io::Write; let _ = write!(stderr(), "{buffer}"); match self.action { Action::Skip => unreachable!("Bailed out earlier"), Action::Ignore => { let _ = write!(stderr(), "{}", self.palette.warn("Ignoring above failures")); } Action::Verify => unreachable!("Something had to fail to get here"), Action::Overwrite => { let _ = write!( stderr(), "{}", self.palette.warn("Overwrote above failures") ); } } } else { match self.action { Action::Skip => unreachable!("Bailed out earlier"), Action::Ignore => unreachable!("Shouldn't be able to fail"), Action::Verify => { use std::fmt::Write; if let Some(action_var) = self.action_var.as_deref() { writeln!( &mut buffer, "{}", self.palette .hint(format_args!("Update with {action_var}=overwrite")) ) .unwrap(); } } Action::Overwrite => {} } panic!("{}", buffer); } } } } /// # Customize Behavior impl Assert { /// Override the color palette pub fn palette(mut self, palette: crate::report::Palette) -> Self { self.palette = palette; self } /// Read the failure action from an environment variable pub fn action_env(mut self, var_name: &str) -> Self { let action = Action::with_env_var(var_name); self.action = action.unwrap_or(self.action); self.action_var = Some(var_name.to_owned()); self } /// Override the failure action pub fn action(mut self, action: Action) -> Self { self.action = action; self.action_var = None; self } /// Override the default [`Redactions`][crate::Redactions] pub fn redact_with(mut self, substitutions: crate::Redactions) -> Self { self.substitutions = substitutions; self } /// Override the default [`Redactions`][crate::Redactions] #[deprecated(since = "0.6.2", note = "Replaced with `Assert::redact_with`")] pub fn substitutions(self, substitutions: crate::Redactions) -> Self { self.redact_with(substitutions) } /// Specify whether text should have path separators normalized /// /// The default is normalized pub fn normalize_paths(mut self, yes: bool) -> Self { self.normalize_paths = yes; self } } impl Assert { pub fn selected_action(&self) -> Action { self.action } pub fn redactions(&self) -> &crate::Redactions { &self.substitutions } } impl Default for Assert { fn default() -> Self { Self { action: Default::default(), action_var: Default::default(), normalize_paths: true, substitutions: Default::default(), palette: crate::report::Palette::color(), } .redact_with(crate::Redactions::with_exe()) } } snapbox-0.6.21/src/bin/snap-fixture.rs000064400000000000000000000027441046102023000157250ustar 00000000000000//! For `snapbox`s tests only use std::env; use std::error::Error; use std::io; use std::io::Write; use std::process; fn run() -> Result<(), Box> { if let Ok(text) = env::var("stdout") { println!("{text}"); } if let Ok(text) = env::var("stderr") { eprintln!("{text}"); } if env::var("echo_large").as_deref() == Ok("1") { for i in 0..(128 * 1024) { println!("{i}"); } } if env::var("echo_cwd").as_deref() == Ok("1") { if let Ok(cwd) = env::current_dir() { eprintln!("{}", cwd.display()); } } if let Ok(raw) = env::var("write") { let (path, text) = raw.split_once('=').unwrap_or((raw.as_str(), "")); std::fs::write(path.trim(), text.trim()).unwrap(); } if let Ok(path) = env::var("cat") { let text = std::fs::read_to_string(path).unwrap(); eprintln!("{text}"); } if let Some(timeout) = env::var("sleep").ok().and_then(|s| s.parse().ok()) { std::thread::sleep(std::time::Duration::from_secs(timeout)); } let code = env::var("exit") .ok() .map(|v| v.parse::()) .map(|r| r.map(Some)) .unwrap_or(Ok(None))? .unwrap_or(0); process::exit(code); } fn main() { let code = match run() { Ok(_) => 0, Err(ref e) => { write!(&mut io::stderr(), "{e}").expect("writing to stderr won't fail"); 1 } }; process::exit(code); } snapbox-0.6.21/src/cmd.rs000064400000000000000000000764701046102023000133020ustar 00000000000000//! Run commands and assert on their behavior #[cfg(feature = "color")] use anstream::panic; use crate::IntoData; /// Process spawning for testing of non-interactive commands #[derive(Debug)] pub struct Command { cmd: std::process::Command, stdin: Option, timeout: Option, _stderr_to_stdout: bool, config: crate::Assert, } /// # Builder API impl Command { pub fn new(program: impl AsRef) -> Self { Self { cmd: std::process::Command::new(program), stdin: None, timeout: None, _stderr_to_stdout: false, config: crate::Assert::new().action_env(crate::assert::DEFAULT_ACTION_ENV), } } /// Constructs a new `Command` from a `std` `Command`. pub fn from_std(cmd: std::process::Command) -> Self { Self { cmd, stdin: None, timeout: None, _stderr_to_stdout: false, config: crate::Assert::new().action_env(crate::assert::DEFAULT_ACTION_ENV), } } /// Customize the assertion behavior pub fn with_assert(mut self, config: crate::Assert) -> Self { self.config = config; self } /// Adds an argument to pass to the program. /// /// Only one argument can be passed per use. So instead of: /// /// ```no_run /// # snapbox::cmd::Command::new("sh") /// .arg("-C /path/to/repo") /// # ; /// ``` /// /// usage would be: /// /// ```no_run /// # snapbox::cmd::Command::new("sh") /// .arg("-C") /// .arg("/path/to/repo") /// # ; /// ``` /// /// To pass multiple arguments see [`args`]. /// /// [`args`]: Command::args() /// /// # Examples /// /// Basic usage: /// /// ```no_run /// use snapbox::cmd::Command; /// /// Command::new("ls") /// .arg("-l") /// .arg("-a") /// .assert() /// .success(); /// ``` pub fn arg(mut self, arg: impl AsRef) -> Self { self.cmd.arg(arg); self } /// Adds multiple arguments to pass to the program. /// /// To pass a single argument see [`arg`]. /// /// [`arg`]: Command::arg() /// /// # Examples /// /// Basic usage: /// /// ```no_run /// use snapbox::cmd::Command; /// /// Command::new("ls") /// .args(&["-l", "-a"]) /// .assert() /// .success(); /// ``` pub fn args(mut self, args: impl IntoIterator>) -> Self { self.cmd.args(args); self } /// Inserts or updates an environment variable mapping. /// /// Note that environment variable names are case-insensitive (but case-preserving) on Windows, /// and case-sensitive on all other platforms. /// /// # Examples /// /// Basic usage: /// /// ```no_run /// use snapbox::cmd::Command; /// /// Command::new("ls") /// .env("PATH", "/bin") /// .assert() /// .failure(); /// ``` pub fn env( mut self, key: impl AsRef, value: impl AsRef, ) -> Self { self.cmd.env(key, value); self } /// Adds or updates multiple environment variable mappings. /// /// # Examples /// /// Basic usage: /// /// ```no_run /// use snapbox::cmd::Command; /// use std::process::Stdio; /// use std::env; /// use std::collections::HashMap; /// /// let filtered_env : HashMap = /// env::vars().filter(|&(ref k, _)| /// k == "TERM" || k == "TZ" || k == "LANG" || k == "PATH" /// ).collect(); /// /// Command::new("printenv") /// .env_clear() /// .envs(&filtered_env) /// .assert() /// .success(); /// ``` pub fn envs( mut self, vars: impl IntoIterator, impl AsRef)>, ) -> Self { self.cmd.envs(vars); self } /// Removes an environment variable mapping. /// /// # Examples /// /// Basic usage: /// /// ```no_run /// use snapbox::cmd::Command; /// /// Command::new("ls") /// .env_remove("PATH") /// .assert() /// .failure(); /// ``` pub fn env_remove(mut self, key: impl AsRef) -> Self { self.cmd.env_remove(key); self } /// Clears the entire environment map for the child process. /// /// # Examples /// /// Basic usage: /// /// ```no_run /// use snapbox::cmd::Command; /// /// Command::new("ls") /// .env_clear() /// .assert() /// .failure(); /// ``` pub fn env_clear(mut self) -> Self { self.cmd.env_clear(); self } /// Sets the working directory for the child process. /// /// # Platform-specific behavior /// /// If the program path is relative (e.g., `"./script.sh"`), it's ambiguous /// whether it should be interpreted relative to the parent's working /// directory or relative to `current_dir`. The behavior in this case is /// platform specific and unstable, and it's recommended to use /// [`canonicalize`] to get an absolute program path instead. /// /// # Examples /// /// Basic usage: /// /// ```no_run /// use snapbox::cmd::Command; /// /// Command::new("ls") /// .current_dir("/bin") /// .assert() /// .success(); /// ``` /// /// [`canonicalize`]: std::fs::canonicalize() pub fn current_dir(mut self, dir: impl AsRef) -> Self { self.cmd.current_dir(dir); self } /// Write `buffer` to `stdin` when the `Command` is run. /// /// # Examples /// /// ```rust /// use snapbox::cmd::Command; /// /// let mut cmd = Command::new("cat") /// .arg("-et") /// .stdin("42") /// .assert() /// .stdout_eq("42"); /// ``` pub fn stdin(mut self, stream: impl IntoData) -> Self { self.stdin = Some(stream.into_data()); self } /// Error out if a timeout is reached /// /// ```rust,no_run /// use snapbox::cmd::Command; /// use snapbox::cmd::cargo_bin; /// /// let assert = Command::new(cargo_bin("snap-fixture")) /// .timeout(std::time::Duration::from_secs(1)) /// .env("sleep", "100") /// .assert() /// .failure(); /// ``` #[cfg(feature = "cmd")] pub fn timeout(mut self, timeout: std::time::Duration) -> Self { self.timeout = Some(timeout); self } /// Merge `stderr` into `stdout` #[cfg(feature = "cmd")] pub fn stderr_to_stdout(mut self) -> Self { self._stderr_to_stdout = true; self } } /// # Run Command impl Command { /// Run the command and assert on the results /// /// ```rust /// use snapbox::cmd::Command; /// /// let mut cmd = Command::new("cat") /// .arg("-et") /// .stdin("42") /// .assert() /// .stdout_eq("42"); /// ``` #[track_caller] pub fn assert(self) -> OutputAssert { let config = self.config.clone(); match self.output() { Ok(output) => OutputAssert::new(output).with_assert(config), Err(err) => { panic!("Failed to spawn: {}", err) } } } /// Run the command and capture the `Output` #[cfg(feature = "cmd")] pub fn output(self) -> Result { if self._stderr_to_stdout { self.single_output() } else { self.split_output() } } #[cfg(not(feature = "cmd"))] pub fn output(self) -> Result { self.split_output() } #[cfg(feature = "cmd")] fn single_output(mut self) -> Result { self.cmd.stdin(std::process::Stdio::piped()); let (reader, writer) = os_pipe::pipe()?; let writer_clone = writer.try_clone()?; self.cmd.stdout(writer); self.cmd.stderr(writer_clone); let mut child = self.cmd.spawn()?; // Avoid a deadlock! This parent process is still holding open pipe // writers (inside the Command object), and we have to close those // before we read. Here we do this by dropping the Command object. drop(self.cmd); let stdin = self .stdin .as_ref() .map(|d| d.to_bytes()) .transpose() .map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?; let stdout = process_single_io(&mut child, reader, stdin)?; let status = wait(child, self.timeout)?; let stdout = stdout.join().unwrap().ok().unwrap_or_default(); Ok(std::process::Output { status, stdout, stderr: Default::default(), }) } fn split_output(mut self) -> Result { self.cmd.stdin(std::process::Stdio::piped()); self.cmd.stdout(std::process::Stdio::piped()); self.cmd.stderr(std::process::Stdio::piped()); let mut child = self.cmd.spawn()?; let stdin = self .stdin .as_ref() .map(|d| d.to_bytes()) .transpose() .map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?; let (stdout, stderr) = process_split_io(&mut child, stdin)?; let status = wait(child, self.timeout)?; let stdout = stdout .and_then(|t| t.join().unwrap().ok()) .unwrap_or_default(); let stderr = stderr .and_then(|t| t.join().unwrap().ok()) .unwrap_or_default(); Ok(std::process::Output { status, stdout, stderr, }) } } fn process_split_io( child: &mut std::process::Child, input: Option>, ) -> std::io::Result<(Option, Option)> { use std::io::Write; let stdin = input.and_then(|i| { child .stdin .take() .map(|mut stdin| std::thread::spawn(move || stdin.write_all(&i))) }); let stdout = child.stdout.take().map(threaded_read); let stderr = child.stderr.take().map(threaded_read); // Finish writing stdin before waiting, because waiting drops stdin. stdin.and_then(|t| t.join().unwrap().ok()); Ok((stdout, stderr)) } #[cfg(feature = "cmd")] fn process_single_io( child: &mut std::process::Child, stdout: os_pipe::PipeReader, input: Option>, ) -> std::io::Result { use std::io::Write; let stdin = input.and_then(|i| { child .stdin .take() .map(|mut stdin| std::thread::spawn(move || stdin.write_all(&i))) }); let stdout = threaded_read(stdout); debug_assert!(child.stdout.is_none()); debug_assert!(child.stderr.is_none()); // Finish writing stdin before waiting, because waiting drops stdin. stdin.and_then(|t| t.join().unwrap().ok()); Ok(stdout) } type Stream = std::thread::JoinHandle, std::io::Error>>; fn threaded_read(mut input: R) -> Stream where R: std::io::Read + Send + 'static, { std::thread::spawn(move || { let mut ret = Vec::new(); input.read_to_end(&mut ret).map(|_| ret) }) } impl From for Command { fn from(cmd: std::process::Command) -> Self { Self::from_std(cmd) } } /// Assert the state of a [`Command`]'s [`Output`]. /// /// Create an `OutputAssert` through the [`Command::assert`]. /// /// [`Output`]: std::process::Output pub struct OutputAssert { output: std::process::Output, config: crate::Assert, } impl OutputAssert { /// Create an `Assert` for a given [`Output`]. /// /// [`Output`]: std::process::Output pub fn new(output: std::process::Output) -> Self { Self { output, config: crate::Assert::new().action_env(crate::assert::DEFAULT_ACTION_ENV), } } /// Customize the assertion behavior pub fn with_assert(mut self, config: crate::Assert) -> Self { self.config = config; self } /// Access the contained [`Output`]. /// /// [`Output`]: std::process::Output pub fn get_output(&self) -> &std::process::Output { &self.output } /// Ensure the command succeeded. /// /// ```rust,no_run /// use snapbox::cmd::Command; /// use snapbox::cmd::cargo_bin; /// /// let assert = Command::new(cargo_bin("snap-fixture")) /// .assert() /// .success(); /// ``` #[track_caller] pub fn success(self) -> Self { if !self.output.status.success() { let desc = format!( "Expected {}, was {}", self.config.palette.info("success"), self.config .palette .error(display_exit_status(self.output.status)) ); use std::fmt::Write; let mut buf = String::new(); writeln!(&mut buf, "{desc}").unwrap(); self.write_stdout(&mut buf).unwrap(); self.write_stderr(&mut buf).unwrap(); panic!("{}", buf); } self } /// Ensure the command failed. /// /// ```rust,no_run /// use snapbox::cmd::Command; /// use snapbox::cmd::cargo_bin; /// /// let assert = Command::new(cargo_bin("snap-fixture")) /// .env("exit", "1") /// .assert() /// .failure(); /// ``` #[track_caller] pub fn failure(self) -> Self { if self.output.status.success() { let desc = format!( "Expected {}, was {}", self.config.palette.info("failure"), self.config.palette.error("success") ); use std::fmt::Write; let mut buf = String::new(); writeln!(&mut buf, "{desc}").unwrap(); self.write_stdout(&mut buf).unwrap(); self.write_stderr(&mut buf).unwrap(); panic!("{}", buf); } self } /// Ensure the command aborted before returning a code. #[track_caller] pub fn interrupted(self) -> Self { if self.output.status.code().is_some() { let desc = format!( "Expected {}, was {}", self.config.palette.info("interrupted"), self.config .palette .error(display_exit_status(self.output.status)) ); use std::fmt::Write; let mut buf = String::new(); writeln!(&mut buf, "{desc}").unwrap(); self.write_stdout(&mut buf).unwrap(); self.write_stderr(&mut buf).unwrap(); panic!("{}", buf); } self } /// Ensure the command returned the expected code. /// /// ```rust,no_run /// use snapbox::cmd::Command; /// use snapbox::cmd::cargo_bin; /// /// let assert = Command::new(cargo_bin("snap-fixture")) /// .env("exit", "42") /// .assert() /// .code(42); /// ``` #[track_caller] pub fn code(self, expected: i32) -> Self { if self.output.status.code() != Some(expected) { let desc = format!( "Expected {}, was {}", self.config.palette.info(expected), self.config .palette .error(display_exit_status(self.output.status)) ); use std::fmt::Write; let mut buf = String::new(); writeln!(&mut buf, "{desc}").unwrap(); self.write_stdout(&mut buf).unwrap(); self.write_stderr(&mut buf).unwrap(); panic!("{}", buf); } self } /// Ensure the command wrote the expected data to `stdout`. /// /// By default [`filters`][crate::filter] are applied, including: /// - `...` is a line-wildcard when on a line by itself /// - `[..]` is a character-wildcard when inside a line /// - `[EXE]` matches `.exe` on Windows /// - `"{...}"` is a JSON value wildcard /// - `"...": "{...}"` is a JSON key-value wildcard /// - `\` to `/` /// - Newlines /// /// To limit this to newline normalization for text, call [`Data::raw`][crate::Data::raw] on `expected`. /// /// # Examples /// /// ```rust,no_run /// use snapbox::cmd::Command; /// use snapbox::cmd::cargo_bin; /// /// let assert = Command::new(cargo_bin("snap-fixture")) /// .env("stdout", "hello") /// .env("stderr", "world") /// .assert() /// .stdout_eq("he[..]o"); /// ``` /// /// Can combine this with [`file!`][crate::file] /// ```rust,no_run /// use snapbox::cmd::Command; /// use snapbox::cmd::cargo_bin; /// use snapbox::file; /// /// let assert = Command::new(cargo_bin("snap-fixture")) /// .env("stdout", "hello") /// .env("stderr", "world") /// .assert() /// .stdout_eq(file!["stdout.log"]); /// ``` #[track_caller] pub fn stdout_eq(self, expected: impl IntoData) -> Self { let expected = expected.into_data(); self.stdout_eq_inner(expected) } #[track_caller] #[deprecated(since = "0.6.0", note = "Replaced with `OutputAssert::stdout_eq`")] pub fn stdout_eq_(self, expected: impl IntoData) -> Self { self.stdout_eq(expected) } #[track_caller] fn stdout_eq_inner(self, expected: crate::Data) -> Self { let actual = self.output.stdout.as_slice().into_data(); if let Err(err) = self.config.try_eq(Some(&"stdout"), actual, expected) { err.panic(); } self } /// Ensure the command wrote the expected data to `stderr`. /// /// By default [`filters`][crate::filter] are applied, including: /// - `...` is a line-wildcard when on a line by itself /// - `[..]` is a character-wildcard when inside a line /// - `[EXE]` matches `.exe` on Windows /// - `"{...}"` is a JSON value wildcard /// - `"...": "{...}"` is a JSON key-value wildcard /// - `\` to `/` /// - Newlines /// /// To limit this to newline normalization for text, call [`Data::raw`][crate::Data::raw] on `expected`. /// /// # Examples /// /// ```rust,no_run /// use snapbox::cmd::Command; /// use snapbox::cmd::cargo_bin; /// /// let assert = Command::new(cargo_bin("snap-fixture")) /// .env("stdout", "hello") /// .env("stderr", "world") /// .assert() /// .stderr_eq("wo[..]d"); /// ``` /// /// Can combine this with [`file!`][crate::file] /// ```rust,no_run /// use snapbox::cmd::Command; /// use snapbox::cmd::cargo_bin; /// use snapbox::file; /// /// let assert = Command::new(cargo_bin("snap-fixture")) /// .env("stdout", "hello") /// .env("stderr", "world") /// .assert() /// .stderr_eq(file!["stderr.log"]); /// ``` #[track_caller] pub fn stderr_eq(self, expected: impl IntoData) -> Self { let expected = expected.into_data(); self.stderr_eq_inner(expected) } #[track_caller] #[deprecated(since = "0.6.0", note = "Replaced with `OutputAssert::stderr_eq`")] pub fn stderr_eq_(self, expected: impl IntoData) -> Self { self.stderr_eq(expected) } #[track_caller] fn stderr_eq_inner(self, expected: crate::Data) -> Self { let actual = self.output.stderr.as_slice().into_data(); if let Err(err) = self.config.try_eq(Some(&"stderr"), actual, expected) { err.panic(); } self } fn write_stdout(&self, writer: &mut dyn std::fmt::Write) -> Result<(), std::fmt::Error> { if !self.output.stdout.is_empty() { writeln!(writer, "stdout:")?; writeln!(writer, "```")?; writeln!(writer, "{}", String::from_utf8_lossy(&self.output.stdout))?; writeln!(writer, "```")?; } Ok(()) } fn write_stderr(&self, writer: &mut dyn std::fmt::Write) -> Result<(), std::fmt::Error> { if !self.output.stderr.is_empty() { writeln!(writer, "stderr:")?; writeln!(writer, "```")?; writeln!(writer, "{}", String::from_utf8_lossy(&self.output.stderr))?; writeln!(writer, "```")?; } Ok(()) } } /// Converts an [`std::process::ExitStatus`] to a human-readable value #[cfg(not(feature = "cmd"))] pub fn display_exit_status(status: std::process::ExitStatus) -> String { basic_exit_status(status) } /// Converts an [`std::process::ExitStatus`] to a human-readable value #[cfg(feature = "cmd")] pub fn display_exit_status(status: std::process::ExitStatus) -> String { #[cfg(unix)] fn detailed_exit_status(status: std::process::ExitStatus) -> Option { use std::os::unix::process::ExitStatusExt; let signal = status.signal()?; let name = match signal as libc::c_int { libc::SIGABRT => ", SIGABRT: process abort signal", libc::SIGALRM => ", SIGALRM: alarm clock", libc::SIGFPE => ", SIGFPE: erroneous arithmetic operation", libc::SIGHUP => ", SIGHUP: hangup", libc::SIGILL => ", SIGILL: illegal instruction", libc::SIGINT => ", SIGINT: terminal interrupt signal", libc::SIGKILL => ", SIGKILL: kill", libc::SIGPIPE => ", SIGPIPE: write on a pipe with no one to read", libc::SIGQUIT => ", SIGQUIT: terminal quit signal", libc::SIGSEGV => ", SIGSEGV: invalid memory reference", libc::SIGTERM => ", SIGTERM: termination signal", libc::SIGBUS => ", SIGBUS: access to undefined memory", #[cfg(not(target_os = "haiku"))] libc::SIGSYS => ", SIGSYS: bad system call", libc::SIGTRAP => ", SIGTRAP: trace/breakpoint trap", _ => "", }; Some(format!("signal: {signal}{name}")) } #[cfg(windows)] fn detailed_exit_status(status: std::process::ExitStatus) -> Option { use windows_sys::Win32::Foundation::*; let extra = match status.code().unwrap() as NTSTATUS { STATUS_ACCESS_VIOLATION => "STATUS_ACCESS_VIOLATION", STATUS_IN_PAGE_ERROR => "STATUS_IN_PAGE_ERROR", STATUS_INVALID_HANDLE => "STATUS_INVALID_HANDLE", STATUS_INVALID_PARAMETER => "STATUS_INVALID_PARAMETER", STATUS_NO_MEMORY => "STATUS_NO_MEMORY", STATUS_ILLEGAL_INSTRUCTION => "STATUS_ILLEGAL_INSTRUCTION", STATUS_NONCONTINUABLE_EXCEPTION => "STATUS_NONCONTINUABLE_EXCEPTION", STATUS_INVALID_DISPOSITION => "STATUS_INVALID_DISPOSITION", STATUS_ARRAY_BOUNDS_EXCEEDED => "STATUS_ARRAY_BOUNDS_EXCEEDED", STATUS_FLOAT_DENORMAL_OPERAND => "STATUS_FLOAT_DENORMAL_OPERAND", STATUS_FLOAT_DIVIDE_BY_ZERO => "STATUS_FLOAT_DIVIDE_BY_ZERO", STATUS_FLOAT_INEXACT_RESULT => "STATUS_FLOAT_INEXACT_RESULT", STATUS_FLOAT_INVALID_OPERATION => "STATUS_FLOAT_INVALID_OPERATION", STATUS_FLOAT_OVERFLOW => "STATUS_FLOAT_OVERFLOW", STATUS_FLOAT_STACK_CHECK => "STATUS_FLOAT_STACK_CHECK", STATUS_FLOAT_UNDERFLOW => "STATUS_FLOAT_UNDERFLOW", STATUS_INTEGER_DIVIDE_BY_ZERO => "STATUS_INTEGER_DIVIDE_BY_ZERO", STATUS_INTEGER_OVERFLOW => "STATUS_INTEGER_OVERFLOW", STATUS_PRIVILEGED_INSTRUCTION => "STATUS_PRIVILEGED_INSTRUCTION", STATUS_STACK_OVERFLOW => "STATUS_STACK_OVERFLOW", STATUS_DLL_NOT_FOUND => "STATUS_DLL_NOT_FOUND", STATUS_ORDINAL_NOT_FOUND => "STATUS_ORDINAL_NOT_FOUND", STATUS_ENTRYPOINT_NOT_FOUND => "STATUS_ENTRYPOINT_NOT_FOUND", STATUS_CONTROL_C_EXIT => "STATUS_CONTROL_C_EXIT", STATUS_DLL_INIT_FAILED => "STATUS_DLL_INIT_FAILED", STATUS_FLOAT_MULTIPLE_FAULTS => "STATUS_FLOAT_MULTIPLE_FAULTS", STATUS_FLOAT_MULTIPLE_TRAPS => "STATUS_FLOAT_MULTIPLE_TRAPS", STATUS_REG_NAT_CONSUMPTION => "STATUS_REG_NAT_CONSUMPTION", STATUS_HEAP_CORRUPTION => "STATUS_HEAP_CORRUPTION", STATUS_STACK_BUFFER_OVERRUN => "STATUS_STACK_BUFFER_OVERRUN", STATUS_ASSERTION_FAILURE => "STATUS_ASSERTION_FAILURE", _ => return None, }; Some(extra.to_owned()) } if let Some(extra) = detailed_exit_status(status) { format!("{} ({})", basic_exit_status(status), extra) } else { basic_exit_status(status) } } fn basic_exit_status(status: std::process::ExitStatus) -> String { if let Some(code) = status.code() { code.to_string() } else { "interrupted".to_owned() } } #[cfg(feature = "cmd")] fn wait( mut child: std::process::Child, timeout: Option, ) -> std::io::Result { if let Some(timeout) = timeout { wait_timeout::ChildExt::wait_timeout(&mut child, timeout) .transpose() .unwrap_or_else(|| { let _ = child.kill(); child.wait() }) } else { child.wait() } } #[cfg(not(feature = "cmd"))] fn wait( mut child: std::process::Child, _timeout: Option, ) -> std::io::Result { child.wait() } pub use snapbox_macros::cargo_bin; /// Look up the path to a cargo-built binary within an integration test. /// /// **NOTE:** Prefer [`cargo_bin!`] as this makes assumptions about cargo pub fn cargo_bin(name: &str) -> std::path::PathBuf { let file_name = format!("{}{}", name, std::env::consts::EXE_SUFFIX); let target_dir = target_dir(); target_dir.join(file_name) } // Adapted from // https://github.com/rust-lang/cargo/blob/485670b3983b52289a2f353d589c57fae2f60f82/tests/testsuite/support/mod.rs#L507 fn target_dir() -> std::path::PathBuf { std::env::current_exe() .ok() .map(|mut path| { path.pop(); if path.ends_with("deps") { path.pop(); } path }) .unwrap() } #[cfg(feature = "examples")] pub use examples::{compile_example, compile_examples}; #[cfg(feature = "examples")] pub(crate) mod examples { /// Prepare an example for testing /// /// Unlike `cargo_bin!`, this does not inherit all of the current compiler settings. It /// will match the current target and profile but will not get feature flags. Pass those arguments /// to the compiler via `args`. /// /// ## Example /// /// ```rust,no_run /// snapbox::cmd::compile_example("snap-example-fixture", []); /// ``` #[cfg(feature = "examples")] pub fn compile_example<'a>( target_name: &str, args: impl IntoIterator, ) -> crate::assert::Result { crate::debug!("Compiling example {}", target_name); let messages = escargot::CargoBuild::new() .current_target() .current_release() .example(target_name) .args(args) .exec() .map_err(|e| crate::assert::Error::new(e.to_string()))?; for message in messages { let message = message.map_err(|e| crate::assert::Error::new(e.to_string()))?; let message = message .decode() .map_err(|e| crate::assert::Error::new(e.to_string()))?; crate::debug!("Message: {:?}", message); if let Some(bin) = decode_example_message(&message) { let (name, bin) = bin?; assert_eq!(target_name, name); return bin; } } Err(crate::assert::Error::new(format!( "Unknown error building example {target_name}" ))) } /// Prepare all examples for testing /// /// Unlike `cargo_bin!`, this does not inherit all of the current compiler settings. It /// will match the current target and profile but will not get feature flags. Pass those arguments /// to the compiler via `args`. /// /// ## Example /// /// ```rust,no_run /// let examples = snapbox::cmd::compile_examples([]).unwrap().collect::>(); /// ``` #[cfg(feature = "examples")] pub fn compile_examples<'a>( args: impl IntoIterator, ) -> crate::assert::Result< impl Iterator)>, > { crate::debug!("Compiling examples"); let mut examples = std::collections::BTreeMap::new(); let messages = escargot::CargoBuild::new() .current_target() .current_release() .examples() .args(args) .exec() .map_err(|e| crate::assert::Error::new(e.to_string()))?; for message in messages { let message = message.map_err(|e| crate::assert::Error::new(e.to_string()))?; let message = message .decode() .map_err(|e| crate::assert::Error::new(e.to_string()))?; crate::debug!("Message: {:?}", message); if let Some(bin) = decode_example_message(&message) { let (name, bin) = bin?; examples.insert(name.to_owned(), bin); } } Ok(examples.into_iter()) } #[allow(clippy::type_complexity)] fn decode_example_message<'m>( message: &'m escargot::format::Message<'_>, ) -> Option)>> { match message { escargot::format::Message::CompilerMessage(msg) => { let level = msg.message.level; if level == escargot::format::diagnostic::DiagnosticLevel::Ice || level == escargot::format::diagnostic::DiagnosticLevel::Error { let output = msg .message .rendered .as_deref() .unwrap_or_else(|| msg.message.message.as_ref()) .to_owned(); if is_example_target(&msg.target) { let bin = Err(crate::assert::Error::new(output)); Some(Ok((msg.target.name.as_ref(), bin))) } else { Some(Err(crate::assert::Error::new(output))) } } else { None } } escargot::format::Message::CompilerArtifact(artifact) => { if !artifact.profile.test && is_example_target(&artifact.target) { let path = artifact .executable .clone() .expect("cargo is new enough for this to be present"); let bin = Ok(path.into_owned()); Some(Ok((artifact.target.name.as_ref(), bin))) } else { None } } _ => None, } } fn is_example_target(target: &escargot::format::Target<'_>) -> bool { target.crate_types == ["bin"] && target.kind == ["example"] } } snapbox-0.6.21/src/data/filters.rs000064400000000000000000000033571046102023000151120ustar 00000000000000use crate::data::DataFormat; #[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] pub(crate) struct FilterSet { flags: usize, against: Option, } impl FilterSet { pub(crate) fn new() -> Self { Self::empty().redactions().newlines().paths() } pub(crate) const fn empty() -> Self { Self { flags: 0, against: None, } } pub(crate) fn redactions(mut self) -> Self { self.set(Self::REDACTIONS); self } pub(crate) fn newlines(mut self) -> Self { self.set(Self::NEWLINES); self } pub(crate) fn paths(mut self) -> Self { self.set(Self::PATHS); self } pub(crate) fn unordered(mut self) -> Self { self.set(Self::UNORDERED); self } pub(crate) fn against(mut self, format: DataFormat) -> Self { self.against = Some(format); self } pub(crate) const fn is_redaction_set(&self) -> bool { self.is_set(Self::REDACTIONS) } pub(crate) const fn is_newlines_set(&self) -> bool { self.is_set(Self::NEWLINES) } pub(crate) const fn is_paths_set(&self) -> bool { self.is_set(Self::PATHS) } pub(crate) const fn is_unordered_set(&self) -> bool { self.is_set(Self::UNORDERED) } pub(crate) const fn get_against(&self) -> Option { self.against } } impl FilterSet { const REDACTIONS: usize = 1 << 0; const NEWLINES: usize = 1 << 1; const PATHS: usize = 1 << 2; const UNORDERED: usize = 1 << 3; fn set(&mut self, flag: usize) -> &mut Self { self.flags |= flag; self } const fn is_set(&self, flag: usize) -> bool { self.flags & flag != 0 } } snapbox-0.6.21/src/data/format.rs000064400000000000000000000034741046102023000147320ustar 00000000000000/// Describes the structure of [`Data`][crate::Data] #[derive(Clone, Debug, PartialEq, Eq, Copy, Hash, Default)] #[non_exhaustive] pub enum DataFormat { /// Processing of the [`Data`][crate::Data] failed Error, /// Non-textual, opaque data Binary, #[default] Text, #[cfg(feature = "json")] Json, /// Streamed JSON output according to #[cfg(feature = "json")] JsonLines, /// [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code#DOS_and_Windows) /// rendered as [svg](https://docs.rs/anstyle-svg) #[cfg(feature = "term-svg")] TermSvg, } impl DataFormat { /// Assumed file extension for the format pub fn ext(self) -> &'static str { match self { Self::Error => "txt", Self::Binary => "bin", Self::Text => "txt", #[cfg(feature = "json")] Self::Json => "json", #[cfg(feature = "json")] Self::JsonLines => "jsonl", #[cfg(feature = "term-svg")] Self::TermSvg => "term.svg", } } } impl From<&std::path::Path> for DataFormat { fn from(path: &std::path::Path) -> Self { let file_name = path .file_name() .and_then(|e| e.to_str()) .unwrap_or_default(); let (file_stem, mut ext) = file_name.split_once('.').unwrap_or((file_name, "")); if file_stem.is_empty() { (_, ext) = file_stem.split_once('.').unwrap_or((file_name, "")); } match ext { #[cfg(feature = "json")] "json" => DataFormat::Json, #[cfg(feature = "json")] "jsonl" => DataFormat::JsonLines, #[cfg(feature = "term-svg")] "term.svg" => Self::TermSvg, _ => DataFormat::Text, } } } snapbox-0.6.21/src/data/mod.rs000064400000000000000000001021711046102023000142130ustar 00000000000000//! `actual` and `expected` [`Data`] for testing code mod filters; mod format; mod runtime; mod source; #[cfg(test)] mod tests; pub use format::DataFormat; pub use source::DataSource; pub use source::Inline; #[doc(hidden)] pub use source::Position; use filters::FilterSet; /// Capture the pretty debug representation of a value /// /// Note: this is fairly brittle as debug representations are not generally subject to semver /// guarantees. /// /// ```rust,no_run /// use snapbox::ToDebug as _; /// /// fn some_function() -> usize { /// // ... /// # 5 /// } /// /// let actual = some_function(); /// let expected = snapbox::str![["5"]]; /// snapbox::assert_data_eq!(actual.to_debug(), expected); /// ``` pub trait ToDebug { fn to_debug(&self) -> Data; } impl ToDebug for D { fn to_debug(&self) -> Data { Data::text(format!("{self:#?}\n")) } } /// Capture the serde representation of a value /// /// # Examples /// /// ```rust,no_run /// use snapbox::IntoJson as _; /// /// fn some_function() -> usize { /// // ... /// # 5 /// } /// /// let actual = some_function(); /// let expected = snapbox::str![["5"]]; /// snapbox::assert_data_eq!(actual.into_json(), expected); /// ``` #[cfg(feature = "json")] pub trait IntoJson { fn into_json(self) -> Data; } #[cfg(feature = "json")] impl IntoJson for S { fn into_json(self) -> Data { match serde_json::to_value(self) { Ok(value) => Data::json(value), Err(err) => Data::error(err.to_string(), DataFormat::Json), } } } /// Convert to [`Data`] with modifiers for `expected` data #[allow(clippy::wrong_self_convention)] pub trait IntoData: Sized { /// Remove default [`filters`][crate::filter] from this `expected` result fn raw(self) -> Data { self.into_data().raw() } /// Treat lines and json arrays as unordered /// /// # Examples /// /// ```rust /// # #[cfg(feature = "json")] { /// use snapbox::prelude::*; /// use snapbox::str; /// use snapbox::assert_data_eq; /// /// let actual = str![[r#"["world", "hello"]"#]] /// .is(snapbox::data::DataFormat::Json) /// .unordered(); /// let expected = str![[r#"["hello", "world"]"#]] /// .is(snapbox::data::DataFormat::Json) /// .unordered(); /// assert_data_eq!(actual, expected); /// # } /// ``` fn unordered(self) -> Data { self.into_data().unordered() } /// Initialize as [`format`][DataFormat] or [`Error`][DataFormat::Error] /// /// This is generally used for `expected` data /// /// # Examples /// /// ```rust /// # #[cfg(feature = "json")] { /// use snapbox::prelude::*; /// use snapbox::str; /// /// let expected = str![[r#"{"hello": "world"}"#]] /// .is(snapbox::data::DataFormat::Json); /// assert_eq!(expected.format(), snapbox::data::DataFormat::Json); /// # } /// ``` fn is(self, format: DataFormat) -> Data { self.into_data().is(format) } /// Initialize as json or [`Error`][DataFormat::Error] /// /// This is generally used for `expected` data /// /// # Examples /// /// ```rust /// # #[cfg(feature = "json")] { /// use snapbox::prelude::*; /// use snapbox::str; /// /// let expected = str![[r#"{"hello": "world"}"#]] /// .is_json(); /// assert_eq!(expected.format(), snapbox::data::DataFormat::Json); /// # } /// ``` #[cfg(feature = "json")] fn is_json(self) -> Data { self.is(DataFormat::Json) } #[cfg(feature = "json")] #[deprecated(since = "0.6.13", note = "Replaced with `IntoData::is_json`")] fn json(self) -> Data { self.is_json() } /// Initialize as json lines or [`Error`][DataFormat::Error] /// /// This is generally used for `expected` data /// /// # Examples /// /// ```rust /// # #[cfg(feature = "json")] { /// use snapbox::prelude::*; /// use snapbox::str; /// /// let expected = str![[r#"{"hello": "world"}"#]] /// .is_jsonlines(); /// assert_eq!(expected.format(), snapbox::data::DataFormat::JsonLines); /// # } /// ``` #[cfg(feature = "json")] fn is_jsonlines(self) -> Data { self.is(DataFormat::JsonLines) } #[cfg(feature = "json")] #[deprecated(since = "0.6.13", note = "Replaced with `IntoData::is_jsonlines`")] fn json_lines(self) -> Data { self.is_jsonlines() } /// Initialize as Term SVG /// /// This is generally used for `expected` data #[cfg(feature = "term-svg")] fn is_termsvg(self) -> Data { self.is(DataFormat::TermSvg) } #[cfg(feature = "term-svg")] #[deprecated(since = "0.6.13", note = "Replaced with `IntoData::is_termsvg`")] fn term_svg(self) -> Data { self.is_termsvg() } /// Override the type this snapshot will be compared against /// /// Normally, the `actual` data is coerced to [`IntoData::is`]. /// This allows overriding that so you can store your snapshot in a more readable, diffable /// format. /// /// # Examples /// /// ```rust /// # #[cfg(feature = "json")] { /// use snapbox::prelude::*; /// use snapbox::str; /// /// let expected = str![[r#"{"hello": "world"}"#]] /// .against(snapbox::data::DataFormat::JsonLines); /// # } /// ``` fn against(self, format: DataFormat) -> Data { self.into_data().against(format) } /// Initialize as json or [`Error`][DataFormat::Error] /// /// This is generally used for `expected` data /// /// # Examples /// /// ```rust /// # #[cfg(feature = "json")] { /// use snapbox::prelude::*; /// use snapbox::str; /// /// let expected = str![[r#"{"hello": "world"}"#]] /// .is_json(); /// # } /// ``` #[cfg(feature = "json")] fn against_json(self) -> Data { self.against(DataFormat::Json) } /// Initialize as json lines or [`Error`][DataFormat::Error] /// /// This is generally used for `expected` data /// /// # Examples /// /// ```rust /// # #[cfg(feature = "json")] { /// use snapbox::prelude::*; /// use snapbox::str; /// /// let expected = str![[r#"{"hello": "world"}"#]] /// .against_jsonlines(); /// # } /// ``` #[cfg(feature = "json")] fn against_jsonlines(self) -> Data { self.against(DataFormat::JsonLines) } /// Convert to [`Data`], applying defaults fn into_data(self) -> Data; } impl IntoData for Data { fn into_data(self) -> Data { self } } impl IntoData for &'_ Data { fn into_data(self) -> Data { self.clone() } } impl IntoData for Vec { fn into_data(self) -> Data { Data::binary(self) } } impl IntoData for &'_ [u8] { fn into_data(self) -> Data { self.to_owned().into_data() } } impl IntoData for String { fn into_data(self) -> Data { Data::text(self) } } impl IntoData for &'_ String { fn into_data(self) -> Data { self.to_owned().into_data() } } impl IntoData for &'_ str { fn into_data(self) -> Data { self.to_owned().into_data() } } impl IntoData for Inline { fn into_data(self) -> Data { let trimmed = self.trimmed(); Data::text(trimmed).with_source(self) } } /// Declare an expected value for an assert from a file /// /// This is relative to the source file the macro is run from /// /// Output type: [`Data`] /// /// ``` /// # #[cfg(feature = "json")] { /// # use snapbox::file; /// file!["./test_data/bar.json"]; /// file!["./test_data/bar.json": Text]; // do textual rather than structural comparisons /// file![_]; /// file![_: Json]; // ensure its treated as json since a type can't be inferred /// # } /// ``` #[macro_export] macro_rules! file { [_] => {{ let path = $crate::data::generate_snapshot_path($crate::fn_path!(), None); $crate::Data::read_from(&path, None) }}; [_ : $type:ident] => {{ let format = $crate::data::DataFormat:: $type; let path = $crate::data::generate_snapshot_path($crate::fn_path!(), Some(format)); $crate::Data::read_from(&path, Some($crate::data::DataFormat:: $type)) }}; [$path:literal] => {{ let mut path = $crate::utils::current_dir!(); path.push($path); $crate::Data::read_from(&path, None) }}; [$path:literal : $type:ident] => {{ let mut path = $crate::utils::current_dir!(); path.push($path); $crate::Data::read_from(&path, Some($crate::data::DataFormat:: $type)) }}; } /// Declare an expected value from within Rust source /// /// Output type: [`Inline`], see [`IntoData`] for operations /// /// ``` /// # use snapbox::str; /// str![[" /// Foo { value: 92 } /// "]]; /// str![r#"{"Foo": 92}"#]; /// ``` #[macro_export] macro_rules! str { [$data:literal] => { $crate::str![[$data]] }; [[$data:literal]] => {{ let position = $crate::data::Position { file: $crate::utils::current_rs!(), line: line!(), column: column!(), }; let inline = $crate::data::Inline { position, data: $data, }; inline }}; [] => { $crate::str![[""]] }; [[]] => { $crate::str![[""]] }; } /// Test fixture, actual output, or expected result /// /// This provides conveniences for tracking the intended format (binary vs text). #[derive(Clone, Debug)] pub struct Data { pub(crate) inner: DataInner, pub(crate) source: Option, pub(crate) filters: FilterSet, } #[derive(Clone, Debug)] pub(crate) enum DataInner { Error(DataError), Binary(Vec), Text(String), #[cfg(feature = "json")] Json(serde_json::Value), // Always a `Value::Array` but using `Value` for easier bookkeeping #[cfg(feature = "json")] JsonLines(serde_json::Value), #[cfg(feature = "term-svg")] TermSvg(String), } /// # Constructors /// /// See also /// - [`str!`] for inline snapshots /// - [`file!`] for external snapshots /// - [`ToString`] for verifying a `Display` representation /// - [`ToDebug`] for verifying a debug representation /// - [`IntoJson`] for verifying the serde representation /// - [`IntoData`] for modifying `expected` impl Data { /// Mark the data as binary (no post-processing) pub fn binary(raw: impl Into>) -> Self { Self::with_inner(DataInner::Binary(raw.into())) } /// Mark the data as text (post-processing) pub fn text(raw: impl Into) -> Self { Self::with_inner(DataInner::Text(raw.into())) } #[cfg(feature = "json")] pub fn json(raw: impl Into) -> Self { Self::with_inner(DataInner::Json(raw.into())) } #[cfg(feature = "json")] pub fn jsonlines(raw: impl Into>) -> Self { Self::with_inner(DataInner::JsonLines(serde_json::Value::Array(raw.into()))) } fn error(raw: impl Into, intended: DataFormat) -> Self { Self::with_inner(DataInner::Error(DataError { error: raw.into(), intended, })) } /// Empty test data pub fn new() -> Self { Self::text("") } /// Load `expected` data from a file pub fn read_from(path: &std::path::Path, data_format: Option) -> Self { match Self::try_read_from(path, data_format) { Ok(data) => data, Err(err) => Self::error(err, data_format.unwrap_or_else(|| DataFormat::from(path))) .with_path(path), } } /// Remove default [`filters`][crate::filter] from this `expected` result pub fn raw(mut self) -> Self { self.filters = FilterSet::empty().newlines(); self } /// Treat lines and json arrays as unordered pub fn unordered(mut self) -> Self { self.filters = self.filters.unordered(); self } } /// # Assertion frameworks operations /// /// For example, see [`OutputAssert`][crate::cmd::OutputAssert] impl Data { pub(crate) fn with_inner(inner: DataInner) -> Self { Self { inner, source: None, filters: FilterSet::new(), } } fn with_source(mut self, source: impl Into) -> Self { self.source = Some(source.into()); self } fn with_path(self, path: impl Into) -> Self { self.with_source(path.into()) } /// Load `expected` data from a file pub fn try_read_from( path: &std::path::Path, data_format: Option, ) -> crate::assert::Result { let data = std::fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; let data = Self::binary(data); let data = match data_format { Some(df) => data.is(df), None => { let inferred_format = DataFormat::from(path); match inferred_format { #[cfg(feature = "json")] DataFormat::Json | DataFormat::JsonLines => data.coerce_to(inferred_format), #[cfg(feature = "term-svg")] DataFormat::TermSvg => { let data = data.coerce_to(DataFormat::Text); data.is(inferred_format) } _ => data.coerce_to(DataFormat::Text), } } }; Ok(data.with_path(path)) } /// Overwrite a snapshot pub fn write_to(&self, source: &DataSource) -> crate::assert::Result<()> { match &source.inner { source::DataSourceInner::Path(p) => self.write_to_path(p), source::DataSourceInner::Inline(p) => runtime::get() .write(self, p) .map_err(|err| err.to_string().into()), } } /// Overwrite a snapshot pub fn write_to_path(&self, path: &std::path::Path) -> crate::assert::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| { format!("Failed to create parent dir for {}: {}", path.display(), e) })?; } let bytes = self.to_bytes()?; std::fs::write(path, bytes) .map_err(|e| format!("Failed to write {}: {}", path.display(), e).into()) } /// Return the underlying `String` /// /// Note: this will not inspect binary data for being a valid `String`. pub fn render(&self) -> Option { match &self.inner { DataInner::Error(_) => None, DataInner::Binary(_) => None, DataInner::Text(data) => Some(data.to_owned()), #[cfg(feature = "json")] DataInner::Json(_) => Some(self.to_string()), #[cfg(feature = "json")] DataInner::JsonLines(_) => Some(self.to_string()), #[cfg(feature = "term-svg")] DataInner::TermSvg(data) => Some(data.to_owned()), } } pub fn to_bytes(&self) -> crate::assert::Result> { match &self.inner { DataInner::Error(err) => Err(err.error.clone()), DataInner::Binary(data) => Ok(data.clone()), DataInner::Text(data) => Ok(data.clone().into_bytes()), #[cfg(feature = "json")] DataInner::Json(_) => Ok(self.to_string().into_bytes()), #[cfg(feature = "json")] DataInner::JsonLines(_) => Ok(self.to_string().into_bytes()), #[cfg(feature = "term-svg")] DataInner::TermSvg(data) => Ok(data.clone().into_bytes()), } } /// Initialize `Self` as [`format`][DataFormat] or [`Error`][DataFormat::Error] /// /// This is generally used for `expected` data pub fn is(self, format: DataFormat) -> Self { let filters = self.filters; let source = self.source.clone(); match self.try_is(format) { Ok(new) => new, Err(err) => { let inner = DataInner::Error(DataError { error: err, intended: format, }); Self { inner, source, filters, } } } } fn try_is(self, format: DataFormat) -> crate::assert::Result { let original = self.format(); let source = self.source; let filters = self.filters; let inner = match (self.inner, format) { (DataInner::Error(inner), _) => DataInner::Error(inner), (DataInner::Binary(inner), DataFormat::Binary) => DataInner::Binary(inner), (DataInner::Text(inner), DataFormat::Text) => DataInner::Text(inner), #[cfg(feature = "json")] (DataInner::Json(inner), DataFormat::Json) => DataInner::Json(inner), #[cfg(feature = "json")] (DataInner::JsonLines(inner), DataFormat::JsonLines) => DataInner::JsonLines(inner), #[cfg(feature = "term-svg")] (DataInner::TermSvg(inner), DataFormat::TermSvg) => DataInner::TermSvg(inner), (DataInner::Binary(inner), _) => { let inner = String::from_utf8(inner).map_err(|_err| "invalid UTF-8".to_owned())?; Self::text(inner).try_is(format)?.inner } #[cfg(feature = "json")] (DataInner::Text(inner), DataFormat::Json) => { let inner = serde_json::from_str::(&inner) .map_err(|err| err.to_string())?; DataInner::Json(inner) } #[cfg(feature = "json")] (DataInner::Text(inner), DataFormat::JsonLines) => { let inner = parse_jsonlines(&inner).map_err(|err| err.to_string())?; DataInner::JsonLines(serde_json::Value::Array(inner)) } #[cfg(feature = "term-svg")] (DataInner::Text(inner), DataFormat::TermSvg) => DataInner::TermSvg(inner), (inner, DataFormat::Binary) => { let remake = Self::with_inner(inner); DataInner::Binary(remake.to_bytes().expect("error case handled")) } // This variant is already covered unless structured data is enabled #[cfg(feature = "structured-data")] (inner, DataFormat::Text) => { if let Some(str) = Self::with_inner(inner).render() { DataInner::Text(str) } else { return Err(format!("cannot convert {original:?} to {format:?}").into()); } } (_, _) => return Err(format!("cannot convert {original:?} to {format:?}").into()), }; Ok(Self { inner, source, filters, }) } /// Override the type this snapshot will be compared against /// /// Normally, the `actual` data is coerced to [`Data::is`]. /// This allows overriding that so you can store your snapshot in a more readable, diffable /// format. /// /// # Examples /// /// ```rust /// # #[cfg(feature = "json")] { /// use snapbox::prelude::*; /// use snapbox::str; /// /// let expected = str![[r#"{"hello": "world"}"#]] /// .is(snapbox::data::DataFormat::Json) /// .against(snapbox::data::DataFormat::JsonLines); /// # } /// ``` fn against(mut self, format: DataFormat) -> Data { self.filters = self.filters.against(format); self } /// Convert `Self` to [`format`][DataFormat] if possible /// /// This is generally used on `actual` data to make it match `expected` pub fn coerce_to(self, format: DataFormat) -> Self { let source = self.source; let filters = self.filters; let inner = match (self.inner, format) { (DataInner::Error(inner), _) => DataInner::Error(inner), (inner, DataFormat::Error) => inner, (DataInner::Binary(inner), DataFormat::Binary) => DataInner::Binary(inner), (DataInner::Text(inner), DataFormat::Text) => DataInner::Text(inner), #[cfg(feature = "json")] (DataInner::Json(inner), DataFormat::Json) => DataInner::Json(inner), #[cfg(feature = "json")] (DataInner::JsonLines(inner), DataFormat::JsonLines) => DataInner::JsonLines(inner), #[cfg(feature = "json")] (DataInner::JsonLines(inner), DataFormat::Json) => DataInner::Json(inner), #[cfg(feature = "json")] (DataInner::Json(inner), DataFormat::JsonLines) => DataInner::JsonLines(inner), #[cfg(feature = "term-svg")] (DataInner::TermSvg(inner), DataFormat::TermSvg) => DataInner::TermSvg(inner), (DataInner::Binary(inner), _) => { if is_binary(&inner) { DataInner::Binary(inner) } else { match String::from_utf8(inner) { Ok(str) => { let coerced = Self::text(str).coerce_to(format); // if the Text cannot be coerced into the correct format // reset it back to Binary let coerced = if coerced.format() != format { coerced.coerce_to(DataFormat::Binary) } else { coerced }; coerced.inner } Err(err) => { let bin = err.into_bytes(); DataInner::Binary(bin) } } } } #[cfg(feature = "json")] (DataInner::Text(inner), DataFormat::Json) => { if let Ok(json) = serde_json::from_str::(&inner) { DataInner::Json(json) } else { DataInner::Text(inner) } } #[cfg(feature = "json")] (DataInner::Text(inner), DataFormat::JsonLines) => { if let Ok(jsonlines) = parse_jsonlines(&inner) { DataInner::JsonLines(serde_json::Value::Array(jsonlines)) } else { DataInner::Text(inner) } } #[cfg(feature = "term-svg")] (DataInner::Text(inner), DataFormat::TermSvg) => { DataInner::TermSvg(anstyle_svg::Term::new().render_svg(&inner)) } (inner, DataFormat::Binary) => { let remake = Self::with_inner(inner); DataInner::Binary(remake.to_bytes().expect("error case handled")) } // This variant is already covered unless structured data is enabled #[cfg(feature = "structured-data")] (inner, DataFormat::Text) => { let remake = Self::with_inner(inner); if let Some(str) = remake.render() { DataInner::Text(str) } else { remake.inner } } // reachable if more than one structured data format is enabled #[allow(unreachable_patterns)] #[cfg(feature = "json")] (inner, DataFormat::Json) => inner, // reachable if more than one structured data format is enabled #[allow(unreachable_patterns)] #[cfg(feature = "json")] (inner, DataFormat::JsonLines) => inner, // reachable if more than one structured data format is enabled #[allow(unreachable_patterns)] #[cfg(feature = "term-svg")] (inner, DataFormat::TermSvg) => inner, }; Self { inner, source, filters, } } /// Location the data came from pub fn source(&self) -> Option<&DataSource> { self.source.as_ref() } /// Outputs the current `DataFormat` of the underlying data pub fn format(&self) -> DataFormat { match &self.inner { DataInner::Error(_) => DataFormat::Error, DataInner::Binary(_) => DataFormat::Binary, DataInner::Text(_) => DataFormat::Text, #[cfg(feature = "json")] DataInner::Json(_) => DataFormat::Json, #[cfg(feature = "json")] DataInner::JsonLines(_) => DataFormat::JsonLines, #[cfg(feature = "term-svg")] DataInner::TermSvg(_) => DataFormat::TermSvg, } } pub(crate) fn intended_format(&self) -> DataFormat { match &self.inner { DataInner::Error(DataError { intended, .. }) => *intended, DataInner::Binary(_) => DataFormat::Binary, DataInner::Text(_) => DataFormat::Text, #[cfg(feature = "json")] DataInner::Json(_) => DataFormat::Json, #[cfg(feature = "json")] DataInner::JsonLines(_) => DataFormat::JsonLines, #[cfg(feature = "term-svg")] DataInner::TermSvg(_) => DataFormat::TermSvg, } } pub(crate) fn against_format(&self) -> DataFormat { self.filters .get_against() .unwrap_or_else(|| self.intended_format()) } pub(crate) fn relevant(&self) -> Option<&str> { match &self.inner { DataInner::Error(_) => None, DataInner::Binary(_) => None, DataInner::Text(_) => None, #[cfg(feature = "json")] DataInner::Json(_) => None, #[cfg(feature = "json")] DataInner::JsonLines(_) => None, #[cfg(feature = "term-svg")] DataInner::TermSvg(data) => term_svg_body(data), } } } impl std::fmt::Display for Data { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.inner { DataInner::Error(data) => data.fmt(f), DataInner::Binary(data) => String::from_utf8_lossy(data).fmt(f), DataInner::Text(data) => data.fmt(f), #[cfg(feature = "json")] DataInner::Json(data) => serde_json::to_string_pretty(data).unwrap().fmt(f), #[cfg(feature = "json")] DataInner::JsonLines(data) => { let array = data.as_array().expect("jsonlines is always an array"); for value in array { writeln!(f, "{}", serde_json::to_string(value).unwrap())?; } Ok(()) } #[cfg(feature = "term-svg")] DataInner::TermSvg(data) => data.fmt(f), } } } impl PartialEq for Data { fn eq(&self, other: &Data) -> bool { match (&self.inner, &other.inner) { (DataInner::Error(left), DataInner::Error(right)) => left == right, (DataInner::Binary(left), DataInner::Binary(right)) => left == right, (DataInner::Text(left), DataInner::Text(right)) => left == right, #[cfg(feature = "json")] (DataInner::Json(left), DataInner::Json(right)) => left == right, #[cfg(feature = "json")] (DataInner::JsonLines(left), DataInner::JsonLines(right)) => left == right, #[cfg(feature = "term-svg")] (DataInner::TermSvg(left), DataInner::TermSvg(right)) => { // HACK: avoid including `width` and `height` in the comparison let left = term_svg_body(left.as_str()).unwrap_or(left.as_str()); let right = term_svg_body(right.as_str()).unwrap_or(right.as_str()); left == right } (_, _) => false, } } } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct DataError { error: crate::assert::Error, intended: DataFormat, } impl std::fmt::Display for DataError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.error.fmt(f) } } #[cfg(feature = "json")] fn parse_jsonlines(text: &str) -> Result, serde_json::Error> { let mut lines = Vec::new(); for line in text.lines() { let line = line.trim(); if line.is_empty() { continue; } let json = serde_json::from_str::(line)?; lines.push(json); } Ok(lines) } #[cfg(feature = "term-svg")] fn term_svg_body(svg: &str) -> Option<&str> { let (_header, body, _footer) = split_term_svg(svg)?; Some(body) } #[cfg(feature = "term-svg")] pub(crate) fn split_term_svg(svg: &str) -> Option<(&str, &str, &str)> { let open_elem_start_idx = svg.find("')?; let open_elem_line_start_idx = svg[..open_elem_start_idx] .rfind('\n') .map(|idx| idx + 1) .unwrap_or(svg.len()); let close_elem = ""; let close_elem_start_idx = svg.rfind(close_elem).unwrap_or(svg.len()); let close_elem_line_end_idx = svg[close_elem_start_idx..] .find('\n') .map(|idx| idx + close_elem_start_idx + 1) .unwrap_or(svg.len()); let header = &svg[..open_elem_line_start_idx]; let body = &svg[open_elem_line_start_idx..close_elem_line_end_idx]; let footer = &svg[close_elem_line_end_idx..]; Some((header, body, footer)) } impl Eq for Data {} impl Default for Data { fn default() -> Self { Self::new() } } impl<'d> From<&'d Data> for Data { fn from(other: &'d Data) -> Self { other.into_data() } } impl From> for Data { fn from(other: Vec) -> Self { other.into_data() } } impl<'b> From<&'b [u8]> for Data { fn from(other: &'b [u8]) -> Self { other.into_data() } } impl From for Data { fn from(other: String) -> Self { other.into_data() } } impl<'s> From<&'s String> for Data { fn from(other: &'s String) -> Self { other.into_data() } } impl<'s> From<&'s str> for Data { fn from(other: &'s str) -> Self { other.into_data() } } impl From for Data { fn from(other: Inline) -> Self { other.into_data() } } #[cfg(feature = "detect-encoding")] fn is_binary(data: &[u8]) -> bool { match content_inspector::inspect(data) { content_inspector::ContentType::BINARY | // We don't support these content_inspector::ContentType::UTF_16LE | content_inspector::ContentType::UTF_16BE | content_inspector::ContentType::UTF_32LE | content_inspector::ContentType::UTF_32BE => { true }, content_inspector::ContentType::UTF_8 | content_inspector::ContentType::UTF_8_BOM => { false }, } } #[cfg(not(feature = "detect-encoding"))] fn is_binary(_data: &[u8]) -> bool { false } #[doc(hidden)] pub fn generate_snapshot_path(fn_path: &str, format: Option) -> std::path::PathBuf { use std::fmt::Write as _; let fn_path_normalized = fn_path.replace("::", "__"); let mut path = format!("tests/snapshots/{fn_path_normalized}"); let count = runtime::get().count(&path); if 0 < count { write!(&mut path, "@{count}").unwrap(); } path.push('.'); path.push_str(format.unwrap_or(DataFormat::Text).ext()); path.into() } #[cfg(test)] mod test { use super::*; #[track_caller] fn validate_cases(cases: &[(&str, bool)], input_format: DataFormat) { for (input, valid) in cases.iter().copied() { let (expected_is_format, expected_coerced_format) = if valid { (input_format, input_format) } else { (DataFormat::Error, DataFormat::Text) }; let actual_is = Data::text(input).is(input_format); assert_eq!( actual_is.format(), expected_is_format, "\n{input}\n{actual_is}" ); let actual_coerced = Data::text(input).coerce_to(input_format); assert_eq!( actual_coerced.format(), expected_coerced_format, "\n{input}\n{actual_coerced}" ); if valid { assert_eq!(actual_is, actual_coerced); let rendered = actual_is.render().unwrap(); let bytes = actual_is.to_bytes().unwrap(); assert_eq!(rendered, std::str::from_utf8(&bytes).unwrap()); assert_eq!(Data::text(&rendered).is(input_format), actual_is); } } } #[test] fn text() { let cases = [("", true), ("good", true), ("{}", true), ("\"\"", true)]; validate_cases(&cases, DataFormat::Text); } #[cfg(feature = "json")] #[test] fn json() { let cases = [("", false), ("bad", false), ("{}", true), ("\"\"", true)]; validate_cases(&cases, DataFormat::Json); } #[cfg(feature = "json")] #[test] fn jsonlines() { let cases = [ ("", true), ("bad", false), ("{}", true), ("\"\"", true), ( " {} {} ", true, ), ( " {} {} ", true, ), ( " {} bad {} ", false, ), ]; validate_cases(&cases, DataFormat::JsonLines); } } snapbox-0.6.21/src/data/runtime.rs000064400000000000000000000345011046102023000151200ustar 00000000000000use std::collections::BTreeMap; use super::Data; use super::Inline; use super::Position; pub(crate) fn get() -> std::sync::MutexGuard<'static, Runtime> { static RT: std::sync::Mutex = std::sync::Mutex::new(Runtime::new()); RT.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } #[derive(Default)] pub(crate) struct Runtime { per_file: Vec, path_count: Vec, } impl Runtime { const fn new() -> Self { Self { per_file: Vec::new(), path_count: Vec::new(), } } pub(crate) fn count(&mut self, path_prefix: &str) -> usize { if let Some(entry) = self .path_count .iter_mut() .find(|entry| entry.is(path_prefix)) { entry.next() } else { let entry = PathRuntime::new(path_prefix); let next = entry.count(); self.path_count.push(entry); next } } pub(crate) fn write(&mut self, actual: &Data, inline: &Inline) -> std::io::Result<()> { let actual = actual.render().expect("`actual` must be UTF-8"); if let Some(entry) = self .per_file .iter_mut() .find(|f| f.path == inline.position.file) { entry.update(&actual, inline)?; } else { let mut entry = SourceFileRuntime::new(inline)?; entry.update(&actual, inline)?; self.per_file.push(entry); } Ok(()) } } struct SourceFileRuntime { path: std::path::PathBuf, original_text: String, patchwork: Patchwork, } impl SourceFileRuntime { fn new(inline: &Inline) -> std::io::Result { let path = inline.position.file.clone(); let original_text = std::fs::read_to_string(&path)?; let patchwork = Patchwork::new(original_text.clone()); Ok(SourceFileRuntime { path, original_text, patchwork, }) } fn update(&mut self, actual: &str, inline: &Inline) -> std::io::Result<()> { let span = Span::from_pos(&inline.position, &self.original_text); let patch = format_patch(actual); self.patchwork.patch(span.literal_range, &patch)?; std::fs::write(&inline.position.file, &self.patchwork.text) } } #[derive(Debug)] struct Patchwork { text: String, indels: BTreeMap, } impl Patchwork { fn new(text: String) -> Patchwork { Patchwork { text, indels: BTreeMap::new(), } } fn patch(&mut self, mut range: std::ops::Range, patch: &str) -> std::io::Result<()> { let key: OrdRange = range.clone().into(); match self.indels.entry(key) { std::collections::btree_map::Entry::Vacant(entry) => { entry.insert((patch.len(), patch.to_owned())); } std::collections::btree_map::Entry::Occupied(entry) => { if entry.get().1 == patch { return Ok(()); } else { return Err(std::io::Error::new( std::io::ErrorKind::Other, "cannot update as it was already modified", )); } } } let (delete, insert) = self .indels .iter() .take_while(|(delete, _)| delete.start < range.start) .map(|(delete, (insert, _))| (delete.end - delete.start, insert)) .fold((0usize, 0usize), |(x1, y1), (x2, y2)| (x1 + x2, y1 + y2)); for pos in &mut [&mut range.start, &mut range.end] { **pos -= delete; **pos += insert; } self.text.replace_range(range, patch); Ok(()) } } #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] struct OrdRange { start: usize, end: usize, } impl From> for OrdRange { fn from(other: std::ops::Range) -> Self { Self { start: other.start, end: other.end, } } } fn lit_kind_for_patch(patch: &str) -> StrLitKind { let has_dquote = patch.chars().any(|c| c == '"'); if !has_dquote { let has_bslash_or_newline = patch.chars().any(|c| matches!(c, '\\' | '\n')); return if has_bslash_or_newline { StrLitKind::Raw(1) } else { StrLitKind::Normal }; } // Find the maximum number of hashes that follow a double quote in the string. // We need to use one more than that to delimit the string. let leading_hashes = |s: &str| s.chars().take_while(|&c| c == '#').count(); let max_hashes = patch.split('"').map(leading_hashes).max().unwrap(); StrLitKind::Raw(max_hashes + 1) } fn format_patch(patch: &str) -> String { let lit_kind = lit_kind_for_patch(patch); let is_multiline = patch.contains('\n'); let mut buf = String::new(); if matches!(lit_kind, StrLitKind::Raw(_)) { buf.push('['); } lit_kind.write_start(&mut buf).unwrap(); if is_multiline { buf.push('\n'); } buf.push_str(patch); if is_multiline { buf.push('\n'); } lit_kind.write_end(&mut buf).unwrap(); if matches!(lit_kind, StrLitKind::Raw(_)) { buf.push(']'); } buf } #[derive(Clone, Debug)] struct Span { /// The byte range of the argument to `expect!`, including the inner `[]` if it exists. literal_range: std::ops::Range, } impl Span { fn from_pos(pos: &Position, file: &str) -> Span { let mut target_line = None; let mut line_start = 0; for (i, line) in crate::utils::LinesWithTerminator::new(file).enumerate() { if i == pos.line as usize - 1 { // `column` points to the first character of the macro invocation: // // expect![[r#""#]] expect![""] // ^ ^ ^ ^ // column offset offset // // Seek past the exclam, then skip any whitespace and // the macro delimiter to get to our argument. #[allow(clippy::skip_while_next)] let byte_offset = line .char_indices() .skip((pos.column - 1).try_into().unwrap()) .skip_while(|&(_, c)| c != '!') .skip(1) // ! .skip_while(|&(_, c)| c.is_whitespace()) .skip(1) // [({ .skip_while(|&(_, c)| c.is_whitespace()) .next() .expect("Failed to parse macro invocation") .0; let literal_start = line_start + byte_offset; target_line = Some(literal_start); break; } line_start += line.len(); } let literal_start = target_line.unwrap(); let lit_to_eof = &file[literal_start..]; let lit_to_eof_trimmed = lit_to_eof.trim_start(); let literal_start = literal_start + (lit_to_eof.len() - lit_to_eof_trimmed.len()); let literal_len = locate_end(lit_to_eof_trimmed).expect("Couldn't find closing delimiter for `expect!`."); let literal_range = literal_start..literal_start + literal_len; Span { literal_range } } } fn locate_end(arg_start_to_eof: &str) -> Option { match arg_start_to_eof.chars().next()? { c if c.is_whitespace() => panic!("skip whitespace before calling `locate_end`"), // expect![[]] '[' => { let str_start_to_eof = arg_start_to_eof[1..].trim_start(); let str_len = find_str_lit_len(str_start_to_eof)?; let str_end_to_eof = &str_start_to_eof[str_len..]; let closing_brace_offset = str_end_to_eof.find(']')?; Some((arg_start_to_eof.len() - str_end_to_eof.len()) + closing_brace_offset + 1) } // expect![] | expect!{} | expect!() ']' | '}' | ')' => Some(0), // expect!["..."] | expect![r#"..."#] _ => find_str_lit_len(arg_start_to_eof), } } /// Parses a string literal, returning the byte index of its last character /// (either a quote or a hash). fn find_str_lit_len(str_lit_to_eof: &str) -> Option { fn try_find_n_hashes( s: &mut impl Iterator, desired_hashes: usize, ) -> Option<(usize, Option)> { let mut n = 0; loop { match s.next()? { '#' => n += 1, c => return Some((n, Some(c))), } if n == desired_hashes { return Some((n, None)); } } } let mut s = str_lit_to_eof.chars(); let kind = match s.next()? { '"' => StrLitKind::Normal, 'r' => { let (n, c) = try_find_n_hashes(&mut s, usize::MAX)?; if c != Some('"') { return None; } StrLitKind::Raw(n) } _ => return None, }; let mut oldc = None; loop { let c = oldc.take().or_else(|| s.next())?; match (c, kind) { ('\\', StrLitKind::Normal) => { let _escaped = s.next()?; } ('"', StrLitKind::Normal) => break, ('"', StrLitKind::Raw(0)) => break, ('"', StrLitKind::Raw(n)) => { let (seen, c) = try_find_n_hashes(&mut s, n)?; if seen == n { break; } oldc = c; } _ => {} } } Some(str_lit_to_eof.len() - s.as_str().len()) } #[derive(Copy, Clone)] enum StrLitKind { Normal, Raw(usize), } impl StrLitKind { fn write_start(self, w: &mut impl std::fmt::Write) -> std::fmt::Result { match self { Self::Normal => write!(w, "\""), Self::Raw(n) => { write!(w, "r")?; for _ in 0..n { write!(w, "#")?; } write!(w, "\"") } } } fn write_end(self, w: &mut impl std::fmt::Write) -> std::fmt::Result { match self { Self::Normal => write!(w, "\""), Self::Raw(n) => { write!(w, "\"")?; for _ in 0..n { write!(w, "#")?; } Ok(()) } } } } #[derive(Clone)] struct PathRuntime { path_prefix: String, count: usize, } impl PathRuntime { fn new(path_prefix: &str) -> Self { Self { path_prefix: path_prefix.to_owned(), count: 0, } } fn is(&self, path_prefix: &str) -> bool { self.path_prefix == path_prefix } fn next(&mut self) -> usize { self.count += 1; self.count } fn count(&self) -> usize { self.count } } #[cfg(test)] mod tests { use super::*; use crate::assert_data_eq; use crate::prelude::*; use crate::str; #[test] fn test_format_patch() { let patch = format_patch("hello\nworld\n"); assert_data_eq!( patch, str![[r##" [r#" hello world "#] "##]], ); let patch = format_patch(r"hello\tworld"); assert_data_eq!(patch, str![[r##"[r#"hello\tworld"#]"##]].raw()); let patch = format_patch("{\"foo\": 42}"); assert_data_eq!(patch, str![[r##"[r#"{"foo": 42}"#]"##]]); } #[test] fn test_patchwork() { let mut patchwork = Patchwork::new("one two three".to_owned()); patchwork.patch(4..7, "zwei").unwrap(); patchwork.patch(0..3, "один").unwrap(); patchwork.patch(8..13, "3").unwrap(); assert_data_eq!( patchwork.to_debug(), str![[r#" Patchwork { text: "один zwei 3", indels: { OrdRange { start: 0, end: 3, }: ( 8, "один", ), OrdRange { start: 4, end: 7, }: ( 4, "zwei", ), OrdRange { start: 8, end: 13, }: ( 1, "3", ), }, } "#]], ); } #[test] fn test_patchwork_overlap_diverge() { let mut patchwork = Patchwork::new("one two three".to_owned()); patchwork.patch(4..7, "zwei").unwrap(); patchwork.patch(4..7, "abcd").unwrap_err(); assert_data_eq!( patchwork.to_debug(), str![[r#" Patchwork { text: "one zwei three", indels: { OrdRange { start: 4, end: 7, }: ( 4, "zwei", ), }, } "#]], ); } #[test] fn test_patchwork_overlap_converge() { let mut patchwork = Patchwork::new("one two three".to_owned()); patchwork.patch(4..7, "zwei").unwrap(); patchwork.patch(4..7, "zwei").unwrap(); assert_data_eq!( patchwork.to_debug(), str![[r#" Patchwork { text: "one zwei three", indels: { OrdRange { start: 4, end: 7, }: ( 4, "zwei", ), }, } "#]], ); } #[test] fn test_locate() { macro_rules! check_locate { ($( [[$s:literal]] ),* $(,)?) => {$({ let lit = stringify!($s); let with_trailer = format!("{} \t]]\n", lit); assert_eq!(locate_end(&with_trailer), Some(lit.len())); })*}; } // Check that we handle string literals containing "]]" correctly. check_locate!( [[r#"{ arr: [[1, 2], [3, 4]], other: "foo" } "#]], [["]]"]], [["\"]]"]], [[r#""]]"#]], ); // Check `str![[ ]]` as well. assert_eq!(locate_end("]]"), Some(0)); } #[test] fn test_find_str_lit_len() { macro_rules! check_str_lit_len { ($( $s:literal ),* $(,)?) => {$({ let lit = stringify!($s); assert_eq!(find_str_lit_len(lit), Some(lit.len())); })*} } check_str_lit_len![ r##"foa\""#"##, r##" asdf][]]""""# "##, "", "\"", "\"\"", "#\"#\"#", ]; } } snapbox-0.6.21/src/data/source.rs000064400000000000000000000054721046102023000147420ustar 00000000000000/// Origin of a snapshot so it can be updated #[derive(Clone, Debug, PartialEq, Eq)] pub struct DataSource { pub(crate) inner: DataSourceInner, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum DataSourceInner { Path(std::path::PathBuf), Inline(Inline), } impl DataSource { pub fn path(path: impl Into) -> Self { Self { inner: DataSourceInner::Path(path.into()), } } pub fn is_path(&self) -> bool { self.as_path().is_some() } pub fn as_path(&self) -> Option<&std::path::Path> { match &self.inner { DataSourceInner::Path(value) => Some(value.as_ref()), _ => None, } } pub fn is_inline(&self) -> bool { self.as_inline().is_some() } pub fn as_inline(&self) -> Option<&Inline> { match &self.inner { DataSourceInner::Inline(value) => Some(value), _ => None, } } } impl From<&'_ std::path::Path> for DataSource { fn from(value: &'_ std::path::Path) -> Self { Self::path(value) } } impl From for DataSource { fn from(value: std::path::PathBuf) -> Self { Self::path(value) } } impl From for DataSource { fn from(inline: Inline) -> Self { Self { inner: DataSourceInner::Inline(inline), } } } impl std::fmt::Display for DataSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.inner { DataSourceInner::Path(value) => crate::dir::display_relpath(value).fmt(f), DataSourceInner::Inline(value) => value.fmt(f), } } } /// Output of [`str!`][crate::str!] #[derive(Clone, Debug, PartialEq, Eq)] pub struct Inline { #[doc(hidden)] pub position: Position, #[doc(hidden)] pub data: &'static str, } impl Inline { pub(crate) fn trimmed(&self) -> String { let mut data = self.data; if data.contains('\n') { data = data.strip_prefix('\n').unwrap_or(data); data = data.strip_suffix('\n').unwrap_or(data); } data.to_owned() } } impl std::fmt::Display for Inline { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.position.fmt(f) } } /// Position within Rust source code, see [`Inline`] #[doc(hidden)] #[derive(Clone, Debug, PartialEq, Eq)] pub struct Position { #[doc(hidden)] pub file: std::path::PathBuf, #[doc(hidden)] pub line: u32, #[doc(hidden)] pub column: u32, } impl std::fmt::Display for Position { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}:{}:{}", crate::dir::display_relpath(&self.file), self.line, self.column ) } } snapbox-0.6.21/src/data/tests.rs000064400000000000000000000146021046102023000145770ustar 00000000000000#[cfg(feature = "json")] use serde_json::json; use super::*; #[test] #[cfg(feature = "term-svg")] fn term_svg_eq() { let left = Data::with_inner(DataInner::TermSvg( " irrelevant relevant irrelevant" .to_owned(), )); let right = Data::with_inner(DataInner::TermSvg( " irrelevant relevant irrelevant" .to_owned(), )); assert_eq!(left, right); let left = Data::with_inner(DataInner::TermSvg( " irrelevant 1 relevant irrelevant 1" .to_owned(), )); let right = Data::with_inner(DataInner::TermSvg( " irrelevant 2 relevant irrelevant 2" .to_owned(), )); assert_eq!(left, right); } #[test] #[cfg(feature = "term-svg")] fn term_svg_ne() { let left = Data::with_inner(DataInner::TermSvg( " irrelevant 1 relevant 1 irrelevant 1" .to_owned(), )); let right = Data::with_inner(DataInner::TermSvg( " irrelevant 2 relevant 2 irrelevant 2" .to_owned(), )); assert_ne!(left, right); } // Tests for checking to_bytes and render produce the same results #[test] fn text_to_bytes_render() { let d = Data::text(String::from("test")); let bytes = d.to_bytes().unwrap(); let bytes = String::from_utf8(bytes).unwrap(); let rendered = d.render().unwrap(); assert_eq!(bytes, rendered); } #[test] #[cfg(feature = "json")] fn json_to_bytes_render() { let d = Data::json(json!({"name": "John\\Doe\r\n"})); let bytes = d.to_bytes().unwrap(); let bytes = String::from_utf8(bytes).unwrap(); let rendered = d.render().unwrap(); assert_eq!(bytes, rendered); } // Tests for checking all types are coercible to each other and // for when the coercion should fail #[test] fn binary_to_text() { let binary = String::from("test").into_bytes(); let d = Data::binary(binary); let text = d.coerce_to(DataFormat::Text); assert_eq!(DataFormat::Text, text.format()); } #[test] fn binary_to_text_not_utf8() { let binary = b"\xFF\xE0\x00\x10\x4A\x46\x49\x46\x00".to_vec(); let d = Data::binary(binary); let d = d.coerce_to(DataFormat::Text); assert_ne!(DataFormat::Text, d.format()); assert_eq!(DataFormat::Binary, d.format()); } #[test] #[cfg(feature = "json")] fn binary_to_json() { let value = json!({"name": "John\\Doe\r\n"}); let binary = serde_json::to_vec_pretty(&value).unwrap(); let d = Data::binary(binary); let json = d.coerce_to(DataFormat::Json); assert_eq!(DataFormat::Json, json.format()); } #[test] #[cfg(feature = "json")] fn binary_to_json_not_utf8() { let binary = b"\xFF\xE0\x00\x10\x4A\x46\x49\x46\x00".to_vec(); let d = Data::binary(binary); let d = d.coerce_to(DataFormat::Json); assert_ne!(DataFormat::Json, d.format()); assert_eq!(DataFormat::Binary, d.format()); } #[test] #[cfg(feature = "json")] fn binary_to_json_not_json() { let binary = String::from("test").into_bytes(); let d = Data::binary(binary); let d = d.coerce_to(DataFormat::Json); assert_ne!(DataFormat::Json, d.format()); assert_eq!(DataFormat::Binary, d.format()); } #[test] fn text_to_binary() { let text = String::from("test"); let d = Data::text(text); let binary = d.coerce_to(DataFormat::Binary); assert_eq!(DataFormat::Binary, binary.format()); } #[test] #[cfg(feature = "json")] fn text_to_json() { let value = json!({"name": "John\\Doe\r\n"}); let text = serde_json::to_string_pretty(&value).unwrap(); let d = Data::text(text); let json = d.coerce_to(DataFormat::Json); assert_eq!(DataFormat::Json, json.format()); } #[test] #[cfg(feature = "json")] fn text_to_json_not_json() { let text = String::from("test"); let d = Data::text(text); let json = d.coerce_to(DataFormat::Json); assert_eq!(DataFormat::Text, json.format()); } #[test] #[cfg(feature = "json")] fn json_to_binary() { let value = json!({"name": "John\\Doe\r\n"}); let d = Data::json(value); let binary = d.coerce_to(DataFormat::Binary); assert_eq!(DataFormat::Binary, binary.format()); } #[test] #[cfg(feature = "json")] fn json_to_text() { let value = json!({"name": "John\\Doe\r\n"}); let d = Data::json(value); let text = d.coerce_to(DataFormat::Text); assert_eq!(DataFormat::Text, text.format()); } // Tests for coercible conversions create the same output as to_bytes/render // // render does not need to be checked against bin -> text since render // outputs None for binary #[test] fn text_to_bin_coerce_equals_to_bytes() { let text = String::from("test"); let d = Data::text(text); let binary = d.clone().coerce_to(DataFormat::Binary); assert_eq!(Data::binary(d.to_bytes().unwrap()), binary); } #[test] #[cfg(feature = "json")] fn json_to_bin_coerce_equals_to_bytes() { let json = json!({"name": "John\\Doe\r\n"}); let d = Data::json(json); let binary = d.clone().coerce_to(DataFormat::Binary); assert_eq!(Data::binary(d.to_bytes().unwrap()), binary); } #[test] #[cfg(feature = "json")] fn json_to_text_coerce_equals_render() { let json = json!({"name": "John\\Doe\r\n"}); let d = Data::json(json); let text = d.clone().coerce_to(DataFormat::Text); assert_eq!(Data::text(d.render().unwrap()), text); } #[cfg(feature = "term-svg")] mod term_svg_body { use super::super::*; #[test] fn empty() { let input = ""; let expected = None; let actual = term_svg_body(input); assert_eq!(expected, actual); } #[test] fn no_open_tag() { let input = "hello world!"; let expected = None; let actual = term_svg_body(input); assert_eq!(expected, actual); } #[test] fn unclosed_open_text() { let input = " Hello world ", ); let actual = term_svg_body(input); assert_eq!(expected, actual); } #[test] fn no_end_tag() { let input = " Hello world"; let expected = Some( " world", ); let actual = term_svg_body(input); assert_eq!(expected, actual); } } snapbox-0.6.21/src/dir/diff.rs000064400000000000000000000307771046102023000142250ustar 00000000000000#[cfg(feature = "dir")] use crate::filter::{Filter as _, FilterNewlines, FilterPaths, NormalizeToExpected}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum PathDiff { Failure(crate::assert::Error), TypeMismatch { expected_path: std::path::PathBuf, actual_path: std::path::PathBuf, expected_type: FileType, actual_type: FileType, }, LinkMismatch { expected_path: std::path::PathBuf, actual_path: std::path::PathBuf, expected_target: std::path::PathBuf, actual_target: std::path::PathBuf, }, ContentMismatch { expected_path: std::path::PathBuf, actual_path: std::path::PathBuf, expected_content: crate::Data, actual_content: crate::Data, }, } impl PathDiff { /// Report differences between `actual_root` and `pattern_root` /// /// Note: Requires feature flag `path` #[cfg(feature = "dir")] pub fn subset_eq_iter( pattern_root: impl Into, actual_root: impl Into, ) -> impl Iterator> { let pattern_root = pattern_root.into(); let actual_root = actual_root.into(); Self::subset_eq_iter_inner(pattern_root, actual_root) } #[cfg(feature = "dir")] pub(crate) fn subset_eq_iter_inner( expected_root: std::path::PathBuf, actual_root: std::path::PathBuf, ) -> impl Iterator> { let walker = crate::dir::Walk::new(&expected_root); walker.map(move |r| { let expected_path = r.map_err(|e| Self::Failure(e.to_string().into()))?; let rel = expected_path.strip_prefix(&expected_root).unwrap(); let actual_path = actual_root.join(rel); let expected_type = FileType::from_path(&expected_path); let actual_type = FileType::from_path(&actual_path); if expected_type != actual_type { return Err(Self::TypeMismatch { expected_path, actual_path, expected_type, actual_type, }); } match expected_type { FileType::Symlink => { let expected_target = std::fs::read_link(&expected_path).ok(); let actual_target = std::fs::read_link(&actual_path).ok(); if expected_target != actual_target { return Err(Self::LinkMismatch { expected_path, actual_path, expected_target: expected_target.unwrap(), actual_target: actual_target.unwrap(), }); } } FileType::File => { let mut actual = crate::Data::try_read_from(&actual_path, None).map_err(Self::Failure)?; let expected = FilterNewlines.filter(crate::Data::read_from(&expected_path, None)); actual = FilterNewlines.filter(actual.coerce_to(expected.intended_format())); if expected != actual { return Err(Self::ContentMismatch { expected_path, actual_path, expected_content: expected, actual_content: actual, }); } } FileType::Dir | FileType::Unknown | FileType::Missing => {} } Ok((expected_path, actual_path)) }) } /// Report differences between `actual_root` and `pattern_root` /// /// Note: Requires feature flag `path` #[cfg(feature = "dir")] pub fn subset_matches_iter( pattern_root: impl Into, actual_root: impl Into, substitutions: &crate::Redactions, ) -> impl Iterator> + '_ { let pattern_root = pattern_root.into(); let actual_root = actual_root.into(); Self::subset_matches_iter_inner(pattern_root, actual_root, substitutions, true) } #[cfg(feature = "dir")] pub(crate) fn subset_matches_iter_inner( expected_root: std::path::PathBuf, actual_root: std::path::PathBuf, substitutions: &crate::Redactions, normalize_paths: bool, ) -> impl Iterator> + '_ { let walker = crate::dir::Walk::new(&expected_root); walker.map(move |r| { let expected_path = r.map_err(|e| Self::Failure(e.to_string().into()))?; let rel = expected_path.strip_prefix(&expected_root).unwrap(); let actual_path = actual_root.join(rel); let expected_type = FileType::from_path(&expected_path); let actual_type = FileType::from_path(&actual_path); if expected_type != actual_type { return Err(Self::TypeMismatch { expected_path, actual_path, expected_type, actual_type, }); } match expected_type { FileType::Symlink => { let expected_target = std::fs::read_link(&expected_path).ok(); let actual_target = std::fs::read_link(&actual_path).ok(); if expected_target != actual_target { return Err(Self::LinkMismatch { expected_path, actual_path, expected_target: expected_target.unwrap(), actual_target: actual_target.unwrap(), }); } } FileType::File => { let mut actual = crate::Data::try_read_from(&actual_path, None).map_err(Self::Failure)?; let expected = FilterNewlines.filter(crate::Data::read_from(&expected_path, None)); actual = actual.coerce_to(expected.intended_format()); if normalize_paths { actual = FilterPaths.filter(actual); } actual = NormalizeToExpected::new() .redact_with(substitutions) .normalize(FilterNewlines.filter(actual), &expected); if expected != actual { return Err(Self::ContentMismatch { expected_path, actual_path, expected_content: expected, actual_content: actual, }); } } FileType::Dir | FileType::Unknown | FileType::Missing => {} } Ok((expected_path, actual_path)) }) } } impl PathDiff { pub fn expected_path(&self) -> Option<&std::path::Path> { match &self { Self::Failure(_msg) => None, Self::TypeMismatch { expected_path, actual_path: _, expected_type: _, actual_type: _, } => Some(expected_path), Self::LinkMismatch { expected_path, actual_path: _, expected_target: _, actual_target: _, } => Some(expected_path), Self::ContentMismatch { expected_path, actual_path: _, expected_content: _, actual_content: _, } => Some(expected_path), } } pub fn write( &self, f: &mut dyn std::fmt::Write, palette: crate::report::Palette, ) -> Result<(), std::fmt::Error> { match &self { Self::Failure(msg) => { writeln!(f, "{}", palette.error(msg))?; } Self::TypeMismatch { expected_path, actual_path: _actual_path, expected_type, actual_type, } => { writeln!( f, "{}: Expected {}, was {}", expected_path.display(), palette.info(expected_type), palette.error(actual_type) )?; } Self::LinkMismatch { expected_path, actual_path: _actual_path, expected_target, actual_target, } => { writeln!( f, "{}: Expected {}, was {}", expected_path.display(), palette.info(expected_target.display()), palette.error(actual_target.display()) )?; } Self::ContentMismatch { expected_path, actual_path, expected_content, actual_content, } => { crate::report::write_diff( f, expected_content, actual_content, Some(&expected_path.display()), Some(&actual_path.display()), palette, )?; } } Ok(()) } pub fn overwrite(&self) -> Result<(), crate::assert::Error> { match self { // Not passing the error up because users most likely want to treat a processing error // differently than an overwrite error Self::Failure(_err) => Ok(()), Self::TypeMismatch { expected_path, actual_path, expected_type: _, actual_type, } => { match actual_type { FileType::Dir => { std::fs::remove_dir_all(expected_path).map_err(|e| { format!("Failed to remove {}: {}", expected_path.display(), e) })?; } FileType::File | FileType::Symlink => { std::fs::remove_file(expected_path).map_err(|e| { format!("Failed to remove {}: {}", expected_path.display(), e) })?; } FileType::Unknown | FileType::Missing => {} } super::shallow_copy(expected_path, actual_path) } Self::LinkMismatch { expected_path, actual_path, expected_target: _, actual_target: _, } => super::shallow_copy(expected_path, actual_path), Self::ContentMismatch { expected_path: _, actual_path: _, expected_content, actual_content, } => actual_content.write_to(expected_content.source().unwrap()), } } } #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum FileType { Dir, File, Symlink, Unknown, Missing, } impl FileType { pub fn from_path(path: &std::path::Path) -> Self { let meta = path.symlink_metadata(); match meta { Ok(meta) => { if meta.is_dir() { Self::Dir } else if meta.is_file() { Self::File } else { let target = std::fs::read_link(path).ok(); if target.is_some() { Self::Symlink } else { Self::Unknown } } } Err(err) => match err.kind() { std::io::ErrorKind::NotFound => Self::Missing, _ => Self::Unknown, }, } } } impl FileType { fn as_str(self) -> &'static str { match self { Self::Dir => "dir", Self::File => "file", Self::Symlink => "symlink", Self::Unknown => "unknown", Self::Missing => "missing", } } } impl std::fmt::Display for FileType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.as_str().fmt(f) } } snapbox-0.6.21/src/dir/fixture.rs000064400000000000000000000074151046102023000147740ustar 00000000000000/// Collection of files pub trait DirFixture: std::fmt::Debug { /// Initialize a test fixture directory `root` fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error>; } #[cfg(feature = "dir")] // for documentation purposes only impl DirFixture for std::path::Path { fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { super::copy_template(self, root) } } #[cfg(feature = "dir")] // for documentation purposes only impl DirFixture for &'_ std::path::Path { fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { std::path::Path::new(self).write_to_path(root) } } #[cfg(feature = "dir")] // for documentation purposes only impl DirFixture for &'_ std::path::PathBuf { fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { std::path::Path::new(self).write_to_path(root) } } #[cfg(feature = "dir")] // for documentation purposes only impl DirFixture for std::path::PathBuf { fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { std::path::Path::new(self).write_to_path(root) } } #[cfg(feature = "dir")] // for documentation purposes only impl DirFixture for str { fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { std::path::Path::new(self).write_to_path(root) } } #[cfg(feature = "dir")] // for documentation purposes only impl DirFixture for &'_ str { fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { std::path::Path::new(self).write_to_path(root) } } #[cfg(feature = "dir")] // for documentation purposes only impl DirFixture for &'_ String { fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { std::path::Path::new(self).write_to_path(root) } } #[cfg(feature = "dir")] // for documentation purposes only impl DirFixture for String { fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { std::path::Path::new(self).write_to_path(root) } } impl DirFixture for &[(P, S)] where P: AsRef, P: std::fmt::Debug, S: AsRef<[u8]>, S: std::fmt::Debug, { fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { let root = super::ops::canonicalize(root) .map_err(|e| format!("Failed to canonicalize {}: {}", root.display(), e))?; for (path, content) in self.iter() { let rel_path = path.as_ref(); let path = root.join(rel_path); let path = super::ops::normalize_path(&path); if !path.starts_with(&root) { return Err(crate::assert::Error::new(format!( "Fixture {} is for outside of the target root", rel_path.display(), ))); } let content = content.as_ref(); if let Some(dir) = path.parent() { std::fs::create_dir_all(dir).map_err(|e| { format!( "Failed to create fixture directory {}: {}", dir.display(), e ) })?; } std::fs::write(&path, content) .map_err(|e| format!("Failed to write fixture {}: {}", path.display(), e))?; } Ok(()) } } impl DirFixture for [(P, S); N] where P: AsRef, P: std::fmt::Debug, S: AsRef<[u8]>, S: std::fmt::Debug, { fn write_to_path(&self, root: &std::path::Path) -> Result<(), crate::assert::Error> { let s: &[(P, S)] = self; s.write_to_path(root) } } snapbox-0.6.21/src/dir/mod.rs000064400000000000000000000010131046102023000140510ustar 00000000000000//! Initialize working directories and assert on how they've changed mod diff; mod fixture; mod ops; mod root; #[cfg(test)] mod tests; pub use diff::FileType; pub use diff::PathDiff; pub use fixture::DirFixture; #[cfg(feature = "dir")] pub use ops::copy_template; pub use ops::resolve_dir; pub use ops::strip_trailing_slash; #[cfg(feature = "dir")] pub use ops::Walk; pub use root::DirRoot; #[cfg(feature = "dir")] pub(crate) use ops::canonicalize; pub(crate) use ops::display_relpath; pub(crate) use ops::shallow_copy; snapbox-0.6.21/src/dir/ops.rs000064400000000000000000000156311046102023000141060ustar 00000000000000/// Recursively walk a path /// /// Note: Ignores `.keep` files #[cfg(feature = "dir")] pub struct Walk { inner: walkdir::IntoIter, } #[cfg(feature = "dir")] impl Walk { pub fn new(path: &std::path::Path) -> Self { Self { inner: walkdir::WalkDir::new(path).into_iter(), } } } #[cfg(feature = "dir")] impl Iterator for Walk { type Item = Result; fn next(&mut self) -> Option { while let Some(entry) = self.inner.next().map(|e| { e.map(walkdir::DirEntry::into_path) .map_err(std::io::Error::from) }) { if entry.as_ref().ok().and_then(|e| e.file_name()) != Some(std::ffi::OsStr::new(".keep")) { return Some(entry); } } None } } /// Copy a template into a [`DirRoot`][super::DirRoot] /// /// Note: Generally you'll use [`DirRoot::with_template`][super::DirRoot::with_template] instead. /// /// Note: Ignores `.keep` files #[cfg(feature = "dir")] pub fn copy_template( source: impl AsRef, dest: impl AsRef, ) -> Result<(), crate::assert::Error> { let source = source.as_ref(); let dest = dest.as_ref(); let source = canonicalize(source) .map_err(|e| format!("Failed to canonicalize {}: {}", source.display(), e))?; std::fs::create_dir_all(dest) .map_err(|e| format!("Failed to create {}: {}", dest.display(), e))?; let dest = canonicalize(dest) .map_err(|e| format!("Failed to canonicalize {}: {}", dest.display(), e))?; for current in Walk::new(&source) { let current = current.map_err(|e| e.to_string())?; let rel = current.strip_prefix(&source).unwrap(); let target = dest.join(rel); shallow_copy(¤t, &target)?; } Ok(()) } /// Copy a file system entry, without recursing pub(crate) fn shallow_copy( source: &std::path::Path, dest: &std::path::Path, ) -> Result<(), crate::assert::Error> { let meta = source .symlink_metadata() .map_err(|e| format!("Failed to read metadata from {}: {}", source.display(), e))?; if meta.is_dir() { std::fs::create_dir_all(dest) .map_err(|e| format!("Failed to create {}: {}", dest.display(), e))?; } else if meta.is_file() { std::fs::copy(source, dest).map_err(|e| { format!( "Failed to copy {} to {}: {}", source.display(), dest.display(), e ) })?; // Avoid a mtime check race where: // - Copy files // - Test checks mtime // - Test writes // - Test checks mtime // // If all of this happens too close to each other, then the second mtime check will think // nothing was written by the test. // // Instead of just setting 1s in the past, we'll just respect the existing mtime. copy_stats(&meta, dest).map_err(|e| { format!( "Failed to copy {} metadata to {}: {}", source.display(), dest.display(), e ) })?; } else if let Ok(target) = std::fs::read_link(source) { symlink_to_file(dest, &target) .map_err(|e| format!("Failed to create symlink {}: {}", dest.display(), e))?; } Ok(()) } #[cfg(feature = "dir")] fn copy_stats( source_meta: &std::fs::Metadata, dest: &std::path::Path, ) -> Result<(), std::io::Error> { let src_mtime = filetime::FileTime::from_last_modification_time(source_meta); filetime::set_file_mtime(dest, src_mtime)?; Ok(()) } #[cfg(not(feature = "dir"))] fn copy_stats( _source_meta: &std::fs::Metadata, _dest: &std::path::Path, ) -> Result<(), std::io::Error> { Ok(()) } #[cfg(windows)] fn symlink_to_file(link: &std::path::Path, target: &std::path::Path) -> Result<(), std::io::Error> { std::os::windows::fs::symlink_file(target, link) } #[cfg(not(windows))] fn symlink_to_file(link: &std::path::Path, target: &std::path::Path) -> Result<(), std::io::Error> { std::os::unix::fs::symlink(target, link) } pub fn resolve_dir( path: impl AsRef, ) -> Result { let path = path.as_ref(); let meta = std::fs::symlink_metadata(path)?; if meta.is_dir() { canonicalize(path) } else if meta.is_file() { // Git might checkout symlinks as files let target = std::fs::read_to_string(path)?; let target_path = path.parent().unwrap().join(target); resolve_dir(target_path) } else { canonicalize(path) } } pub(crate) fn canonicalize(path: &std::path::Path) -> Result { #[cfg(feature = "dir")] { dunce::canonicalize(path) } #[cfg(not(feature = "dir"))] { // Hope for the best Ok(strip_trailing_slash(path).to_owned()) } } pub fn strip_trailing_slash(path: &std::path::Path) -> &std::path::Path { path.components().as_path() } /// Normalize a path, removing things like `.` and `..`. /// /// CAUTION: This does not resolve symlinks (unlike /// [`std::fs::canonicalize`]). This may cause incorrect or surprising /// behavior at times. This should be used carefully. Unfortunately, /// [`std::fs::canonicalize`] can be hard to use correctly, since it can often /// fail, or on Windows returns annoying device paths. This is a problem Cargo /// needs to improve on. pub(crate) fn normalize_path(path: &std::path::Path) -> std::path::PathBuf { use std::path::Component; let mut components = path.components().peekable(); let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() { components.next(); std::path::PathBuf::from(c.as_os_str()) } else { std::path::PathBuf::new() }; for component in components { match component { Component::Prefix(..) => unreachable!(), Component::RootDir => { ret.push(Component::RootDir); } Component::CurDir => {} Component::ParentDir => { if ret.ends_with(Component::ParentDir) { ret.push(Component::ParentDir); } else { let popped = ret.pop(); if !popped && !ret.has_root() { ret.push(Component::ParentDir); } } } Component::Normal(c) => { ret.push(c); } } } ret } pub(crate) fn display_relpath(path: impl AsRef) -> String { let path = path.as_ref(); let relpath = if let Ok(cwd) = std::env::current_dir() { match path.strip_prefix(cwd) { Ok(path) => path, Err(_) => path, } } else { path }; relpath.display().to_string() } snapbox-0.6.21/src/dir/root.rs000064400000000000000000000062531046102023000142700ustar 00000000000000/// Working directory for tests #[derive(Debug)] pub struct DirRoot(DirRootInner); #[derive(Debug)] enum DirRootInner { None, Immutable(std::path::PathBuf), #[cfg(feature = "dir")] MutablePath(std::path::PathBuf), #[cfg(feature = "dir")] MutableTemp { temp: tempfile::TempDir, path: std::path::PathBuf, }, } impl DirRoot { pub fn none() -> Self { Self(DirRootInner::None) } pub fn immutable(target: &std::path::Path) -> Self { Self(DirRootInner::Immutable(target.to_owned())) } #[cfg(feature = "dir")] pub fn mutable_temp() -> Result { let temp = tempfile::tempdir().map_err(|e| e.to_string())?; // We need to get the `/private` prefix on Mac so variable substitutions work // correctly let path = crate::dir::canonicalize(temp.path()) .map_err(|e| format!("Failed to canonicalize {}: {}", temp.path().display(), e))?; Ok(Self(DirRootInner::MutableTemp { temp, path })) } #[cfg(feature = "dir")] pub fn mutable_at(target: &std::path::Path) -> Result { let _ = std::fs::remove_dir_all(target); std::fs::create_dir_all(target) .map_err(|e| format!("Failed to create {}: {}", target.display(), e))?; Ok(Self(DirRootInner::MutablePath(target.to_owned()))) } #[cfg(feature = "dir")] pub fn with_template(self, template: &F) -> Result where F: crate::dir::DirFixture + ?Sized, { match &self.0 { DirRootInner::None | DirRootInner::Immutable(_) => { return Err("Sandboxing is disabled".into()); } DirRootInner::MutablePath(path) | DirRootInner::MutableTemp { path, .. } => { crate::debug!("Initializing {} from {:?}", path.display(), template); template.write_to_path(path)?; } } Ok(self) } pub fn is_mutable(&self) -> bool { match &self.0 { DirRootInner::None | DirRootInner::Immutable(_) => false, #[cfg(feature = "dir")] DirRootInner::MutablePath(_) => true, #[cfg(feature = "dir")] DirRootInner::MutableTemp { .. } => true, } } pub fn path(&self) -> Option<&std::path::Path> { match &self.0 { DirRootInner::None => None, DirRootInner::Immutable(path) => Some(path.as_path()), #[cfg(feature = "dir")] DirRootInner::MutablePath(path) => Some(path.as_path()), #[cfg(feature = "dir")] DirRootInner::MutableTemp { path, .. } => Some(path.as_path()), } } /// Explicitly close to report errors pub fn close(self) -> Result<(), std::io::Error> { match self.0 { DirRootInner::None | DirRootInner::Immutable(_) => Ok(()), #[cfg(feature = "dir")] DirRootInner::MutablePath(_) => Ok(()), #[cfg(feature = "dir")] DirRootInner::MutableTemp { temp, .. } => temp.close(), } } } impl Default for DirRoot { fn default() -> Self { Self::none() } } snapbox-0.6.21/src/dir/tests.rs000064400000000000000000000017671046102023000144540ustar 00000000000000use super::*; #[test] fn strips_trailing_slash() { let path = std::path::Path::new("/foo/bar/"); let rendered = path.display().to_string(); assert_eq!(rendered.as_bytes()[rendered.len() - 1], b'/'); let stripped = strip_trailing_slash(path); let rendered = stripped.display().to_string(); assert_eq!(rendered.as_bytes()[rendered.len() - 1], b'r'); } #[test] fn file_type_detect_file() { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); dbg!(&path); let actual = FileType::from_path(&path); assert_eq!(actual, FileType::File); } #[test] fn file_type_detect_dir() { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); dbg!(path); let actual = FileType::from_path(path); assert_eq!(actual, FileType::Dir); } #[test] fn file_type_detect_missing() { let path = std::path::Path::new("this-should-never-exist"); dbg!(path); let actual = FileType::from_path(path); assert_eq!(actual, FileType::Missing); } snapbox-0.6.21/src/filter/mod.rs000064400000000000000000000134031046102023000145660ustar 00000000000000//! Filter `actual` or `expected` [`Data`] //! //! This can be done for //! - Making snapshots consistent across platforms or conditional compilation //! - Focusing snapshots on the characteristics of the data being tested mod pattern; mod redactions; #[cfg(test)] mod test; #[cfg(test)] mod test_redactions; #[cfg(test)] mod test_unordered_redactions; use crate::data::DataInner; use crate::Data; pub use pattern::NormalizeToExpected; pub use redactions::RedactedValue; pub use redactions::Redactions; pub trait Filter { fn filter(&self, data: Data) -> Data; } pub struct FilterNewlines; impl Filter for FilterNewlines { fn filter(&self, data: Data) -> Data { let source = data.source; let filters = data.filters; let inner = match data.inner { DataInner::Error(err) => DataInner::Error(err), DataInner::Binary(bin) => DataInner::Binary(bin), DataInner::Text(text) => { let lines = normalize_lines(&text); DataInner::Text(lines) } #[cfg(feature = "json")] DataInner::Json(value) => { let mut value = value; normalize_json_string(&mut value, &normalize_lines); DataInner::Json(value) } #[cfg(feature = "json")] DataInner::JsonLines(value) => { let mut value = value; normalize_json_string(&mut value, &normalize_lines); DataInner::JsonLines(value) } #[cfg(feature = "term-svg")] DataInner::TermSvg(text) => { let lines = normalize_lines(&text); DataInner::TermSvg(lines) } }; Data { inner, source, filters, } } } /// Normalize line endings pub fn normalize_lines(data: &str) -> String { normalize_lines_chars(data.chars()).collect() } fn normalize_lines_chars(data: impl Iterator) -> impl Iterator { normalize_line_endings::normalized(data) } pub struct FilterPaths; impl Filter for FilterPaths { fn filter(&self, data: Data) -> Data { let source = data.source; let filters = data.filters; let inner = match data.inner { DataInner::Error(err) => DataInner::Error(err), DataInner::Binary(bin) => DataInner::Binary(bin), DataInner::Text(text) => { let lines = normalize_paths(&text); DataInner::Text(lines) } #[cfg(feature = "json")] DataInner::Json(value) => { let mut value = value; normalize_json_string(&mut value, &normalize_paths); DataInner::Json(value) } #[cfg(feature = "json")] DataInner::JsonLines(value) => { let mut value = value; normalize_json_string(&mut value, &normalize_paths); DataInner::JsonLines(value) } #[cfg(feature = "term-svg")] DataInner::TermSvg(text) => { let lines = normalize_paths(&text); DataInner::TermSvg(lines) } }; Data { inner, source, filters, } } } /// Normalize path separators /// /// [`std::path::MAIN_SEPARATOR`] can vary by platform, so make it consistent /// /// Note: this cannot distinguish between when a character is being used as a path separator or not /// and can "normalize" unrelated data pub fn normalize_paths(data: &str) -> String { normalize_paths_chars(data.chars()).collect() } fn normalize_paths_chars(data: impl Iterator) -> impl Iterator { data.map(|c| if c == '\\' { '/' } else { c }) } struct NormalizeRedactions<'r> { redactions: &'r Redactions, } impl Filter for NormalizeRedactions<'_> { fn filter(&self, data: Data) -> Data { let source = data.source; let filters = data.filters; let inner = match data.inner { DataInner::Error(err) => DataInner::Error(err), DataInner::Binary(bin) => DataInner::Binary(bin), DataInner::Text(text) => { let lines = self.redactions.redact(&text); DataInner::Text(lines) } #[cfg(feature = "json")] DataInner::Json(value) => { let mut value = value; normalize_json_string(&mut value, &|s| self.redactions.redact(s)); DataInner::Json(value) } #[cfg(feature = "json")] DataInner::JsonLines(value) => { let mut value = value; normalize_json_string(&mut value, &|s| self.redactions.redact(s)); DataInner::JsonLines(value) } #[cfg(feature = "term-svg")] DataInner::TermSvg(text) => { let lines = self.redactions.redact(&text); DataInner::TermSvg(lines) } }; Data { inner, source, filters, } } } #[cfg(feature = "structured-data")] fn normalize_json_string(value: &mut serde_json::Value, op: &dyn Fn(&str) -> String) { match value { serde_json::Value::String(str) => { *str = op(str); } serde_json::Value::Array(arr) => { for value in arr.iter_mut() { normalize_json_string(value, op); } } serde_json::Value::Object(obj) => { for (key, mut value) in std::mem::replace(obj, serde_json::Map::new()) { let key = op(&key); normalize_json_string(&mut value, op); obj.insert(key, value); } } _ => {} } } snapbox-0.6.21/src/filter/pattern.rs000064400000000000000000000537611046102023000154770ustar 00000000000000use super::{Filter, NormalizeRedactions, Redactions}; use crate::data::DataInner; use crate::Data; /// Adjust `actual` based on `expected` pub struct NormalizeToExpected<'a> { substitutions: Option<&'a Redactions>, unordered: bool, } impl<'a> NormalizeToExpected<'a> { pub fn new() -> Self { Self { substitutions: None, unordered: false, } } /// Make unordered content comparable /// /// This is done by re-ordering `actual` according to `expected`. pub fn unordered(mut self) -> Self { self.unordered = true; self } /// Apply built-in redactions. /// /// Built-in redactions: /// - `...` on a line of its own: match multiple complete lines /// - `[..]`: match multiple characters within a line /// /// Built-ins cannot automatically be applied to `actual` but are inferred from `expected` pub fn redact(mut self) -> Self { static REDACTIONS: Redactions = Redactions::new(); self.substitutions = Some(&REDACTIONS); self } /// Apply built-in and user [`Redactions`] /// /// Built-in redactions: /// - `...` on a line of its own: match multiple complete lines /// - `[..]`: match multiple characters within a line /// /// Built-ins cannot automatically be applied to `actual` but are inferred from `expected` pub fn redact_with(mut self, redactions: &'a Redactions) -> Self { self.substitutions = Some(redactions); self } pub fn normalize(&self, actual: Data, expected: &Data) -> Data { let actual = if let Some(substitutions) = self.substitutions { NormalizeRedactions { redactions: substitutions, } .filter(actual) } else { actual }; match (self.substitutions, self.unordered) { (None, false) => actual, (Some(substitutions), false) => { normalize_data_to_redactions(actual, expected, substitutions) } (None, true) => normalize_data_to_unordered(actual, expected), (Some(substitutions), true) => { normalize_data_to_unordered_redactions(actual, expected, substitutions) } } } } impl Default for NormalizeToExpected<'_> { fn default() -> Self { Self::new() } } fn normalize_data_to_unordered(actual: Data, expected: &Data) -> Data { let source = actual.source; let filters = actual.filters; let inner = match (actual.inner, &expected.inner) { (DataInner::Error(err), _) => DataInner::Error(err), (DataInner::Binary(bin), _) => DataInner::Binary(bin), (DataInner::Text(text), _) => { if let Some(pattern) = expected.render() { let lines = normalize_str_to_unordered(&text, &pattern); DataInner::Text(lines) } else { DataInner::Text(text) } } #[cfg(feature = "json")] (DataInner::Json(value), DataInner::Json(exp)) => { let mut value = value; normalize_value_to_unordered(&mut value, exp); DataInner::Json(value) } #[cfg(feature = "json")] (DataInner::JsonLines(value), DataInner::JsonLines(exp)) => { let mut value = value; normalize_value_to_unordered(&mut value, exp); DataInner::JsonLines(value) } #[cfg(feature = "term-svg")] (DataInner::TermSvg(text), DataInner::TermSvg(exp)) => { if let (Some((header, body, footer)), Some((_, exp, _))) = ( crate::data::split_term_svg(&text), crate::data::split_term_svg(exp), ) { let lines = normalize_str_to_unordered(body, exp); DataInner::TermSvg(format!("{header}{lines}{footer}")) } else { DataInner::TermSvg(text) } } // reachable if more than one structured data format is enabled #[allow(unreachable_patterns)] (inner, _) => inner, }; Data { inner, source, filters, } } #[cfg(feature = "structured-data")] fn normalize_value_to_unordered(actual: &mut serde_json::Value, expected: &serde_json::Value) { use serde_json::Value::{Array, Object, String}; match (actual, expected) { (String(act), String(exp)) => { *act = normalize_str_to_unordered(act, exp); } (Array(act), Array(exp)) => { let mut actual_values = std::mem::take(act); let mut expected_values = exp.clone(); expected_values.retain(|expected_value| { let mut matched = false; actual_values.retain(|actual_value| { if !matched && actual_value == expected_value { matched = true; false } else { true } }); if matched { act.push(expected_value.clone()); } !matched }); for actual_value in actual_values { act.push(actual_value); } } (Object(act), Object(exp)) => { for (actual_key, mut actual_value) in std::mem::replace(act, serde_json::Map::new()) { if let Some(expected_value) = exp.get(&actual_key) { normalize_value_to_unordered(&mut actual_value, expected_value); } act.insert(actual_key, actual_value); } } (_, _) => {} } } fn normalize_str_to_unordered(actual: &str, expected: &str) -> String { if actual == expected { return actual.to_owned(); } let mut normalized: Vec<&str> = Vec::new(); let mut actual_lines: Vec<_> = crate::utils::LinesWithTerminator::new(actual).collect(); let mut expected_lines: Vec<_> = crate::utils::LinesWithTerminator::new(expected).collect(); expected_lines.retain(|expected_line| { let mut matched = false; actual_lines.retain(|actual_line| { if !matched && actual_line == expected_line { matched = true; false } else { true } }); if matched { normalized.push(expected_line); } !matched }); for actual_line in &actual_lines { normalized.push(actual_line); } normalized.join("") } #[cfg(feature = "structured-data")] const KEY_WILDCARD: &str = "..."; #[cfg(feature = "structured-data")] const VALUE_WILDCARD: &str = "{...}"; fn normalize_data_to_unordered_redactions( actual: Data, expected: &Data, substitutions: &Redactions, ) -> Data { let source = actual.source; let filters = actual.filters; let inner = match (actual.inner, &expected.inner) { (DataInner::Error(err), _) => DataInner::Error(err), (DataInner::Binary(bin), _) => DataInner::Binary(bin), (DataInner::Text(text), _) => { if let Some(pattern) = expected.render() { let lines = normalize_str_to_unordered_redactions(&text, &pattern, substitutions); DataInner::Text(lines) } else { DataInner::Text(text) } } #[cfg(feature = "json")] (DataInner::Json(value), DataInner::Json(exp)) => { let mut value = value; normalize_value_to_unordered_redactions(&mut value, exp, substitutions); DataInner::Json(value) } #[cfg(feature = "json")] (DataInner::JsonLines(value), DataInner::JsonLines(exp)) => { let mut value = value; normalize_value_to_unordered_redactions(&mut value, exp, substitutions); DataInner::JsonLines(value) } #[cfg(feature = "term-svg")] (DataInner::TermSvg(text), DataInner::TermSvg(exp)) => { if let (Some((header, body, footer)), Some((_, exp, _))) = ( crate::data::split_term_svg(&text), crate::data::split_term_svg(exp), ) { let lines = normalize_str_to_unordered_redactions(body, exp, substitutions); DataInner::TermSvg(format!("{header}{lines}{footer}")) } else { DataInner::TermSvg(text) } } // reachable if more than one structured data format is enabled #[allow(unreachable_patterns)] (inner, _) => inner, }; Data { inner, source, filters, } } #[cfg(feature = "structured-data")] fn normalize_value_to_unordered_redactions( actual: &mut serde_json::Value, expected: &serde_json::Value, substitutions: &Redactions, ) { use serde_json::Value::{Array, Object, String}; match (actual, expected) { (act, String(exp)) if exp == VALUE_WILDCARD => { *act = serde_json::json!(VALUE_WILDCARD); } (String(act), String(exp)) => { *act = normalize_str_to_unordered_redactions(act, exp, substitutions); } (Array(act), Array(exp)) => { *act = normalize_array_to_unordered_redactions(act, exp, substitutions); } (Object(act), Object(exp)) => { let has_key_wildcard = exp.get(KEY_WILDCARD).and_then(|v| v.as_str()) == Some(VALUE_WILDCARD); for (actual_key, mut actual_value) in std::mem::replace(act, serde_json::Map::new()) { if let Some(expected_value) = exp.get(&actual_key) { normalize_value_to_unordered_redactions( &mut actual_value, expected_value, substitutions, ); } else if has_key_wildcard { continue; } act.insert(actual_key, actual_value); } if has_key_wildcard { act.insert(KEY_WILDCARD.to_owned(), String(VALUE_WILDCARD.to_owned())); } } (_, _) => {} } } #[cfg(feature = "structured-data")] fn normalize_array_to_unordered_redactions( actual: &[serde_json::Value], expected: &[serde_json::Value], substitutions: &Redactions, ) -> Vec { if actual == expected { return actual.to_owned(); } let mut normalized: Vec = Vec::new(); let mut actual_values = actual.to_owned(); let mut expected_values = expected.to_owned(); let mut elided = false; expected_values.retain(|expected_value| { let mut matched = false; if expected_value == VALUE_WILDCARD { matched = true; elided = true; } else { actual_values.retain(|actual_value| { let mut normalized_actual_value = actual_value.clone(); normalize_value_to_unordered_redactions( &mut normalized_actual_value, expected_value, substitutions, ); if !matched && normalized_actual_value == *expected_value { matched = true; false } else { true } }); } if matched { normalized.push(expected_value.clone()); } !matched }); if !elided { for actual_value in actual_values { normalized.push(actual_value); } } normalized } fn normalize_str_to_unordered_redactions( actual: &str, expected: &str, substitutions: &Redactions, ) -> String { if actual == expected { return actual.to_owned(); } let mut normalized: Vec<&str> = Vec::new(); let mut actual_lines: Vec<_> = crate::utils::LinesWithTerminator::new(actual).collect(); let mut expected_lines: Vec<_> = crate::utils::LinesWithTerminator::new(expected).collect(); let mut elided = false; expected_lines.retain(|expected_line| { let mut matched = false; if is_line_elide(expected_line) { matched = true; elided = true; } else { actual_lines.retain(|actual_line| { if !matched && line_matches(actual_line, expected_line, substitutions) { matched = true; false } else { true } }); } if matched { normalized.push(expected_line); } !matched }); if !elided { for actual_line in &actual_lines { normalized.push(actual_line); } } normalized.join("") } fn normalize_data_to_redactions(actual: Data, expected: &Data, substitutions: &Redactions) -> Data { let source = actual.source; let filters = actual.filters; let inner = match (actual.inner, &expected.inner) { (DataInner::Error(err), _) => DataInner::Error(err), (DataInner::Binary(bin), _) => DataInner::Binary(bin), (DataInner::Text(text), _) => { if let Some(pattern) = expected.render() { let lines = normalize_str_to_redactions(&text, &pattern, substitutions); DataInner::Text(lines) } else { DataInner::Text(text) } } #[cfg(feature = "json")] (DataInner::Json(value), DataInner::Json(exp)) => { let mut value = value; normalize_value_to_redactions(&mut value, exp, substitutions); DataInner::Json(value) } #[cfg(feature = "json")] (DataInner::JsonLines(value), DataInner::JsonLines(exp)) => { let mut value = value; normalize_value_to_redactions(&mut value, exp, substitutions); DataInner::JsonLines(value) } #[cfg(feature = "term-svg")] (DataInner::TermSvg(text), DataInner::TermSvg(exp)) => { if let (Some((header, body, footer)), Some((_, exp, _))) = ( crate::data::split_term_svg(&text), crate::data::split_term_svg(exp), ) { let lines = normalize_str_to_redactions(body, exp, substitutions); DataInner::TermSvg(format!("{header}{lines}{footer}")) } else { DataInner::TermSvg(text) } } // reachable if more than one structured data format is enabled #[allow(unreachable_patterns)] (inner, _) => inner, }; Data { inner, source, filters, } } #[cfg(feature = "structured-data")] fn normalize_value_to_redactions( actual: &mut serde_json::Value, expected: &serde_json::Value, substitutions: &Redactions, ) { use serde_json::Value::{Array, Object, String}; match (actual, expected) { (act, String(exp)) if exp == VALUE_WILDCARD => { *act = serde_json::json!(VALUE_WILDCARD); } (String(act), String(exp)) => { *act = normalize_str_to_redactions(act, exp, substitutions); } (Array(act), Array(exp)) => { *act = normalize_array_to_redactions(act, exp, substitutions); } (Object(act), Object(exp)) => { let has_key_wildcard = exp.get(KEY_WILDCARD).and_then(|v| v.as_str()) == Some(VALUE_WILDCARD); for (actual_key, mut actual_value) in std::mem::replace(act, serde_json::Map::new()) { if let Some(expected_value) = exp.get(&actual_key) { normalize_value_to_redactions(&mut actual_value, expected_value, substitutions); } else if has_key_wildcard { continue; } act.insert(actual_key, actual_value); } if has_key_wildcard { act.insert(KEY_WILDCARD.to_owned(), String(VALUE_WILDCARD.to_owned())); } } (_, _) => {} } } #[cfg(feature = "structured-data")] fn normalize_array_to_redactions( actual: &[serde_json::Value], expected: &[serde_json::Value], redactions: &Redactions, ) -> Vec { if actual == expected { return actual.to_vec(); } let mut normalized: Vec = Vec::new(); let mut actual_index = 0; let mut expected = expected.iter().peekable(); while let Some(expected_elem) = expected.next() { if expected_elem == VALUE_WILDCARD { let Some(next_expected_elem) = expected.peek() else { // Stop as elide consumes to end normalized.push(expected_elem.clone()); actual_index = actual.len(); break; }; let Some(index_offset) = actual[actual_index..].iter().position(|next_actual_elem| { let mut next_actual_elem = next_actual_elem.clone(); normalize_value_to_redactions( &mut next_actual_elem, next_expected_elem, redactions, ); next_actual_elem == **next_expected_elem }) else { // Give up as we can't find where the elide ends break; }; normalized.push(expected_elem.clone()); actual_index += index_offset; } else { let Some(actual_elem) = actual.get(actual_index) else { // Give up as we have no more content to check break; }; actual_index += 1; let mut normalized_elem = actual_elem.clone(); normalize_value_to_redactions(&mut normalized_elem, expected_elem, redactions); normalized.push(normalized_elem); } } normalized.extend(actual[actual_index..].iter().cloned()); normalized } fn normalize_str_to_redactions(actual: &str, expected: &str, redactions: &Redactions) -> String { if actual == expected { return actual.to_owned(); } let mut normalized: Vec<&str> = Vec::new(); let mut actual_index = 0; let actual_lines: Vec<_> = crate::utils::LinesWithTerminator::new(actual).collect(); let mut expected_lines = crate::utils::LinesWithTerminator::new(expected).peekable(); while let Some(expected_line) = expected_lines.next() { if is_line_elide(expected_line) { let Some(next_expected_line) = expected_lines.peek() else { // Stop as elide consumes to end normalized.push(expected_line); actual_index = actual_lines.len(); break; }; let Some(index_offset) = actual_lines[actual_index..] .iter() .position(|next_actual_line| { line_matches(next_actual_line, next_expected_line, redactions) }) else { // Give up as we can't find where the elide ends break; }; normalized.push(expected_line); actual_index += index_offset; } else { let Some(actual_line) = actual_lines.get(actual_index) else { // Give up as we have no more content to check break; }; if line_matches(actual_line, expected_line, redactions) { actual_index += 1; normalized.push(expected_line); } else { // Skip this line and keep processing actual_index += 1; normalized.push(actual_line); } } } normalized.extend(actual_lines[actual_index..].iter().copied()); normalized.join("") } fn is_line_elide(line: &str) -> bool { line == "...\n" || line == "..." } fn line_matches(mut actual: &str, expected: &str, redactions: &Redactions) -> bool { if actual == expected { return true; } let expected = redactions.clear_unused(expected); let mut sections = expected.split("[..]").peekable(); while let Some(section) = sections.next() { if let Some(remainder) = actual.strip_prefix(section) { if let Some(next_section) = sections.peek() { if next_section.is_empty() { actual = ""; } else if let Some(restart_index) = remainder.find(next_section) { actual = &remainder[restart_index..]; } } else { return remainder.is_empty(); } } else { return false; } } false } #[cfg(test)] mod test { use super::*; #[test] fn str_normalize_redactions_line_matches_cases() { let cases = [ ("", "", true), ("", "[..]", true), ("hello", "hello", true), ("hello", "goodbye", false), ("hello", "[..]", true), ("hello", "he[..]", true), ("hello", "go[..]", false), ("hello", "[..]o", true), ("hello", "[..]e", false), ("hello", "he[..]o", true), ("hello", "he[..]e", false), ("hello", "go[..]o", false), ("hello", "go[..]e", false), ( "hello world, goodbye moon", "hello [..], goodbye [..]", true, ), ( "hello world, goodbye moon", "goodbye [..], goodbye [..]", false, ), ( "hello world, goodbye moon", "goodbye [..], hello [..]", false, ), ("hello world, goodbye moon", "hello [..], [..] moon", true), ( "hello world, goodbye moon", "goodbye [..], [..] moon", false, ), ("hello world, goodbye moon", "hello [..], [..] world", false), ]; for (line, pattern, expected) in cases { let actual = line_matches(line, pattern, &Redactions::new()); assert_eq!(expected, actual, "line={line:?} pattern={pattern:?}"); } } } snapbox-0.6.21/src/filter/redactions.rs000064400000000000000000000257251046102023000161540ustar 00000000000000use std::borrow::Cow; use std::path::Path; use std::path::PathBuf; /// Replace data with placeholders /// /// This can be used for: /// - Handling test-run dependent data like temp directories or elapsed time /// - Making special characters more obvious (e.g. redacting a tab a `[TAB]`) /// - Normalizing platform-specific data like [`std::env::consts::EXE_SUFFIX`] /// /// # Examples /// /// ```rust /// let mut subst = snapbox::Redactions::new(); /// subst.insert("[LOCATION]", "World"); /// assert_eq!(subst.redact("Hello World!"), "Hello [LOCATION]!"); /// ``` #[derive(Default, Clone, Debug, PartialEq, Eq)] pub struct Redactions { vars: Option< std::collections::BTreeMap>, >, unused: Option>, } impl Redactions { pub const fn new() -> Self { Self { vars: None, unused: None, } } pub(crate) fn with_exe() -> Self { let mut redactions = Self::new(); redactions .insert("[EXE]", std::env::consts::EXE_SUFFIX) .unwrap(); redactions } /// Insert an additional match pattern /// /// `placeholder` must be enclosed in `[` and `]`. /// /// ```rust /// let mut subst = snapbox::Redactions::new(); /// subst.insert("[EXE]", std::env::consts::EXE_SUFFIX); /// ``` /// /// With the `regex` feature, you can define patterns using regexes. /// You can choose to replace a subset of the regex by giving it the named capture group /// `redacted`. /// /// ```rust /// # #[cfg(feature = "regex")] { /// let mut subst = snapbox::Redactions::new(); /// subst.insert("[OBJECT]", regex::Regex::new("(?(world|moon))").unwrap()); /// assert_eq!(subst.redact("Hello world!"), "Hello [OBJECT]!"); /// assert_eq!(subst.redact("Hello moon!"), "Hello [OBJECT]!"); /// assert_eq!(subst.redact("Hello other!"), "Hello other!"); /// # } /// ``` pub fn insert( &mut self, placeholder: &'static str, value: impl Into, ) -> crate::assert::Result<()> { let placeholder = validate_placeholder(placeholder)?; let value = value.into(); if let Some(value) = value.inner { self.vars .get_or_insert(std::collections::BTreeMap::new()) .entry(value) .or_default() .insert(placeholder); } else { self.unused .get_or_insert(std::collections::BTreeSet::new()) .insert(RedactedValueInner::Str(placeholder)); } Ok(()) } /// Insert additional match patterns /// /// Placeholders must be enclosed in `[` and `]`. pub fn extend( &mut self, vars: impl IntoIterator)>, ) -> crate::assert::Result<()> { for (placeholder, value) in vars { self.insert(placeholder, value)?; } Ok(()) } pub fn remove(&mut self, placeholder: &'static str) -> crate::assert::Result<()> { let placeholder = validate_placeholder(placeholder)?; self.vars .get_or_insert(std::collections::BTreeMap::new()) .retain(|_value, placeholders| { placeholders.retain(|p| *p != placeholder); !placeholders.is_empty() }); Ok(()) } /// Apply redaction only, no pattern-dependent globs /// /// # Examples /// /// ```rust /// let mut subst = snapbox::Redactions::new(); /// subst.insert("[LOCATION]", "World"); /// let output = subst.redact("Hello World!"); /// assert_eq!(output, "Hello [LOCATION]!"); /// ``` pub fn redact(&self, input: &str) -> String { let mut input = input.to_owned(); replace_many( &mut input, self.vars .iter() .flatten() .flat_map(|(value, placeholders)| { placeholders .iter() .map(move |placeholder| (value, *placeholder)) }), ); input } /// Clear unused redactions from expected data /// /// Some redactions can be conditionally present, like redacting [`std::env::consts::EXE_SUFFIX`]. /// When the redaction is not present, it needs to be removed from the expected data so it can /// be matched against the actual data. pub fn clear_unused<'v>(&self, pattern: &'v str) -> Cow<'v, str> { if !self.unused.as_ref().map(|s| s.is_empty()).unwrap_or(false) && pattern.contains('[') { let mut pattern = pattern.to_owned(); replace_many( &mut pattern, self.unused.iter().flatten().map(|var| (var, "")), ); Cow::Owned(pattern) } else { Cow::Borrowed(pattern) } } } #[derive(Clone)] pub struct RedactedValue { inner: Option, } #[derive(Clone, Debug)] enum RedactedValueInner { Str(&'static str), String(String), Path { native: String, normalized: String, }, #[cfg(feature = "regex")] Regex(regex::Regex), } impl RedactedValueInner { fn find_in(&self, buffer: &str) -> Option> { match self { Self::Str(s) => buffer.find(s).map(|offset| offset..(offset + s.len())), Self::String(s) => buffer.find(s).map(|offset| offset..(offset + s.len())), Self::Path { native, normalized } => { match (buffer.find(native), buffer.find(normalized)) { (Some(native_offset), Some(normalized_offset)) => { if native_offset <= normalized_offset { Some(native_offset..(native_offset + native.len())) } else { Some(normalized_offset..(normalized_offset + normalized.len())) } } (Some(offset), None) => Some(offset..(offset + native.len())), (None, Some(offset)) => Some(offset..(offset + normalized.len())), (None, None) => None, } } #[cfg(feature = "regex")] Self::Regex(r) => { let captures = r.captures(buffer)?; let m = captures.name("redacted").or_else(|| captures.get(0))?; Some(m.range()) } } } fn as_cmp(&self) -> (usize, std::cmp::Reverse, &str) { match self { Self::Str(s) => (0, std::cmp::Reverse(s.len()), s), Self::String(s) => (0, std::cmp::Reverse(s.len()), s), Self::Path { normalized: s, .. } => (0, std::cmp::Reverse(s.len()), s), #[cfg(feature = "regex")] Self::Regex(r) => { let s = r.as_str(); (1, std::cmp::Reverse(s.len()), s) } } } } impl From<&'static str> for RedactedValue { fn from(inner: &'static str) -> Self { if inner.is_empty() { Self { inner: None } } else { Self { inner: Some(RedactedValueInner::Str(inner)), } } } } impl From for RedactedValue { fn from(inner: String) -> Self { if inner.is_empty() { Self { inner: None } } else { Self { inner: Some(RedactedValueInner::String(inner)), } } } } impl From<&'_ String> for RedactedValue { fn from(inner: &'_ String) -> Self { inner.clone().into() } } impl From> for RedactedValue { fn from(inner: Cow<'static, str>) -> Self { match inner { Cow::Borrowed(s) => s.into(), Cow::Owned(s) => s.into(), } } } impl From<&'static Path> for RedactedValue { fn from(inner: &'static Path) -> Self { inner.to_owned().into() } } impl From for RedactedValue { fn from(inner: PathBuf) -> Self { if inner.as_os_str().is_empty() { Self { inner: None } } else { let native = match inner.into_os_string().into_string() { Ok(s) => s, Err(os) => PathBuf::from(os).display().to_string(), }; let normalized = crate::filter::normalize_paths(&native); Self { inner: Some(RedactedValueInner::Path { native, normalized }), } } } } impl From<&'_ PathBuf> for RedactedValue { fn from(inner: &'_ PathBuf) -> Self { inner.clone().into() } } #[cfg(feature = "regex")] impl From for RedactedValue { fn from(inner: regex::Regex) -> Self { Self { inner: Some(RedactedValueInner::Regex(inner)), } } } #[cfg(feature = "regex")] impl From<&'_ regex::Regex> for RedactedValue { fn from(inner: &'_ regex::Regex) -> Self { inner.clone().into() } } impl PartialOrd for RedactedValueInner { fn partial_cmp(&self, other: &Self) -> Option { Some(self.as_cmp().cmp(&other.as_cmp())) } } impl Ord for RedactedValueInner { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.as_cmp().cmp(&other.as_cmp()) } } impl PartialEq for RedactedValueInner { fn eq(&self, other: &Self) -> bool { self.as_cmp().eq(&other.as_cmp()) } } impl Eq for RedactedValueInner {} /// Replacements is `(from, to)` fn replace_many<'a>( buffer: &mut String, replacements: impl IntoIterator, ) { for (var, replace) in replacements { let mut index = 0; while let Some(offset) = var.find_in(&buffer[index..]) { let old_range = (index + offset.start)..(index + offset.end); buffer.replace_range(old_range, replace); index += offset.start + replace.len(); } } } fn validate_placeholder(placeholder: &'static str) -> crate::assert::Result<&'static str> { if !placeholder.starts_with('[') || !placeholder.ends_with(']') { return Err(format!("Key `{placeholder}` is not enclosed in []").into()); } if placeholder[1..(placeholder.len() - 1)] .find(|c: char| !c.is_ascii_uppercase() && c != '_') .is_some() { return Err(format!("Key `{placeholder}` can only be A-Z but ").into()); } Ok(placeholder) } #[cfg(test)] mod test { use super::*; #[test] fn test_validate_placeholder() { let cases = [ ("[HELLO", false), ("HELLO]", false), ("[HELLO]", true), ("[HELLO_WORLD]", true), ("[hello]", false), ("[HE O]", false), ]; for (placeholder, expected) in cases { let actual = validate_placeholder(placeholder).is_ok(); assert_eq!(expected, actual, "placeholder={placeholder:?}"); } } } snapbox-0.6.21/src/filter/test.rs000064400000000000000000000061411046102023000147670ustar 00000000000000#[cfg(feature = "json")] use serde_json::json; #[cfg(feature = "json")] use super::*; // Tests for normalization on json #[test] #[cfg(feature = "json")] fn json_normalize_paths_and_lines_string() { let json = json!({"name": "John\\Doe\r\n"}); let data = Data::json(json); let data = FilterPaths.filter(data); assert_eq!(Data::json(json!({"name": "John/Doe\r\n"})), data); let data = FilterNewlines.filter(data); assert_eq!(Data::json(json!({"name": "John/Doe\n"})), data); } #[test] #[cfg(feature = "json")] fn json_normalize_paths_and_lines_nested_string() { let json = json!({ "person": { "name": "John\\Doe\r\n", "nickname": "Jo\\hn\r\n", } }); let data = Data::json(json); let data = FilterPaths.filter(data); let assert = json!({ "person": { "name": "John/Doe\r\n", "nickname": "Jo/hn\r\n", } }); assert_eq!(Data::json(assert), data); let data = FilterNewlines.filter(data); let assert = json!({ "person": { "name": "John/Doe\n", "nickname": "Jo/hn\n", } }); assert_eq!(Data::json(assert), data); } #[test] #[cfg(feature = "json")] fn json_normalize_paths_and_lines_obj_key() { let json = json!({ "person": { "John\\Doe\r\n": "name", "Jo\\hn\r\n": "nickname", } }); let data = Data::json(json); let data = FilterPaths.filter(data); let assert = json!({ "person": { "John/Doe\r\n": "name", "Jo/hn\r\n": "nickname", } }); assert_eq!(Data::json(assert), data); let data = FilterNewlines.filter(data); let assert = json!({ "person": { "John/Doe\n": "name", "Jo/hn\n": "nickname", } }); assert_eq!(Data::json(assert), data); } #[test] #[cfg(feature = "json")] fn json_normalize_paths_and_lines_array() { let json = json!({"people": ["John\\Doe\r\n", "Jo\\hn\r\n"]}); let data = Data::json(json); let data = FilterPaths.filter(data); let paths = json!({"people": ["John/Doe\r\n", "Jo/hn\r\n"]}); assert_eq!(Data::json(paths), data); let data = FilterNewlines.filter(data); let new_lines = json!({"people": ["John/Doe\n", "Jo/hn\n"]}); assert_eq!(Data::json(new_lines), data); } #[test] #[cfg(feature = "json")] fn json_normalize_paths_and_lines_array_obj() { let json = json!({ "people": [ { "name": "John\\Doe\r\n", "nickname": "Jo\\hn\r\n", } ] }); let data = Data::json(json); let data = FilterPaths.filter(data); let paths = json!({ "people": [ { "name": "John/Doe\r\n", "nickname": "Jo/hn\r\n", } ] }); assert_eq!(Data::json(paths), data); let data = FilterNewlines.filter(data); let new_lines = json!({ "people": [ { "name": "John/Doe\n", "nickname": "Jo/hn\n", } ] }); assert_eq!(Data::json(new_lines), data); } snapbox-0.6.21/src/filter/test_redactions.rs000064400000000000000000000351701046102023000172060ustar 00000000000000use std::path::PathBuf; #[cfg(feature = "json")] use serde_json::json; use super::*; use crate::prelude::*; #[test] fn str_normalize_empty() { let input = ""; let pattern = ""; let expected = ""; let actual = NormalizeToExpected::new() .redact() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_literals_match() { let input = "Hello\nWorld"; let pattern = "Hello\nWorld"; let expected = "Hello\nWorld"; let actual = NormalizeToExpected::new() .redact() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_pattern_shorter() { let input = "Hello\nWorld"; let pattern = "Hello\n"; let expected = "Hello\nWorld"; let actual = NormalizeToExpected::new() .redact() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_input_shorter() { let input = "Hello\n"; let pattern = "Hello\nWorld"; let expected = "Hello\n"; let actual = NormalizeToExpected::new() .redact() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_all_different() { let input = "Hello\nWorld"; let pattern = "Goodbye\nMoon"; let expected = "Hello\nWorld"; let actual = NormalizeToExpected::new() .redact() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_middles_diverge() { let input = "Hello\nWorld\nGoodbye"; let pattern = "Hello\nMoon\nGoodbye"; let expected = "Hello\nWorld\nGoodbye"; let actual = NormalizeToExpected::new() .redact() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_elide_delimited_with_sub() { let input = "Hello World\nHow are you?\nGoodbye World"; let pattern = "Hello [..]\n...\nGoodbye [..]"; let expected = "Hello [..]\n...\nGoodbye [..]"; let actual = NormalizeToExpected::new() .redact() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_leading_elide() { let input = "Hello\nWorld\nGoodbye"; let pattern = "...\nGoodbye"; let expected = "...\nGoodbye"; let actual = NormalizeToExpected::new() .redact() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_trailing_elide() { let input = "Hello\nWorld\nGoodbye"; let pattern = "Hello\n..."; let expected = "Hello\n..."; let actual = NormalizeToExpected::new() .redact() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_middle_elide() { let input = "Hello\nWorld\nGoodbye"; let pattern = "Hello\n...\nGoodbye"; let expected = "Hello\n...\nGoodbye"; let actual = NormalizeToExpected::new() .redact() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_post_elide_diverge() { let input = "Hello\nSun\nAnd\nWorld"; let pattern = "Hello\n...\nMoon"; let expected = "Hello\nSun\nAnd\nWorld"; let actual = NormalizeToExpected::new() .redact() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_post_diverge_elide() { let input = "Hello\nWorld\nGoodbye\nSir"; let pattern = "Hello\nMoon\nGoodbye\n..."; let expected = "Hello\nWorld\nGoodbye\n..."; let actual = NormalizeToExpected::new() .redact() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_inline_elide() { let input = "Hello\nWorld\nGoodbye\nSir"; let pattern = "Hello\nW[..]d\nGoodbye\nSir"; let expected = "Hello\nW[..]d\nGoodbye\nSir"; let actual = NormalizeToExpected::new() .redact() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_user_literal() { let input = "Hello world!"; let pattern = "Hello [OBJECT]!"; let mut sub = Redactions::new(); sub.insert("[OBJECT]", "world").unwrap(); let actual = NormalizeToExpected::new() .redact_with(&sub) .normalize(input.into(), &pattern.into()); assert_eq!(actual, pattern.into_data()); } #[test] fn str_normalize_user_path() { let input = "input: /home/epage"; let pattern = "input: [HOME]"; let mut sub = Redactions::new(); let sep = std::path::MAIN_SEPARATOR.to_string(); let redacted = PathBuf::from(sep).join("home").join("epage"); sub.insert("[HOME]", redacted).unwrap(); let actual = NormalizeToExpected::new() .redact_with(&sub) .normalize(input.into(), &pattern.into()); assert_eq!(actual, pattern.into_data()); } #[test] fn str_normalize_user_overlapping_path() { let input = "\ a: /home/epage b: /home/epage/snapbox"; let pattern = "\ a: [A] b: [B]"; let mut sub = Redactions::new(); let sep = std::path::MAIN_SEPARATOR.to_string(); let redacted = PathBuf::from(&sep).join("home").join("epage"); sub.insert("[A]", redacted).unwrap(); let redacted = PathBuf::from(sep) .join("home") .join("epage") .join("snapbox"); sub.insert("[B]", redacted).unwrap(); let actual = NormalizeToExpected::new() .redact_with(&sub) .normalize(input.into(), &pattern.into()); assert_eq!(actual, pattern.into_data()); } #[test] fn str_normalize_user_disabled() { let input = "cargo"; let pattern = "cargo[EXE]"; let mut sub = Redactions::new(); sub.insert("[EXE]", "").unwrap(); let actual = NormalizeToExpected::new() .redact_with(&sub) .normalize(input.into(), &pattern.into()); assert_eq!(actual, pattern.into_data()); } #[test] #[cfg(feature = "regex")] fn str_normalize_user_regex_unnamed() { let input = "Hello world!"; let pattern = "Hello [OBJECT]!"; let mut sub = Redactions::new(); sub.insert("[OBJECT]", regex::Regex::new("world").unwrap()) .unwrap(); let actual = NormalizeToExpected::new() .redact_with(&sub) .normalize(input.into(), &pattern.into()); assert_eq!(actual, pattern.into_data()); } #[test] #[cfg(feature = "regex")] fn str_normalize_user_regex_named() { let input = "Hello world!"; let pattern = "Hello [OBJECT]!"; let mut sub = Redactions::new(); sub.insert( "[OBJECT]", regex::Regex::new("(?world)!").unwrap(), ) .unwrap(); let actual = NormalizeToExpected::new() .redact_with(&sub) .normalize(input.into(), &pattern.into()); assert_eq!(actual, pattern.into_data()); } #[test] #[cfg(feature = "json")] fn json_normalize_glob_for_string() { let exp = json!({"name": "{...}"}); let expected = Data::json(exp); let actual = json!({"name": "JohnDoe"}); let actual = NormalizeToExpected::new() .redact() .normalize(Data::json(actual), &expected); if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { assert_eq!(exp, act); } } #[test] #[cfg(feature = "json")] fn json_normalize_glob_for_array() { let exp = json!({"people": "{...}"}); let expected = Data::json(exp); let actual = json!({ "people": [ { "name": "JohnDoe", "nickname": "John", } ] }); let actual = NormalizeToExpected::new() .redact() .normalize(Data::json(actual), &expected); if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { assert_eq!(exp, act); } } #[test] #[cfg(feature = "json")] fn json_normalize_glob_for_obj() { let exp = json!({"people": "{...}"}); let expected = Data::json(exp); let actual = json!({ "people": { "name": "JohnDoe", "nickname": "John", } }); let actual = NormalizeToExpected::new() .redact() .normalize(Data::json(actual), &expected); if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { assert_eq!(exp, act); } } #[test] #[cfg(feature = "json")] fn json_normalize_glob_array_start() { let exp = json!({ "people": [ "{...}", { "name": "three", "nickname": "3", } ] }); let expected = Data::json(exp); let actual = json!({ "people": [ { "name": "one", "nickname": "1", }, { "name": "two", "nickname": "2", }, { "name": "three", "nickname": "3", } ] }); let actual = NormalizeToExpected::new() .redact() .normalize(Data::json(actual), &expected); if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { assert_eq!(exp, act); } } #[test] #[cfg(feature = "json")] fn json_normalize_glob_for_array_start_end() { let exp = json!([ "{...}", { "name": "two", "nickname": "2", }, "{...}" ]); let expected = Data::json(exp); let actual = json!([ { "name": "one", "nickname": "1", }, { "name": "two", "nickname": "2", }, { "name": "three", "nickname": "3", }, { "name": "four", "nickname": "4", } ]); let actual = NormalizeToExpected::new() .redact() .normalize(Data::json(actual), &expected); if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { assert_eq!(exp, act); } } #[test] #[cfg(feature = "json")] fn json_normalize_glob_for_array_middle_end() { let exp = json!([ { "name": "one", "nickname": "1", }, "{...}", { "name": "three", "nickname": "3", }, "{...}" ]); let expected = Data::json(exp); let actual = json!([ { "name": "one", "nickname": "1", }, { "name": "two", "nickname": "2", }, { "name": "three", "nickname": "3", }, { "name": "four", "nickname": "4", }, { "name": "five", "nickname": "5", } ]); let actual = NormalizeToExpected::new() .redact() .normalize(Data::json(actual), &expected); if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { assert_eq!(exp, act); } } #[test] #[cfg(feature = "json")] fn json_normalize_glob_for_array_mismatch() { let exp = json!([ { "name": "one", "nickname": "1", }, "{...}", { "name": "three", "nickname": "3", }, "{...}" ]); let expected = Data::json(exp); let actual = json!([ { "name": "one", "nickname": "1", }, { "name": "two", "nickname": "2", }, { "name": "four", "nickname": "4", }, { "name": "five", "nickname": "5", } ]); let actual_normalized = NormalizeToExpected::new() .redact() .normalize(Data::json(actual.clone()), &expected); if let DataInner::Json(act) = actual_normalized.inner { assert_eq!(act, actual); } } #[test] #[cfg(feature = "json")] fn json_normalize_bad_order() { let exp = json!({ "people": ["John", "Jane"] }); let expected = Data::json(exp); let actual = json!({ "people": ["Jane", "John"] }); let actual = NormalizeToExpected::new() .redact() .normalize(Data::json(actual), &expected); if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { assert_ne!(exp, act); } } #[test] #[cfg(feature = "json")] fn json_normalize_obj_key() { let expected = json!({ "[A]": "value-a", "[B]": "value-b", "[C]": "value-c", }); let expected = Data::json(expected); let actual = json!({ "key-a": "value-a", "key-b": "value-b", "key-c": "value-c", }); let actual = Data::json(actual); let mut sub = Redactions::new(); sub.insert("[A]", "key-a").unwrap(); sub.insert("[B]", "key-b").unwrap(); sub.insert("[C]", "key-c").unwrap(); let actual = NormalizeToExpected::new() .redact_with(&sub) .normalize(actual, &expected); let expected_actual = json!({ "[A]": "value-a", "[B]": "value-b", "[C]": "value-c", }); let expected_actual = Data::json(expected_actual); assert_eq!(actual, expected_actual); } #[test] #[cfg(feature = "json")] fn json_normalize_with_missing_obj_key() { let expected = json!({ "a": "[A]", "b": "[B]", "c": "[C]", }); let expected = Data::json(expected); let actual = json!({ "a": "value-a", "c": "value-c", }); let actual = Data::json(actual); let mut sub = Redactions::new(); sub.insert("[A]", "value-a").unwrap(); sub.insert("[B]", "value-b").unwrap(); sub.insert("[C]", "value-c").unwrap(); let actual = NormalizeToExpected::new() .redact_with(&sub) .normalize(actual, &expected); let expected_actual = json!({ "a": "[A]", "c": "[C]", }); let expected_actual = Data::json(expected_actual); assert_eq!(actual, expected_actual); } #[test] #[cfg(feature = "json")] fn json_normalize_glob_obj_key() { let expected = json!({ "a": "value-a", "c": "value-c", "...": "{...}", }); let expected = Data::json(expected); let actual = json!({ "a": "value-a", "b": "value-b", "c": "value-c", }); let actual = Data::json(actual); let actual = NormalizeToExpected::new() .redact() .normalize(actual, &expected); let expected_actual = json!({ "a": "value-a", "c": "value-c", "...": "{...}", }); let expected_actual = Data::json(expected_actual); assert_eq!(actual, expected_actual); } snapbox-0.6.21/src/filter/test_unordered.rs000064400000000000000000000110411046102023000170310ustar 00000000000000use std::path::PathBuf; #[cfg(feature = "json")] use serde_json::json; use super::*; use crate::prelude::*; #[test] fn str_normalize_empty() { let input = ""; let pattern = ""; let expected = ""; let actual = NormalizeToExpected::new() .unordered() .normalize(input.into_data(), &pattern.into_data()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_same_order() { let input = "1 2 3 "; let pattern = "1 2 3 "; let expected = "1 2 3 "; let actual = NormalizeToExpected::new() .unordered() .normalize(input.into_data(), &pattern.into_data()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_reverse_order() { let input = "1 2 3 "; let pattern = "3 2 1 "; let expected = "3 2 1 "; let actual = NormalizeToExpected::new() .unordered() .normalize(input.into_data(), &pattern.into_data()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_actual_missing() { let input = "1 3 "; let pattern = "1 2 3 "; let expected = "1 3 "; let actual = NormalizeToExpected::new() .unordered() .normalize(input.into_data(), &pattern.into_data()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_expected_missing() { let input = "1 2 3 "; let pattern = "1 3 "; let expected = "1 3 2 "; let actual = NormalizeToExpected::new() .unordered() .normalize(input.into_data(), &pattern.into_data()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_actual_duplicated() { let input = "1 2 2 3 "; let pattern = "1 2 3 "; let expected = "1 2 3 2 "; let actual = NormalizeToExpected::new() .unordered() .normalize(input.into_data(), &pattern.into_data()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_expected_duplicated() { let input = "1 2 3 "; let pattern = "1 2 2 3 "; let expected = "1 2 3 "; let actual = NormalizeToExpected::new() .unordered() .normalize(input.into_data(), &pattern.into_data()); assert_eq!(actual, expected.into_data()); } #[test] #[cfg(feature = "json")] fn json_normalize_empty() { let input = json!([]); let pattern = json!([]); let expected = json!([]); let actual = NormalizeToExpected::new() .unordered() .normalize(Data::json(input), &Data::json(pattern)); assert_eq!(actual, Data::json(expected)); } #[test] #[cfg(feature = "json")] fn json_normalize_same_order() { let input = json!([1, 2, 3]); let pattern = json!([1, 2, 3]); let expected = json!([1, 2, 3]); let actual = NormalizeToExpected::new() .unordered() .normalize(Data::json(input), &Data::json(pattern)); assert_eq!(actual, Data::json(expected)); } #[test] #[cfg(feature = "json")] fn json_normalize_reverse_order() { let input = json!([1, 2, 3]); let pattern = json!([3, 2, 1]); let expected = json!([3, 2, 1]); let actual = NormalizeToExpected::new() .unordered() .normalize(Data::json(input), &Data::json(pattern)); assert_eq!(actual, Data::json(expected)); } #[test] #[cfg(feature = "json")] fn json_normalize_actual_missing() { let input = json!([1, 3]); let pattern = json!([1, 2, 3]); let expected = json!([1, 3]); let actual = NormalizeToExpected::new() .unordered() .normalize(Data::json(input), &Data::json(pattern)); assert_eq!(actual, Data::json(expected)); } #[test] #[cfg(feature = "json")] fn json_normalize_expected_missing() { let input = json!([1, 2, 3]); let pattern = json!([1, 3]); let expected = json!([1, 3, 2]); let actual = NormalizeToExpected::new() .unordered() .normalize(Data::json(input), &Data::json(pattern)); assert_eq!(actual, Data::json(expected)); } #[test] #[cfg(feature = "json")] fn json_normalize_actual_duplicated() { let input = json!([1, 2, 2, 3]); let pattern = json!([1, 2, 3]); let expected = json!([1, 2, 3, 2]); let actual = NormalizeToExpected::new() .unordered() .normalize(Data::json(input), &Data::json(pattern)); assert_eq!(actual, Data::json(expected)); } #[test] #[cfg(feature = "json")] fn json_normalize_expected_duplicated() { let input = json!([1, 2, 3]); let pattern = json!([1, 2, 2, 3]); let expected = json!([1, 2, 3]); let actual = NormalizeToExpected::new() .unordered() .normalize(Data::json(input), &Data::json(pattern)); assert_eq!(actual, Data::json(expected)); } snapbox-0.6.21/src/filter/test_unordered_redactions.rs000064400000000000000000000356031046102023000212560ustar 00000000000000use std::path::PathBuf; #[cfg(feature = "json")] use serde_json::json; use super::*; use crate::prelude::*; #[test] fn str_normalize_empty() { let input = ""; let pattern = ""; let expected = ""; let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(input.into_data(), &pattern.into_data()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_same_order() { let input = "1 2 3 "; let pattern = "1 2 3 "; let expected = "1 2 3 "; let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(input.into_data(), &pattern.into_data()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_reverse_order() { let input = "1 2 3 "; let pattern = "3 2 1 "; let expected = "3 2 1 "; let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(input.into_data(), &pattern.into_data()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_actual_missing() { let input = "1 3 "; let pattern = "1 2 3 "; let expected = "1 3 "; let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(input.into_data(), &pattern.into_data()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_expected_missing() { let input = "1 2 3 "; let pattern = "1 3 "; let expected = "1 3 2 "; let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(input.into_data(), &pattern.into_data()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_actual_duplicated() { let input = "1 2 2 3 "; let pattern = "1 2 3 "; let expected = "1 2 3 2 "; let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(input.into_data(), &pattern.into_data()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_expected_duplicated() { let input = "1 2 3 "; let pattern = "1 2 2 3 "; let expected = "1 2 3 "; let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(input.into_data(), &pattern.into_data()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_elide_delimited_with_sub() { let input = "Hello World\nHow are you?\nGoodbye World"; let pattern = "Hello [..]\n...\nGoodbye [..]"; let expected = "Hello [..]\n...\nGoodbye [..]"; let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_leading_elide() { let input = "Hello\nWorld\nGoodbye"; let pattern = "...\nGoodbye"; let expected = "...\nGoodbye"; let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_trailing_elide() { let input = "Hello\nWorld\nGoodbye"; let pattern = "Hello\n..."; let expected = "Hello\n..."; let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_middle_elide() { let input = "Hello\nWorld\nGoodbye"; let pattern = "Hello\n...\nGoodbye"; let expected = "Hello\n...\nGoodbye"; let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_post_elide_diverge() { let input = "Hello\nSun\nAnd\nWorld"; let pattern = "Hello\n...\nMoon"; let expected = "Hello\n...\n"; let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_post_diverge_elide() { let input = "Hello\nWorld\nGoodbye\nSir"; let pattern = "Hello\nMoon\nGoodbye\n..."; let expected = "Hello\nGoodbye\n..."; let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_inline_elide() { let input = "Hello\nWorld\nGoodbye\nSir"; let pattern = "Hello\nW[..]d\nGoodbye\nSir"; let expected = "Hello\nW[..]d\nGoodbye\nSir"; let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(input.into(), &pattern.into()); assert_eq!(actual, expected.into_data()); } #[test] fn str_normalize_user_literal() { let input = "Hello world!"; let pattern = "Hello [OBJECT]!"; let mut sub = Redactions::new(); sub.insert("[OBJECT]", "world").unwrap(); let actual = NormalizeToExpected::new() .redact_with(&sub) .unordered() .normalize(input.into(), &pattern.into()); assert_eq!(actual, pattern.into_data()); } #[test] fn str_normalize_user_path() { let input = "input: /home/epage"; let pattern = "input: [HOME]"; let mut sub = Redactions::new(); let sep = std::path::MAIN_SEPARATOR.to_string(); let redacted = PathBuf::from(sep).join("home").join("epage"); sub.insert("[HOME]", redacted).unwrap(); let actual = NormalizeToExpected::new() .redact_with(&sub) .unordered() .normalize(input.into(), &pattern.into()); assert_eq!(actual, pattern.into_data()); } #[test] fn str_normalize_user_overlapping_path() { let input = "\ a: /home/epage b: /home/epage/snapbox"; let pattern = "\ a: [A] b: [B]"; let mut sub = Redactions::new(); let sep = std::path::MAIN_SEPARATOR.to_string(); let redacted = PathBuf::from(&sep).join("home").join("epage"); sub.insert("[A]", redacted).unwrap(); let redacted = PathBuf::from(sep) .join("home") .join("epage") .join("snapbox"); sub.insert("[B]", redacted).unwrap(); let actual = NormalizeToExpected::new() .redact_with(&sub) .unordered() .normalize(input.into(), &pattern.into()); assert_eq!(actual, pattern.into_data()); } #[test] fn str_normalize_user_disabled() { let input = "cargo"; let pattern = "cargo[EXE]"; let mut sub = Redactions::new(); sub.insert("[EXE]", "").unwrap(); let actual = NormalizeToExpected::new() .redact_with(&sub) .unordered() .normalize(input.into(), &pattern.into()); assert_eq!(actual, pattern.into_data()); } #[test] #[cfg(feature = "json")] fn json_normalize_empty() { let input = json!([]); let pattern = json!([]); let expected = json!([]); let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(Data::json(input), &Data::json(pattern)); assert_eq!(actual, Data::json(expected)); } #[test] #[cfg(feature = "json")] fn json_normalize_same_order() { let input = json!([1, 2, 3]); let pattern = json!([1, 2, 3]); let expected = json!([1, 2, 3]); let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(Data::json(input), &Data::json(pattern)); assert_eq!(actual, Data::json(expected)); } #[test] #[cfg(feature = "json")] fn json_normalize_reverse_order() { let input = json!([1, 2, 3]); let pattern = json!([3, 2, 1]); let expected = json!([3, 2, 1]); let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(Data::json(input), &Data::json(pattern)); assert_eq!(actual, Data::json(expected)); } #[test] #[cfg(feature = "json")] fn json_normalize_actual_missing() { let input = json!([1, 3]); let pattern = json!([1, 2, 3]); let expected = json!([1, 3]); let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(Data::json(input), &Data::json(pattern)); assert_eq!(actual, Data::json(expected)); } #[test] #[cfg(feature = "json")] fn json_normalize_expected_missing() { let input = json!([1, 2, 3]); let pattern = json!([1, 3]); let expected = json!([1, 3, 2]); let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(Data::json(input), &Data::json(pattern)); assert_eq!(actual, Data::json(expected)); } #[test] #[cfg(feature = "json")] fn json_normalize_actual_duplicated() { let input = json!([1, 2, 2, 3]); let pattern = json!([1, 2, 3]); let expected = json!([1, 2, 3, 2]); let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(Data::json(input), &Data::json(pattern)); assert_eq!(actual, Data::json(expected)); } #[test] #[cfg(feature = "json")] fn json_normalize_expected_duplicated() { let input = json!([1, 2, 3]); let pattern = json!([1, 2, 2, 3]); let expected = json!([1, 2, 3]); let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(Data::json(input), &Data::json(pattern)); assert_eq!(actual, Data::json(expected)); } #[test] #[cfg(feature = "json")] fn json_normalize_glob_for_string() { let exp = json!({"name": "{...}"}); let expected = Data::json(exp); let actual = json!({"name": "JohnDoe"}); let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(Data::json(actual), &expected); if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { assert_eq!(exp, act); } } #[test] #[cfg(feature = "json")] fn json_normalize_glob_for_array() { let exp = json!({"people": "{...}"}); let expected = Data::json(exp); let actual = json!({ "people": [ { "name": "JohnDoe", "nickname": "John", } ] }); let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(Data::json(actual), &expected); if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { assert_eq!(exp, act); } } #[test] #[cfg(feature = "json")] fn json_normalize_glob_for_obj() { let exp = json!({"people": "{...}"}); let expected = Data::json(exp); let actual = json!({ "people": { "name": "JohnDoe", "nickname": "John", } }); let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(Data::json(actual), &expected); if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { assert_eq!(exp, act); } } #[test] #[cfg(feature = "json")] fn json_normalize_glob_array_start() { let exp = json!({ "people": [ "{...}", { "name": "three", "nickname": "3", } ] }); let expected = Data::json(exp); let actual = json!({ "people": [ { "name": "one", "nickname": "1", }, { "name": "two", "nickname": "2", }, { "name": "three", "nickname": "3", } ] }); let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(Data::json(actual), &expected); if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { assert_eq!(exp, act); } } #[test] #[cfg(feature = "json")] fn json_normalize_glob_for_array_mismatch() { let exp = json!([ { "name": "one", "nickname": "1", }, { "name": "three", "nickname": "3", }, "{...}" ]); let expected = Data::json(exp); let actual = json!([ { "name": "one", "nickname": "1", }, { "name": "two", "nickname": "2", }, { "name": "four", "nickname": "4", }, { "name": "five", "nickname": "5", } ]); let expected_actual = json!([ { "name": "one", "nickname": "1", }, "{...}" ]); let actual_normalized = NormalizeToExpected::new() .redact() .unordered() .normalize(Data::json(actual.clone()), &expected); if let DataInner::Json(act) = actual_normalized.inner { assert_eq!(act, expected_actual); } } #[test] #[cfg(feature = "json")] fn json_normalize_obj_key() { let expected = json!({ "[A]": "value-a", "[B]": "value-b", "[C]": "value-c", }); let expected = Data::json(expected); let actual = json!({ "key-a": "value-a", "key-b": "value-b", "key-c": "value-c", }); let actual = Data::json(actual); let mut sub = Redactions::new(); sub.insert("[A]", "key-a").unwrap(); sub.insert("[B]", "key-b").unwrap(); sub.insert("[C]", "key-c").unwrap(); let actual = NormalizeToExpected::new() .redact_with(&sub) .unordered() .normalize(actual, &expected); let expected_actual = json!({ "[A]": "value-a", "[B]": "value-b", "[C]": "value-c", }); let expected_actual = Data::json(expected_actual); assert_eq!(actual, expected_actual); } #[test] #[cfg(feature = "json")] fn json_normalize_with_missing_obj_key() { let expected = json!({ "a": "[A]", "b": "[B]", "c": "[C]", }); let expected = Data::json(expected); let actual = json!({ "a": "value-a", "c": "value-c", }); let actual = Data::json(actual); let mut sub = Redactions::new(); sub.insert("[A]", "value-a").unwrap(); sub.insert("[B]", "value-b").unwrap(); sub.insert("[C]", "value-c").unwrap(); let actual = NormalizeToExpected::new() .redact_with(&sub) .unordered() .normalize(actual, &expected); let expected_actual = json!({ "a": "[A]", "c": "[C]", }); let expected_actual = Data::json(expected_actual); assert_eq!(actual, expected_actual); } #[test] #[cfg(feature = "json")] fn json_normalize_glob_obj_key() { let expected = json!({ "a": "value-a", "c": "value-c", "...": "{...}", }); let expected = Data::json(expected); let actual = json!({ "a": "value-a", "b": "value-b", "c": "value-c", }); let actual = Data::json(actual); let actual = NormalizeToExpected::new() .redact() .unordered() .normalize(actual, &expected); let expected_actual = json!({ "a": "value-a", "c": "value-c", "...": "{...}", }); let expected_actual = Data::json(expected_actual); assert_eq!(actual, expected_actual); } snapbox-0.6.21/src/lib.rs000064400000000000000000000105561046102023000132760ustar 00000000000000//! # Snapshot testing toolbox //! //! > When you have to treat your tests like pets, instead of [cattle][trycmd] //! //! `snapbox` is a snapshot-testing toolbox that is ready to use for verifying output from //! - Function return values //! - CLI stdout/stderr //! - Filesystem changes //! //! It is also flexible enough to build your own test harness like [trycmd](https://crates.io/crates/trycmd). //! //! ## Which tool is right //! //! - [cram](https://bitheap.org/cram/): End-to-end CLI snapshotting agnostic of any programming language //! - See also [scrut](https://github.com/facebookincubator/scrut) //! - [trycmd](https://crates.io/crates/trycmd): For running a lot of blunt tests (limited test predicates) //! - Particular attention is given to allow the test data to be pulled into documentation, like //! with [mdbook](https://rust-lang.github.io/mdBook/) //! - [tryfn](https://crates.io/crates/tryfn): For running a lot of simple input/output tests //! - `snapbox`: When you want something like `trycmd` in one off //! cases or you need to customize `trycmd`s behavior. //! - [assert_cmd](https://crates.io/crates/assert_cmd) + //! [assert_fs](https://crates.io/crates/assert_fs): Test cases follow a certain pattern but //! special attention is needed in how to verify the results. //! - Hand-written test cases: for peculiar circumstances //! //! ## Getting Started //! //! Testing Functions: //! - [`assert_data_eq!`] for quick and dirty snapshotting //! //! Testing Commands: //! - [`cmd::Command`]: Process spawning for testing of non-interactive commands //! - [`cmd::OutputAssert`]: Assert the state of a [`Command`][cmd::Command]'s //! [`Output`][std::process::Output]. //! //! Testing Filesystem Interactions: //! - [`dir::DirRoot`]: Working directory for tests //! - [`Assert`]: Diff a directory against files present in a pattern directory //! //! You can also build your own version of these with the lower-level building blocks these are //! made of. //! #![cfg_attr(feature = "document-features", doc = document_features::document_features!())] //! //! # Examples //! //! [`assert_data_eq!`] //! ```rust //! snapbox::assert_data_eq!("Hello many people!", "Hello [..] people!"); //! ``` //! //! [`Assert`] //! ```rust,no_run //! let actual = "..."; //! snapbox::Assert::new() //! .action_env("SNAPSHOTS") //! .eq(actual, snapbox::file!["help_output_is_clean.txt"]); //! ``` //! //! [trycmd]: https://docs.rs/trycmd #![cfg_attr(docsrs, feature(doc_auto_cfg))] #![warn(clippy::print_stderr)] #![warn(clippy::print_stdout)] mod macros; pub mod assert; pub mod cmd; pub mod data; pub mod dir; pub mod filter; pub mod report; pub mod utils; pub use assert::Assert; pub use data::Data; pub use data::IntoData; #[cfg(feature = "json")] pub use data::IntoJson; pub use data::ToDebug; pub use filter::RedactedValue; pub use filter::Redactions; #[doc(hidden)] pub use snapbox_macros::debug; /// Easier access to common traits pub mod prelude { pub use crate::IntoData; #[cfg(feature = "json")] pub use crate::IntoJson; pub use crate::ToDebug; } /// Check if a path matches the content of another path, recursively /// /// When the content is text, newlines are normalized. /// /// ```rust,no_run /// let output_root = "..."; /// let expected_root = "tests/snapshots/output.txt"; /// snapbox::assert_subset_eq(expected_root, output_root); /// ``` #[cfg(feature = "dir")] #[track_caller] pub fn assert_subset_eq( expected_root: impl Into, actual_root: impl Into, ) { Assert::new() .action_env(assert::DEFAULT_ACTION_ENV) .subset_eq(expected_root, actual_root); } /// Check if a path matches the pattern of another path, recursively /// /// Pattern syntax: /// - `...` is a line-wildcard when on a line by itself /// - `[..]` is a character-wildcard when inside a line /// - `[EXE]` matches `.exe` on Windows /// /// Normalization: /// - Newlines /// - `\` to `/` /// /// ```rust,no_run /// let output_root = "..."; /// let expected_root = "tests/snapshots/output.txt"; /// snapbox::assert_subset_matches(expected_root, output_root); /// ``` #[cfg(feature = "dir")] #[track_caller] pub fn assert_subset_matches( pattern_root: impl Into, actual_root: impl Into, ) { Assert::new() .action_env(assert::DEFAULT_ACTION_ENV) .subset_matches(pattern_root, actual_root); } snapbox-0.6.21/src/macros.rs000064400000000000000000000071721046102023000140140ustar 00000000000000/// Check if a value is the same as an expected value /// /// By default [`filters`][crate::filter] are applied, including: /// - `...` is a line-wildcard when on a line by itself /// - `[..]` is a character-wildcard when inside a line /// - `[EXE]` matches `.exe` on Windows /// - `"{...}"` is a JSON value wildcard /// - `"...": "{...}"` is a JSON key-value wildcard /// - `\` to `/` /// - Newlines /// /// To limit this to newline normalization for text, call [`Data::raw`][crate::Data] on `expected`. /// /// # Effective signature /// /// ```rust /// # use snapbox::IntoData; /// fn assert_data_eq(actual: impl IntoData, expected: impl IntoData) { /// // ... /// } /// ``` /// /// # Examples /// /// ```rust /// # use snapbox::assert_data_eq; /// let output = "something"; /// let expected = "so[..]g"; /// assert_data_eq!(output, expected); /// ``` /// /// Can combine this with [`file!`] /// ```rust,no_run /// # use snapbox::assert_data_eq; /// # use snapbox::file; /// let actual = "something"; /// assert_data_eq!(actual, file!["output.txt"]); /// ``` #[macro_export] macro_rules! assert_data_eq { ($actual: expr, $expected: expr $(,)?) => {{ let actual = $crate::IntoData::into_data($actual); let expected = $crate::IntoData::into_data($expected); $crate::Assert::new() .action_env($crate::assert::DEFAULT_ACTION_ENV) .eq(actual, expected); }}; } /// Find the directory for your source file #[doc(hidden)] // forced to be visible in intended location #[macro_export] macro_rules! current_dir { () => {{ let root = $crate::utils::cargo_rustc_current_dir!(); let file = ::std::file!(); let rel_path = ::std::path::Path::new(file).parent().unwrap(); root.join(rel_path) }}; } /// Find the directory for your source file #[doc(hidden)] // forced to be visible in intended location #[macro_export] macro_rules! current_rs { () => {{ let root = $crate::utils::cargo_rustc_current_dir!(); let file = ::std::file!(); let rel_path = ::std::path::Path::new(file); root.join(rel_path) }}; } /// Find the base directory for [`std::file!`] #[doc(hidden)] // forced to be visible in intended location #[macro_export] macro_rules! cargo_rustc_current_dir { () => {{ if let Some(rustc_root) = ::std::option_env!("CARGO_RUSTC_CURRENT_DIR") { ::std::path::Path::new(rustc_root) } else { let manifest_dir = ::std::path::Path::new(::std::env!("CARGO_MANIFEST_DIR")); manifest_dir .ancestors() .filter(|it| it.join("Cargo.toml").exists()) .last() .unwrap() } }}; } /// Path to the current function /// /// Closures are ignored #[doc(hidden)] #[macro_export] macro_rules! fn_path { () => {{ fn f() {} fn type_name_of_val(_: T) -> &'static str { std::any::type_name::() } let mut name = type_name_of_val(f).strip_suffix("::f").unwrap_or(""); while let Some(rest) = name.strip_suffix("::{{closure}}") { name = rest; } name }}; } #[cfg(test)] mod test { #[test] fn direct_fn_path() { assert_eq!(fn_path!(), "snapbox::macros::test::direct_fn_path"); } #[test] #[allow(clippy::redundant_closure_call)] fn closure_fn_path() { (|| { assert_eq!(fn_path!(), "snapbox::macros::test::closure_fn_path"); })(); } #[test] fn nested_fn_path() { fn nested() { assert_eq!(fn_path!(), "snapbox::macros::test::nested_fn_path::nested"); } nested(); } } snapbox-0.6.21/src/report/color.rs000064400000000000000000000041311046102023000151510ustar 00000000000000#[derive(Copy, Clone, Debug, Default)] pub struct Palette { pub(crate) info: Style, pub(crate) warn: Style, pub(crate) error: Style, pub(crate) hint: Style, pub(crate) expected: Style, pub(crate) actual: Style, } impl Palette { pub fn color() -> Self { if cfg!(feature = "color") { Self { info: anstyle::AnsiColor::Green.on_default(), warn: anstyle::AnsiColor::Yellow.on_default(), error: anstyle::AnsiColor::Red.on_default(), hint: anstyle::Effects::DIMMED.into(), expected: anstyle::AnsiColor::Red.on_default() | anstyle::Effects::UNDERLINE, actual: anstyle::AnsiColor::Green.on_default() | anstyle::Effects::UNDERLINE, } } else { Self::plain() } } pub fn plain() -> Self { Self::default() } pub fn info(self, item: D) -> Styled { Styled::new(item, self.info) } pub fn warn(self, item: D) -> Styled { Styled::new(item, self.warn) } pub fn error(self, item: D) -> Styled { Styled::new(item, self.error) } pub fn hint(self, item: D) -> Styled { Styled::new(item, self.hint) } pub fn expected(self, item: D) -> Styled { Styled::new(item, self.expected) } pub fn actual(self, item: D) -> Styled { Styled::new(item, self.actual) } } pub(crate) use anstyle::Style; #[derive(Debug)] pub struct Styled { display: D, style: Style, } impl Styled { pub(crate) fn new(display: D, style: Style) -> Self { Self { display, style } } } impl std::fmt::Display for Styled { #[inline] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.style.render())?; self.display.fmt(f)?; write!(f, "{}", self.style.render_reset())?; Ok(()) } } snapbox-0.6.21/src/report/diff.rs000064400000000000000000000327361046102023000147570ustar 00000000000000use crate::report::Styled; pub fn write_diff( writer: &mut dyn std::fmt::Write, expected: &crate::Data, actual: &crate::Data, expected_name: Option<&dyn std::fmt::Display>, actual_name: Option<&dyn std::fmt::Display>, palette: crate::report::Palette, ) -> Result<(), std::fmt::Error> { #[allow(unused_mut)] let mut rendered = false; #[cfg(feature = "diff")] if let (Some(expected_relevant), Some(actual_relevant)) = (expected.relevant(), actual.relevant()) { let expected_rendered = expected.render().unwrap(); let expected_line_offset = expected_rendered[..expected_rendered .find(expected_relevant) .unwrap_or(expected_rendered.len())] .lines() .count(); let actual_rendered = actual.render().unwrap(); let actual_line_offset = actual_rendered[..actual_rendered .find(actual_relevant) .unwrap_or(actual_rendered.len())] .lines() .count(); write_diff_inner( writer, expected_relevant, actual_relevant, expected_name, actual_name, palette, expected_line_offset, actual_line_offset, )?; rendered = true; } else if let (Some(expected), Some(actual)) = (expected.render(), actual.render()) { let expected_line_offset = 0; let actual_line_offset = 0; write_diff_inner( writer, &expected, &actual, expected_name, actual_name, palette, expected_line_offset, actual_line_offset, )?; rendered = true; } if !rendered { if let Some(expected_name) = expected_name { writeln!(writer, "{} {}:", expected_name, palette.error("(expected)"))?; } else { writeln!(writer, "{}:", palette.error("Expected"))?; } writeln!(writer, "{}", palette.error(&expected))?; if let Some(actual_name) = actual_name { writeln!(writer, "{} {}:", actual_name, palette.info("(actual)"))?; } else { writeln!(writer, "{}:", palette.info("Actual"))?; } writeln!(writer, "{}", palette.info(&actual))?; } Ok(()) } #[cfg(feature = "diff")] #[allow(clippy::too_many_arguments)] fn write_diff_inner( writer: &mut dyn std::fmt::Write, expected: &str, actual: &str, expected_name: Option<&dyn std::fmt::Display>, actual_name: Option<&dyn std::fmt::Display>, palette: crate::report::Palette, expected_line_offset: usize, actual_line_offset: usize, ) -> Result<(), std::fmt::Error> { let timeout = std::time::Duration::from_millis(500); let min_elide = 20; let context = 5; let changes = similar::TextDiff::configure() .algorithm(similar::Algorithm::Patience) .timeout(timeout) .newline_terminated(false) .diff_lines(expected, actual); writeln!(writer)?; if let Some(expected_name) = expected_name { writeln!( writer, "{}", palette.error(format_args!("{:->4} expected: {}", "", expected_name)) )?; } else { writeln!(writer, "{}", palette.error(format_args!("--- Expected")))?; } if let Some(actual_name) = actual_name { writeln!( writer, "{}", palette.info(format_args!("{:+>4} actual: {}", "", actual_name)) )?; } else { writeln!(writer, "{}", palette.info(format_args!("+++ Actual")))?; } let changes = changes .ops() .iter() .flat_map(|op| changes.iter_inline_changes(op)) .collect::>(); let tombstones = if min_elide < changes.len() { let mut tombstones = vec![true; changes.len()]; let mut counter = context; for (i, change) in changes.iter().enumerate() { match change.tag() { similar::ChangeTag::Insert | similar::ChangeTag::Delete => { counter = context; tombstones[i] = false; } similar::ChangeTag::Equal => { if counter != 0 { tombstones[i] = false; counter -= 1; } } } } let mut counter = context; for (i, change) in changes.iter().enumerate().rev() { match change.tag() { similar::ChangeTag::Insert | similar::ChangeTag::Delete => { counter = context; tombstones[i] = false; } similar::ChangeTag::Equal => { if counter != 0 { tombstones[i] = false; counter -= 1; } } } } tombstones } else { Vec::new() }; let mut elided = false; for (i, change) in changes.into_iter().enumerate() { if tombstones.get(i).copied().unwrap_or(false) { if !elided { let sign = "⋮"; write!(writer, "{:>4} ", " ",)?; write!(writer, "{:>4} ", " ",)?; writeln!(writer, "{}", palette.hint(sign))?; } elided = true; } else { elided = false; match change.tag() { similar::ChangeTag::Insert => { write_change( writer, change, "+", palette.actual, palette.info, palette, expected_line_offset, actual_line_offset, )?; } similar::ChangeTag::Delete => { write_change( writer, change, "-", palette.expected, palette.error, palette, expected_line_offset, actual_line_offset, )?; } similar::ChangeTag::Equal => { write_change( writer, change, "|", palette.hint, palette.hint, palette, expected_line_offset, actual_line_offset, )?; } } } } Ok(()) } #[cfg(feature = "diff")] #[allow(clippy::too_many_arguments)] fn write_change( writer: &mut dyn std::fmt::Write, change: similar::InlineChange<'_, str>, sign: &str, em_style: crate::report::Style, style: crate::report::Style, palette: crate::report::Palette, expected_line_offset: usize, actual_line_offset: usize, ) -> Result<(), std::fmt::Error> { if let Some(index) = change.old_index() { write!( writer, "{:>4} ", palette.hint(index + 1 + expected_line_offset), )?; } else { write!(writer, "{:>4} ", " ",)?; } if let Some(index) = change.new_index() { write!( writer, "{:>4} ", palette.hint(index + 1 + actual_line_offset), )?; } else { write!(writer, "{:>4} ", " ",)?; } write!(writer, "{} ", Styled::new(sign, style))?; for &(emphasized, change) in change.values() { let cur_style = if emphasized { em_style } else { style }; write!(writer, "{}", Styled::new(change, cur_style))?; } if change.missing_newline() { writeln!(writer, "{}", Styled::new("∅", em_style))?; } Ok(()) } #[cfg(test)] mod test { use super::*; #[cfg(feature = "diff")] #[test] fn diff_eq() { let expected = "Hello\nWorld\n"; let expected_name = "A"; let actual = "Hello\nWorld\n"; let actual_name = "B"; let palette = crate::report::Palette::plain(); let mut actual_diff = String::new(); write_diff_inner( &mut actual_diff, expected, actual, Some(&expected_name), Some(&actual_name), palette, 0, 0, ) .unwrap(); let expected_diff = " ---- expected: A ++++ actual: B 1 1 | Hello 2 2 | World "; assert_eq!(expected_diff, actual_diff); } #[cfg(feature = "diff")] #[test] fn diff_ne_line_missing() { let expected = "Hello\nWorld\n"; let expected_name = "A"; let actual = "Hello\n"; let actual_name = "B"; let palette = crate::report::Palette::plain(); let mut actual_diff = String::new(); write_diff_inner( &mut actual_diff, expected, actual, Some(&expected_name), Some(&actual_name), palette, 0, 0, ) .unwrap(); let expected_diff = " ---- expected: A ++++ actual: B 1 1 | Hello 2 - World "; assert_eq!(expected_diff, actual_diff); } #[cfg(feature = "diff")] #[test] fn diff_eq_trailing_extra_newline() { let expected = "Hello\nWorld"; let expected_name = "A"; let actual = "Hello\nWorld\n"; let actual_name = "B"; let palette = crate::report::Palette::plain(); let mut actual_diff = String::new(); write_diff_inner( &mut actual_diff, expected, actual, Some(&expected_name), Some(&actual_name), palette, 0, 0, ) .unwrap(); let expected_diff = " ---- expected: A ++++ actual: B 1 1 | Hello 2 - World∅ 2 + World "; assert_eq!(expected_diff, actual_diff); } #[cfg(feature = "diff")] #[test] fn diff_eq_trailing_newline_missing() { let expected = "Hello\nWorld\n"; let expected_name = "A"; let actual = "Hello\nWorld"; let actual_name = "B"; let palette = crate::report::Palette::plain(); let mut actual_diff = String::new(); write_diff_inner( &mut actual_diff, expected, actual, Some(&expected_name), Some(&actual_name), palette, 0, 0, ) .unwrap(); let expected_diff = " ---- expected: A ++++ actual: B 1 1 | Hello 2 - World 2 + World∅ "; assert_eq!(expected_diff, actual_diff); } #[cfg(feature = "diff")] #[test] fn diff_eq_elided() { let mut expected = String::new(); expected.push_str("Hello\n"); for i in 0..20 { expected.push_str(&i.to_string()); expected.push('\n'); } expected.push_str("World\n"); for i in 0..20 { expected.push_str(&i.to_string()); expected.push('\n'); } expected.push_str("!\n"); let expected_name = "A"; let mut actual = String::new(); actual.push_str("Goodbye\n"); for i in 0..20 { actual.push_str(&i.to_string()); actual.push('\n'); } actual.push_str("Moon\n"); for i in 0..20 { actual.push_str(&i.to_string()); actual.push('\n'); } actual.push_str("?\n"); let actual_name = "B"; let palette = crate::report::Palette::plain(); let mut actual_diff = String::new(); write_diff_inner( &mut actual_diff, &expected, &actual, Some(&expected_name), Some(&actual_name), palette, 0, 0, ) .unwrap(); let expected_diff = " ---- expected: A ++++ actual: B 1 - Hello 1 + Goodbye 2 2 | 0 3 3 | 1 4 4 | 2 5 5 | 3 6 6 | 4 ⋮ 17 17 | 15 18 18 | 16 19 19 | 17 20 20 | 18 21 21 | 19 22 - World 22 + Moon 23 23 | 0 24 24 | 1 25 25 | 2 26 26 | 3 27 27 | 4 ⋮ 38 38 | 15 39 39 | 16 40 40 | 17 41 41 | 18 42 42 | 19 43 - ! 43 + ? "; assert_eq!(expected_diff, actual_diff); } #[cfg(feature = "diff")] #[cfg(feature = "term-svg")] #[test] fn diff_ne_ignore_irrelevant_details() { let expected = " Hello Moon "; let expected_name = "A"; let actual = " Hello World "; let actual_name = "B"; let palette = crate::report::Palette::plain(); let mut actual_diff = String::new(); write_diff( &mut actual_diff, &crate::Data::with_inner(crate::data::DataInner::TermSvg(expected.to_owned())), &crate::Data::with_inner(crate::data::DataInner::TermSvg(actual.to_owned())), Some(&expected_name), Some(&actual_name), palette, ) .unwrap(); let expected_diff = " ---- expected: A ++++ actual: B 2 2 | 3 - Hello Moon 3 + Hello World 4 4 | "; assert_eq!(expected_diff, actual_diff); } } snapbox-0.6.21/src/report/mod.rs000064400000000000000000000002531046102023000146130ustar 00000000000000//! Utilities to report test results to users mod color; mod diff; pub use color::Palette; pub(crate) use color::Style; pub use color::Styled; pub use diff::write_diff; snapbox-0.6.21/src/utils/lines.rs000064400000000000000000000013721046102023000147760ustar 00000000000000#[derive(Clone, Debug)] pub struct LinesWithTerminator<'a> { data: &'a str, } impl<'a> LinesWithTerminator<'a> { pub fn new(data: &'a str) -> LinesWithTerminator<'a> { LinesWithTerminator { data } } } impl<'a> Iterator for LinesWithTerminator<'a> { type Item = &'a str; #[inline] fn next(&mut self) -> Option<&'a str> { match self.data.find('\n') { None if self.data.is_empty() => None, None => { let line = self.data; self.data = ""; Some(line) } Some(end) => { let line = &self.data[..end + 1]; self.data = &self.data[end + 1..]; Some(line) } } } } snapbox-0.6.21/src/utils/mod.rs000064400000000000000000000002751046102023000144440ustar 00000000000000mod lines; pub use lines::LinesWithTerminator; #[doc(inline)] pub use crate::cargo_rustc_current_dir; #[doc(inline)] pub use crate::current_dir; #[doc(inline)] pub use crate::current_rs;