diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ed98b87a..4d244c82f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,24 +68,29 @@ jobs: - uses: Swatinem/rust-cache@23bce251a8cd2ffc3c1075eaa2367cf899916d84 # v2 with: key: ${{ matrix.rust-version }} + - name: Install tools + uses: taiki-e/install-action@8484225d9734e230a8bf38421a4ffec1cc249372 # v2 + with: + tool: cargo-hack,just,nextest - name: Build - run: cargo build - - name: Install latest nextest release - uses: taiki-e/install-action@nextest - - name: Build datatest-stable - run: cargo build + run: just powerset build + - name: Build with all targets + run: just powerset build --all-targets - name: Run tests - run: cargo nextest run + run: just powerset nextest run - name: Run tests with cargo test - run: cargo test + run: just powerset test # Remove Cargo.lock to ensure that building with the latest versions works on stable. - name: Remove Cargo.lock and rebuild on stable if: matrix.rust-version == 'stable' run: rm Cargo.lock && cargo build + - name: Build with all targets + if: matrix.rust-version == 'stable' + run: just powerset build --all-targets - name: Run tests on stable if: matrix.rust-version == 'stable' - run: cargo nextest run + run: just powerset nextest run - name: Run tests with cargo test on stable if: matrix.rust-version == 'stable' - run: cargo test + run: just powerset test diff --git a/Cargo.lock b/Cargo.lock index 651d4e6b6..e95e23310 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -183,6 +183,7 @@ dependencies = [ "camino-tempfile", "fancy-regex", "fs_extra", + "include_dir", "libtest-mimic", "walkdir", ] @@ -241,6 +242,25 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "is-terminal" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 735ba5e6c..4b20468c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,9 +21,17 @@ crates-io = true docs-rs = true rust-version = true +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg=doc_cfg"] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(doc_cfg)'] } + [dependencies] camino = "1.1.9" fancy-regex = "0.14.0" +include_dir = { version = "0.7.4", optional = true } libtest-mimic = "0.8.1" walkdir = "2.5.0" @@ -38,3 +46,6 @@ harness = false [[test]] name = "run_example" harness = true + +[features] +include-dir = ["dep:include_dir"] diff --git a/Justfile b/Justfile index 1c2e1733e..34c95e6d7 100644 --- a/Justfile +++ b/Justfile @@ -2,6 +2,11 @@ help: just --list + +# Run `cargo hack --feature-powerset` with the given arguments. +powerset *args: + cargo hack --feature-powerset {{args}} + # Build docs for crates and direct dependencies rustdoc: @cargo tree --depth 1 -e normal --prefix none --workspace \ @@ -11,4 +16,4 @@ rustdoc: # Generate README.md files using `cargo-sync-rdme`. generate-readmes: - cargo sync-rdme --toolchain nightly + cargo sync-rdme --toolchain nightly --all-features diff --git a/README.md b/README.md index 8710330e0..cee6e9294 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,16 @@ `datatest-stable` is a test harness intended to write *file-driven* or *data-driven* tests, -where individual test cases are specified as files and not as code. +where individual test case fixtures are specified as files and not as code. Given: * a test `my_test` that accepts a path, and optionally the contents as input -* a directory to look for files in +* a directory to look for files (test fixtures) in * a pattern to match files on -`datatest-stable` will call the `my_test` function once per matching file in the directory. +`datatest-stable` will call the `my_test` function once per matching file in +the directory. Directory traversals are recursive. `datatest-stable` works with [cargo nextest](https://nexte.st/), and is part of the [nextest-rs organization](https://github.com/nextest-rs/) on GitHub. @@ -38,6 +39,7 @@ harness = false * `testfn` - The test function to be executed on each matching input. This function can be one of: + * `fn(&Path) -> datatest_stable::Result<()>` * `fn(&Utf8Path) -> datatest_stable::Result<()>` (`Utf8Path` is part of the [`camino`](https://docs.rs/camino) library, and is re-exported here for convenience.) @@ -49,14 +51,23 @@ harness = false in as a `Vec` (erroring out if that failed). * `root` - The path to the root directory where the input files (fixtures) live. This path is relative to the root of the crate (the directory where the crate’s `Cargo.toml` is located). + + `root` is an arbitrary expression that implements `Display`, such as `&str`, or a + function call that returns a `Utf8PathBuf`. + * `pattern` - a regex used to match against and select each file to be tested. Extended regexes with lookaround and backtracking are supported via the [`fancy_regex`](https://docs.rs/fancy-regex) crate. + + `pattern` is an arbitrary expression that implements `Display`, such as + `&str`, or a function call that returns a `String`. + +The passed-in `Path` and `Utf8Path` are **absolute** paths to the files to be tested. The three parameters can be repeated if you have multiple sets of data-driven tests to be run: `datatest_stable::harness!(testfn1, root1, pattern1, testfn2, root2, pattern2)`. -## Examples +### Examples This is an example test. Use it with `harness = false`. @@ -82,6 +93,92 @@ datatest_stable::harness!( ); ```` +### Embedding directories at compile time + +With the `include-dir` feature enabled, you can use the +[`include_dir`](https://docs.rs/include_dir) crate’s [`include_dir!`](https://docs.rs/include_dir_macros/0.7.4/include_dir_macros/macro.include_dir.html) macro. +This allows you to embed directories into the binary at compile time. + +This is generally not recommended for rapidly-changing test data, since each +change will force a rebuild. But it can be useful for relatively-unchanging +data suites distributed separately, e.g. on crates.io. + +With the `include-dir` feature enabled, you can use: + +````rust +// The `include_dir!` macro is re-exported for convenience. +use datatest_stable::include_dir; +use std::path::Path; + +fn my_test(path: &Path, contents: Vec) -> datatest_stable::Result<()> { + // ... write test here + Ok(()) +} + +datatest_stable::harness!( + my_test, include_dir!("tests/files"), r"^.*/*", +); +```` + +You can also use directories published as `static` items in upstream crates: + +````rust +use datatest_stable::{include_dir, Utf8Path}; + +// In the upstream crate: +pub static FIXTURES: include_dir::Dir<'static> = include_dir!("tests/files"); + +// In your test: +fn my_test(path: &Utf8Path, contents: String) -> datatest_stable::Result<()> { + // ... write test here + Ok(()) +} + +datatest_stable::harness!( + my_test, &FIXTURES, r"^.*/*", +); +```` + +In this case, the passed-in `Path` and `Utf8Path` are **relative** to the +root of the included directory. + +Because the files don’t exist on disk, the test functions must accept their +contents as either a `String` or a `Vec`. If the argument is not +provided, the harness will panic at runtime. + +### Conditionally embedding directories + +It is also possible to conditionally include directories at compile time via +a feature flag. For example, you might have an internal-only `testing` +feature that you turn on locally, but users don’t on crates.io. In that +case, you can use: + +````rust +use datatest_stable::Utf8Path; + +static FIXTURES: &str = "tests/files"; + +static FIXTURES: include_dir::Dir<'static> = datatest_stable::include_dir!("tests/files"); + +fn my_test(path: &Utf8Path, contents: String) -> datatest_stable::Result<()> { + // ... write test here + Ok(()) +} + +datatest_stable::harness!( + my_test, &FIXTURES, r"^.*/*", +); +```` + +In this case, note that `path` will be absolute if `FIXTURES` is a string, +and relative if `FIXTURES` is a `Dir`. Your test should be prepared to +handle either case. + +## Features + +* `include-dir`: Enables the `include_dir!` macro, which allows embedding + directories at compile time. This feature is disabled by default. + ## Minimum supported Rust version (MSRV) The minimum supported Rust version is **Rust 1.72**. MSRV bumps may be accompanied by a minor diff --git a/src/data_source.rs b/src/data_source.rs new file mode 100644 index 000000000..9c287193b --- /dev/null +++ b/src/data_source.rs @@ -0,0 +1,361 @@ +// Copyright (c) The datatest-stable Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use camino::{Utf8Path, Utf8PathBuf}; + +#[derive(Debug)] +#[doc(hidden)] +pub enum DataSource { + Directory(Utf8PathBuf), + #[cfg(feature = "include-dir")] + IncludeDir(std::borrow::Cow<'static, include_dir::Dir<'static>>), +} + +impl DataSource { + /// Iterates over all files in the data source. + /// + /// This returns entries that have just been discovered, so they're expected + /// to exist. + pub(crate) fn walk_files(&self) -> Box> + '_> { + match self { + DataSource::Directory(path) => Box::new(iter_directory(path)), + #[cfg(feature = "include-dir")] + DataSource::IncludeDir(dir) => Box::new(iter_include_dir(dir)), + } + } + + /// Finds a test path from the filter provided. + /// + /// The path might or might not exist -- the caller should call `.exists()` + /// to ensure it does. + /// + /// Used for `--exact` matches. + pub(crate) fn derive_exact(&self, filter: &str, test_name: &str) -> Option { + let rel_path = filter.strip_prefix(test_name)?.strip_prefix("::")?; + match self { + DataSource::Directory(path) => Some(TestEntry { + source: TestSource::Path(path.join(rel_path)), + rel_path: rel_path.into(), + }), + #[cfg(feature = "include-dir")] + DataSource::IncludeDir(dir) => { + let file = dir.get_file(rel_path)?; + Some(TestEntry { + source: TestSource::IncludeDir(file), + rel_path: rel_path.into(), + }) + } + } + } + + /// Returns true if data is not available on disk and must be provided from + /// an in-memory buffer. + pub(crate) fn is_in_memory(&self) -> bool { + match self { + DataSource::Directory(_) => false, + #[cfg(feature = "include-dir")] + DataSource::IncludeDir(_) => true, + } + } + + pub(crate) fn display(&self) -> String { + match self { + DataSource::Directory(path) => format!("directory: `{path}`"), + #[cfg(feature = "include-dir")] + DataSource::IncludeDir(_) => "included directory".to_string(), + } + } +} + +fn iter_directory(root: &Utf8Path) -> impl Iterator> + '_ { + walkdir::WalkDir::new(root) + .into_iter() + .filter(|res| { + // Continue to bubble up all errors to the parent. + res.as_ref().map_or(true, |entry| { + entry.file_type().is_file() + && entry + .file_name() + .to_str() + .map_or(false, |s| !s.starts_with('.')) // Skip hidden files + }) + }) + .map(move |res| match res { + Ok(entry) => { + let path = Utf8PathBuf::try_from(entry.into_path()) + .map_err(|error| error.into_io_error())?; + Ok(TestEntry::from_full_path(root, path)) + } + Err(error) => Err(error.into()), + }) +} + +#[cfg(feature = "include-dir")] +fn iter_include_dir<'a>( + dir: &'a include_dir::Dir<'static>, +) -> impl Iterator> + 'a { + // Need to maintain a stack to do a depth-first traversal. + struct IncludeDirIter<'a> { + stack: Vec<&'a include_dir::DirEntry<'a>>, + } + + impl<'a> Iterator for IncludeDirIter<'a> { + type Item = &'a include_dir::File<'a>; + + fn next(&mut self) -> Option { + while let Some(entry) = self.stack.pop() { + match entry { + include_dir::DirEntry::File(file) => { + return Some(file); + } + include_dir::DirEntry::Dir(dir) => { + self.stack.extend(dir.entries()); + } + } + } + + None + } + } + + IncludeDirIter { + stack: dir.entries().iter().collect(), + } + .map(|file| { + let rel_path = Utf8PathBuf::try_from(file.path().to_path_buf()) + .map_err(|error| error.into_io_error())?; + Ok(TestEntry { + source: TestSource::IncludeDir(file), + rel_path, + }) + }) +} + +#[derive(Debug)] +pub(crate) struct TestEntry { + source: TestSource, + rel_path: Utf8PathBuf, +} + +impl TestEntry { + pub(crate) fn from_full_path(root: &Utf8Path, path: Utf8PathBuf) -> Self { + let rel_path = path + .strip_prefix(root) + .unwrap_or_else(|_| panic!("failed to strip root '{}' from path '{}'", root, path)) + .to_owned(); + Self { + source: TestSource::Path(path), + rel_path, + } + } + + pub(crate) fn derive_test_name(&self, test_name: &str) -> String { + format!("{}::{}", test_name, self.rel_path) + } + + pub(crate) fn read(&self) -> crate::Result> { + match &self.source { + TestSource::Path(path) => std::fs::read(path) + .map_err(|err| format!("error reading file '{path}': {err}").into()), + #[cfg(feature = "include-dir")] + TestSource::IncludeDir(file) => Ok(file.contents().to_vec()), + } + } + + pub(crate) fn read_as_string(&self) -> crate::Result { + match &self.source { + TestSource::Path(path) => std::fs::read_to_string(path) + .map_err(|err| format!("error reading file '{path}' as UTF-8: {err}").into()), + #[cfg(feature = "include-dir")] + TestSource::IncludeDir(file) => { + let contents = file.contents().to_vec(); + String::from_utf8(contents).map_err(|err| { + format!( + "error reading included file at '{}' as UTF-8: {err}", + self.rel_path + ) + .into() + }) + } + } + } + + /// Returns the path to the test data. + /// + /// For directories on disk, this is the absolute path. For `include_dir` + /// sources, this is the path relative to the root of the include directory. + pub(crate) fn test_path(&self) -> &Utf8Path { + match &self.source { + TestSource::Path(path) => path, + #[cfg(feature = "include-dir")] + TestSource::IncludeDir(_) => { + // The UTF-8-encoded version of file.path is stored in `rel_path`. + &self.rel_path + } + } + } + + /// Returns the path to the file on disk. + /// + /// If the data source is an `include_dir`, this will return `None`. + pub(crate) fn disk_path(&self) -> Option<&Utf8Path> { + match &self.source { + TestSource::Path(path) => Some(path), + #[cfg(feature = "include-dir")] + TestSource::IncludeDir(_) => None, + } + } + + /// Returns true if the path exists. + pub(crate) fn exists(&self) -> bool { + match &self.source { + TestSource::Path(path) => path.exists(), + #[cfg(feature = "include-dir")] + TestSource::IncludeDir(_) => { + // include_dir files are guaranteed to exist. + true + } + } + } +} + +#[derive(Debug)] +#[doc(hidden)] +pub(crate) enum TestSource { + Path(Utf8PathBuf), + #[cfg(feature = "include-dir")] + IncludeDir(&'static include_dir::File<'static>), +} + +/// Polymorphic dispatch to resolve data sources +/// +/// This is similar to how `test_kinds` works. Here, we're assuming that +/// `include_dir::Dir` will never implement `ToString`. This isn't provable to +/// the compiler directly, but is a reasonable assumption. +/// +/// This could use auto(de)ref specialization to be more semver-safe, but a +/// `Display` impl on `include_dir::Dir` is exceedingly unlikely by Rust +/// community standards, and meanwhile this produces better error messages. +#[doc(hidden)] +pub mod data_source_kinds { + use super::*; + + mod private { + pub trait AsDirectorySealed {} + #[cfg(feature = "include-dir")] + pub trait AsIncludeDirSealed {} + } + + // -- As directory --- + + pub trait AsDirectory: private::AsDirectorySealed { + fn resolve_data_source(self) -> DataSource; + } + + impl private::AsDirectorySealed for T {} + + impl AsDirectory for T { + fn resolve_data_source(self) -> DataSource { + DataSource::Directory(self.to_string().into()) + } + } + + #[cfg(feature = "include-dir")] + pub trait AsIncludeDir: private::AsIncludeDirSealed { + fn resolve_data_source(self) -> DataSource; + } + + #[cfg(feature = "include-dir")] + impl private::AsIncludeDirSealed for include_dir::Dir<'static> {} + + #[cfg(feature = "include-dir")] + impl AsIncludeDir for include_dir::Dir<'static> { + fn resolve_data_source(self) -> DataSource { + DataSource::IncludeDir(std::borrow::Cow::Owned(self)) + } + } + + #[cfg(feature = "include-dir")] + impl private::AsIncludeDirSealed for &'static include_dir::Dir<'static> {} + + #[cfg(feature = "include-dir")] + impl AsIncludeDir for &'static include_dir::Dir<'static> { + fn resolve_data_source(self) -> DataSource { + DataSource::IncludeDir(std::borrow::Cow::Borrowed(self)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_test_name() { + assert_eq!(derive_test_path("root".into(), "file", "test_name"), None); + } + + #[test] + fn missing_colons() { + assert_eq!( + derive_test_path("root".into(), "test_name", "test_name"), + None + ); + } + + #[test] + fn is_relative_to_root() { + assert_eq!( + derive_test_path("root".into(), "test_name::file", "test_name"), + Some("root/file".into()) + ); + assert_eq!( + derive_test_path("root2".into(), "test_name::file", "test_name"), + Some("root2/file".into()) + ); + } + + #[test] + fn nested_dirs() { + assert_eq!( + derive_test_path("root".into(), "test_name::dir/dir2/file", "test_name"), + Some("root/dir/dir2/file".into()) + ); + } + + #[test] + fn subsequent_module_separators_remain() { + assert_eq!( + derive_test_path("root".into(), "test_name::mod::file", "test_name"), + Some("root/mod::file".into()) + ); + } + + #[test] + fn inverse_of_derive_test_name() { + let root: Utf8PathBuf = "root".into(); + for (path, test_name) in [ + (root.join("foo/bar.txt"), "test_name"), + (root.join("foo::bar.txt"), "test_name"), + (root.join("foo/bar/baz"), "test_name"), + (root.join("foo"), "test_name::mod"), + (root.join("🦀"), "🚀::🚀"), + ] { + let derived_test_name = derive_test_name(&root, &path, test_name); + assert_eq!( + derive_test_path(&root, &derived_test_name, test_name), + Some(path) + ); + } + } + + fn derive_test_name(root: &Utf8Path, path: &Utf8Path, test_name: &str) -> String { + TestEntry::from_full_path(root, path.to_owned()).derive_test_name(test_name) + } + + fn derive_test_path(root: &Utf8Path, path: &str, test_name: &str) -> Option { + DataSource::Directory(root.to_owned()) + .derive_exact(path, test_name) + .map(|entry| entry.test_path().to_owned()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 4bddc274d..bb1f11e47 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,15 +4,16 @@ #![forbid(unsafe_code)] //! `datatest-stable` is a test harness intended to write *file-driven* or *data-driven* tests, -//! where individual test cases are specified as files and not as code. +//! where individual test case fixtures are specified as files and not as code. //! //! Given: //! //! * a test `my_test` that accepts a path, and optionally the contents as input -//! * a directory to look for files in +//! * a directory to look for files (test fixtures) in //! * a pattern to match files on //! -//! `datatest-stable` will call the `my_test` function once per matching file in the directory. +//! `datatest-stable` will call the `my_test` function once per matching file in +//! the directory. Directory traversals are recursive. //! //! `datatest-stable` works with [cargo nextest](https://nexte.st/), and is part of the [nextest-rs //! organization](https://github.com/nextest-rs/) on GitHub. @@ -41,16 +42,26 @@ //! * `fn(&P, Vec) -> datatest_stable::Result<()>` where `P` is `Path` or `Utf8Path`. If the //! extra `Vec` parameter is specified, the contents of the file will be loaded and passed //! in as a `Vec` (erroring out if that failed). +//! //! * `root` - The path to the root directory where the input files (fixtures) live. This path is //! relative to the root of the crate (the directory where the crate's `Cargo.toml` is located). +//! +//! `root` is an arbitrary expression that implements `Display`, such as `&str`, or a +//! function call that returns a `Utf8PathBuf`. +//! //! * `pattern` - a regex used to match against and select each file to be tested. Extended regexes //! with lookaround and backtracking are supported via the //! [`fancy_regex`](https://docs.rs/fancy-regex) crate. //! +//! `pattern` is an arbitrary expression that implements `Display`, such as +//! `&str`, or a function call that returns a `String`. +//! +//! The passed-in `Path` and `Utf8Path` are **absolute** paths to the files to be tested. +//! //! The three parameters can be repeated if you have multiple sets of data-driven tests to be run: //! `datatest_stable::harness!(testfn1, root1, pattern1, testfn2, root2, pattern2)`. //! -//! # Examples +//! ## Examples //! //! This is an example test. Use it with `harness = false`. //! @@ -76,6 +87,97 @@ //! ); //! ``` //! +//! ## Embedding directories at compile time +//! +//! With the `include-dir` feature enabled, you can use the +//! [`include_dir`](https://docs.rs/include_dir) crate's [`include_dir!`] macro. +//! This allows you to embed directories into the binary at compile time. +//! +//! This is generally not recommended for rapidly-changing test data, since each +//! change will force a rebuild. But it can be useful for relatively-unchanging +//! data suites distributed separately, e.g. on crates.io. +//! +//! With the `include-dir` feature enabled, you can use: +//! +#![cfg_attr(feature = "include-dir", doc = "```rust")] +#![cfg_attr(not(feature = "include-dir"), doc = "```rust,ignore")] +//! // The `include_dir!` macro is re-exported for convenience. +//! use datatest_stable::include_dir; +//! use std::path::Path; +//! +//! fn my_test(path: &Path, contents: Vec) -> datatest_stable::Result<()> { +//! // ... write test here +//! Ok(()) +//! } +//! +//! datatest_stable::harness!( +//! my_test, include_dir!("tests/files"), r"^.*/*", +//! ); +//! ``` +//! +//! You can also use directories published as `static` items in upstream crates: +//! +#![cfg_attr(feature = "include-dir", doc = "```rust")] +#![cfg_attr(not(feature = "include-dir"), doc = "```rust,ignore")] +//! use datatest_stable::{include_dir, Utf8Path}; +//! +//! // In the upstream crate: +//! pub static FIXTURES: include_dir::Dir<'static> = include_dir!("tests/files"); +//! +//! // In your test: +//! fn my_test(path: &Utf8Path, contents: String) -> datatest_stable::Result<()> { +//! // ... write test here +//! Ok(()) +//! } +//! +//! datatest_stable::harness!( +//! my_test, &FIXTURES, r"^.*/*", +//! ); +//! ``` +//! +//! In this case, the passed-in `Path` and `Utf8Path` are **relative** to the +//! root of the included directory. +//! +//! Because the files don't exist on disk, the test functions must accept their +//! contents as either a `String` or a `Vec`. If the argument is not +//! provided, the harness will panic at runtime. +//! +//! ## Conditionally embedding directories +//! +//! It is also possible to conditionally include directories at compile time via +//! a feature flag. For example, you might have an internal-only `testing` +//! feature that you turn on locally, but users don't on crates.io. In that +//! case, you can use: +//! +#![cfg_attr(feature = "include-dir", doc = "```rust")] +#![cfg_attr(not(feature = "include-dir"), doc = "```rust,ignore")] +//! use datatest_stable::Utf8Path; +//! +//! #[cfg(feature = "testing")] +//! static FIXTURES: &str = "tests/files"; +//! +//! #[cfg(not(feature = "testing"))] +//! static FIXTURES: include_dir::Dir<'static> = datatest_stable::include_dir!("tests/files"); +//! +//! fn my_test(path: &Utf8Path, contents: String) -> datatest_stable::Result<()> { +//! // ... write test here +//! Ok(()) +//! } +//! +//! datatest_stable::harness!( +//! my_test, &FIXTURES, r"^.*/*", +//! ); +//! ``` +//! +//! In this case, note that `path` will be absolute if `FIXTURES` is a string, +//! and relative if `FIXTURES` is a `Dir`. Your test should be prepared to +//! handle either case. +//! +//! # Features +//! +//! * `include-dir`: Enables the `include_dir!` macro, which allows embedding +//! directories at compile time. This feature is disabled by default. +//! //! # Minimum supported Rust version (MSRV) //! //! The minimum supported Rust version is **Rust 1.72**. MSRV bumps may be accompanied by a minor @@ -88,17 +190,24 @@ //! * [Data-driven testing](https://en.wikipedia.org/wiki/Data-driven_testing) #![warn(missing_docs)] +#![cfg_attr(doc_cfg, feature(doc_cfg, doc_auto_cfg))] +mod data_source; mod macros; mod runner; -mod utils; /// The result type for `datatest-stable` tests. pub type Result = std::result::Result>; +#[doc(hidden)] +pub use self::data_source::{data_source_kinds, DataSource}; /// Not part of the public API, just used for macros. #[doc(hidden)] pub use self::runner::{runner, test_kinds, Requirements, TestFn}; /// A re-export of this type from the `camino` crate, since it forms part of function signatures. #[doc(no_inline)] pub use camino::Utf8Path; +/// A re-export of `include_dir!` from the `include_dir` crate, for convenience. +#[cfg(feature = "include-dir")] +#[doc(no_inline)] +pub use include_dir::include_dir; diff --git a/src/macros.rs b/src/macros.rs index 7ac1d6170..3f5ec8f02 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -10,6 +10,7 @@ macro_rules! harness { ( $( $name:path, $root:expr, $pattern:expr ),+ $(,)* ) => { fn main() -> ::std::process::ExitCode { let mut requirements = Vec::new(); + use $crate::data_source_kinds::*; use $crate::test_kinds::*; $( @@ -17,7 +18,7 @@ macro_rules! harness { $crate::Requirements::new( $name.kind().resolve($name), stringify!($name).to_string(), - $root.to_string().into(), + $root.resolve_data_source(), $pattern.to_string() ) ); diff --git a/src/runner.rs b/src/runner.rs index 95db3fafb..45f3d2a94 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,7 +1,7 @@ // Copyright (c) The datatest-stable Contributors // SPDX-License-Identifier: MIT OR Apache-2.0 -use crate::{utils, Result}; +use crate::{data_source::TestEntry, DataSource, Result}; use camino::{Utf8Path, Utf8PathBuf}; use libtest_mimic::{Arguments, Trial}; use std::{path::Path, process::ExitCode}; @@ -101,13 +101,23 @@ fn exact_filter(args: &Arguments) -> Option<&str> { pub struct Requirements { test: TestFn, test_name: String, - root: Utf8PathBuf, + root: DataSource, pattern: String, } impl Requirements { #[doc(hidden)] - pub fn new(test: TestFn, test_name: String, root: Utf8PathBuf, pattern: String) -> Self { + pub fn new(test: TestFn, test_name: String, root: DataSource, pattern: String) -> Self { + // include_dir data sources aren't compatible with test functions that + // don't accept the contents as an argument. + if !test.loads_data() && root.is_in_memory() { + panic!( + "test data for '{}' is stored in memory, so it \ + must accept file contents as an argument", + test_name + ); + } + Self { test, test_name, @@ -116,19 +126,19 @@ impl Requirements { } } - fn trial(&self, path: Utf8PathBuf) -> Trial { + fn trial(&self, entry: TestEntry) -> Trial { let testfn = self.test; - let name = utils::derive_test_name(&self.root, &path, &self.test_name); + let name = entry.derive_test_name(&self.test_name); Trial::test(name, move || { testfn - .call(&path) + .call(entry) .map_err(|err| format!("{:?}", err).into()) }) } fn exact(&self, filter: &str) -> Option { - let path = utils::derive_test_path(&self.root, filter, &self.test_name)?; - path.exists().then(|| self.trial(path)) + let entry = self.root.derive_exact(filter, &self.test_name)?; + entry.exists().then(|| self.trial(entry)) } /// Scans all files in a given directory, finds matching ones and generates a test descriptor @@ -137,18 +147,19 @@ impl Requirements { let re = fancy_regex::Regex::new(&self.pattern) .unwrap_or_else(|_| panic!("invalid regular expression: '{}'", self.pattern)); - let tests: Vec<_> = utils::iterate_directory(&self.root) - .filter_map(|path_res| { - let path = path_res.expect("error while iterating directory"); - if re.is_match(path.as_str()).unwrap_or_else(|error| { + let tests: Vec<_> = self + .root + .walk_files() + .filter_map(|entry_res| { + let entry = entry_res.expect("error reading directory"); + let path_str = entry.test_path().as_str(); + if re.is_match(path_str).unwrap_or_else(|error| { panic!( "error matching pattern '{}' against path '{}' : {}", - self.pattern, - path.as_str(), - error + self.pattern, path_str, error ) }) { - Some(self.trial(path)) + Some(self.trial(entry)) } else { None } @@ -158,8 +169,10 @@ impl Requirements { // We want to avoid silent fails due to typos in regexp! if tests.is_empty() { panic!( - "no test cases found for test '{}'. Scanned directory: '{}' with pattern '{}'", - self.test_name, self.root, self.pattern, + "no test cases found for test '{}' -- scanned {} with pattern '{}'", + self.test_name, + self.root.display(), + self.pattern, ); } @@ -181,11 +194,23 @@ pub enum TestFn { } impl TestFn { - fn call(&self, path: &Utf8Path) -> Result<()> { + fn loads_data(&self) -> bool { + match self { + TestFn::Base(_) => false, + TestFn::LoadString(_) | TestFn::LoadBinary(_) => true, + } + } + + fn call(&self, entry: TestEntry) -> Result<()> { match self { - TestFn::Base(f) => f.call(path), - TestFn::LoadString(f) => f.call(path), - TestFn::LoadBinary(f) => f.call(path), + TestFn::Base(f) => { + let path = entry + .disk_path() + .expect("test entry being on disk was checked in the constructor"); + f.call(path) + } + TestFn::LoadString(f) => f.call(entry), + TestFn::LoadBinary(f) => f.call(entry), } } } @@ -214,12 +239,11 @@ pub enum TestFnLoadString { } impl TestFnLoadString { - fn call(&self, path: &Utf8Path) -> Result<()> { - let contents = std::fs::read_to_string(path) - .map_err(|err| format!("error reading file '{path}' as UTF-8: {err}"))?; + fn call(&self, entry: TestEntry) -> Result<()> { + let contents = entry.read_as_string()?; match self { - TestFnLoadString::Path(f) => f(path.as_ref(), contents), - TestFnLoadString::Utf8Path(f) => f(path, contents), + TestFnLoadString::Path(f) => f(entry.test_path().as_ref(), contents), + TestFnLoadString::Utf8Path(f) => f(entry.test_path(), contents), } } } @@ -232,12 +256,11 @@ pub enum TestFnLoadBinary { } impl TestFnLoadBinary { - fn call(&self, path: &Utf8Path) -> Result<()> { - let contents = - std::fs::read(path).map_err(|err| format!("error reading file '{path}': {err}"))?; + fn call(&self, entry: TestEntry) -> Result<()> { + let contents = entry.read()?; match self { - TestFnLoadBinary::Path(f) => f(path.as_ref(), contents), - TestFnLoadBinary::Utf8Path(f) => f(path, contents), + TestFnLoadBinary::Path(f) => f(entry.test_path().as_ref(), contents), + TestFnLoadBinary::Utf8Path(f) => f(entry.test_path(), contents), } } } @@ -440,3 +463,25 @@ pub mod test_kinds { impl) -> Result<()>> private::Utf8PathBytesSealed for F {} impl) -> Result<()>> Utf8PathBytesKind for F {} } + +#[cfg(all(test, feature = "include-dir"))] +mod include_dir_tests { + use super::*; + use std::borrow::Cow; + + #[test] + #[should_panic = "test data for 'my_test' is stored in memory, \ + so it must accept file contents as an argument"] + fn include_dir_without_arg() { + fn my_test(_: &Path) -> Result<()> { + Ok(()) + } + + Requirements::new( + TestFn::Base(TestFnBase::Path(my_test)), + "my_test".to_owned(), + DataSource::IncludeDir(Cow::Owned(include_dir::include_dir!("tests/files"))), + "xxx".to_owned(), + ); + } +} diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index 11a8a5ffa..000000000 --- a/src/utils.rs +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) The datatest-stable Contributors -// SPDX-License-Identifier: MIT OR Apache-2.0 - -use camino::{Utf8Path, Utf8PathBuf}; - -/// Helper function to iterate through all the files in the given directory, skipping hidden files, -/// and return an iterator of their paths. -pub fn iterate_directory(path: &Utf8Path) -> impl Iterator> { - walkdir::WalkDir::new(path) - .into_iter() - .filter(|res| { - // Continue to bubble up all errors to the parent. - res.as_ref().map_or(true, |entry| { - entry.file_type().is_file() - && entry - .file_name() - .to_str() - .map_or(false, |s| !s.starts_with('.')) // Skip hidden files - }) - }) - .map(|res| match res { - Ok(entry) => { - Utf8PathBuf::try_from(entry.into_path()).map_err(|error| error.into_io_error()) - } - Err(error) => Err(error.into()), - }) -} - -pub fn derive_test_name(root: &Utf8Path, path: &Utf8Path, test_name: &str) -> String { - let relative = path - .strip_prefix(root) - .unwrap_or_else(|_| panic!("failed to strip prefix '{}' from path '{}'", root, path)); - - format!("{}::{}", test_name, relative) -} - -pub fn derive_test_path(root: &Utf8Path, filter: &str, test_name: &str) -> Option { - let relative = filter.strip_prefix(test_name)?.strip_prefix("::")?; - Some(root.join(relative)) -} - -#[cfg(test)] -mod tests { - use super::*; - - mod derive_path { - use super::*; - - #[test] - fn missing_test_name() { - assert_eq!(derive_test_path("root".into(), "file", "test_name"), None); - } - - #[test] - fn missing_colons() { - assert_eq!( - derive_test_path("root".into(), "test_name", "test_name"), - None - ); - } - - #[test] - fn is_relative_to_root() { - assert_eq!( - derive_test_path("root".into(), "test_name::file", "test_name"), - Some("root/file".into()) - ); - assert_eq!( - derive_test_path("root2".into(), "test_name::file", "test_name"), - Some("root2/file".into()) - ); - } - - #[test] - fn nested_dirs() { - assert_eq!( - derive_test_path("root".into(), "test_name::dir/dir2/file", "test_name"), - Some("root/dir/dir2/file".into()) - ); - } - - #[test] - fn subsequent_module_separators_remain() { - assert_eq!( - derive_test_path("root".into(), "test_name::mod::file", "test_name"), - Some("root/mod::file".into()) - ); - } - - #[test] - fn inverse_of_derive_test_name() { - let root: Utf8PathBuf = "root".into(); - for (path, test_name) in [ - (root.join("foo/bar.txt"), "test_name"), - (root.join("foo::bar.txt"), "test_name"), - (root.join("foo/bar/baz"), "test_name"), - (root.join("foo"), "test_name::mod"), - (root.join("🦀"), "🚀::🚀"), - ] { - let derived_test_name = derive_test_name(&root, &path, test_name); - assert_eq!( - derive_test_path(&root, &derived_test_name, test_name), - Some(path) - ); - } - } - } -} diff --git a/tests/example.rs b/tests/example.rs index 6ef7dc2ee..42b4d95cd 100644 --- a/tests/example.rs +++ b/tests/example.rs @@ -17,20 +17,80 @@ fn test_artifact_utf8(path: &Utf8Path) -> Result<()> { test_artifact(path.as_ref()) } -fn test_artifact_string(path: &Path, contents: String) -> Result<()> { - compare_contents(path, contents.as_bytes()) -} +#[cfg(feature = "include-dir")] +#[macro_use] +mod with_contents { + use super::*; -fn test_artifact_utf8_string(path: &Utf8Path, contents: String) -> Result<()> { - compare_contents(path.as_std_path(), contents.as_bytes()) -} + /// Returns an `include_dir::Dir` instance. + macro_rules! maybe_include_dir { + () => { + include_dir::include_dir!("tests/files") + }; + } + + /// A `&'static include_dir::Dir` instance. + pub(crate) static MAYBE_INCLUDE_STATIC: include_dir::Dir = + include_dir::include_dir!("tests/files"); + + pub(crate) fn test_artifact_string(path: &Path, contents: String) -> Result<()> { + // In general we can't verify the contents, but in this case we can do + // so because the contents are known. + compare_include_dir_contents(path, contents.as_bytes()) + } + + pub(crate) fn test_artifact_utf8_string(path: &Utf8Path, contents: String) -> Result<()> { + compare_include_dir_contents(path.as_std_path(), contents.as_bytes()) + } -fn test_artifact_bytes(path: &Path, contents: Vec) -> Result<()> { - compare_contents(path, &contents) + pub(crate) fn test_artifact_bytes(path: &Path, contents: Vec) -> Result<()> { + compare_include_dir_contents(path, &contents) + } + + pub(crate) fn test_artifact_utf8_bytes(path: &Utf8Path, contents: Vec) -> Result<()> { + compare_include_dir_contents(path.as_std_path(), &contents) + } + + fn compare_include_dir_contents(path: &Path, expected: &[u8]) -> Result<()> { + // The path must be relative. + assert!(path.is_relative(), "path must be relative: {:?}", path); + + // Prepend tests/files to the path to get the expected contents. + let path = Path::new("tests/files").join(path); + compare_contents(&path, expected) + } } -fn test_artifact_utf8_bytes(path: &Utf8Path, contents: Vec) -> Result<()> { - compare_contents(path.as_std_path(), &contents) +#[cfg(not(feature = "include-dir"))] +#[macro_use] +mod with_contents { + use super::*; + + /// Returns an `include_dir::Dir` instance. + macro_rules! maybe_include_dir { + () => { + "tests/files" + }; + } + + /// A `&'static include_dir::Dir` instance. + pub(crate) static MAYBE_INCLUDE_STATIC: &str = "tests/files"; + + pub(crate) fn test_artifact_string(path: &Path, contents: String) -> Result<()> { + compare_contents(path, contents.as_bytes()) + } + + pub(crate) fn test_artifact_utf8_string(path: &Utf8Path, contents: String) -> Result<()> { + compare_contents(path.as_std_path(), contents.as_bytes()) + } + + pub(crate) fn test_artifact_bytes(path: &Path, contents: Vec) -> Result<()> { + compare_contents(path, &contents) + } + + pub(crate) fn test_artifact_utf8_bytes(path: &Utf8Path, contents: Vec) -> Result<()> { + compare_contents(path.as_std_path(), &contents) + } } fn compare_contents(path: &Path, expected: &[u8]) -> Result<()> { @@ -49,16 +109,16 @@ datatest_stable::harness!( test_artifact_utf8, "tests/files", r"^.*\.txt$", // this regex pattern matches all files - test_artifact_string, - "tests/files", + with_contents::test_artifact_string, + maybe_include_dir!(), r"^.*\.txt$", - test_artifact_utf8_string, - "tests/files", + with_contents::test_artifact_utf8_string, + &with_contents::MAYBE_INCLUDE_STATIC, // Test out some combinations with &'static include_dir::Dir. r"^.*\.txt$", - test_artifact_bytes, - "tests/files", + with_contents::test_artifact_bytes, + &with_contents::MAYBE_INCLUDE_STATIC, r"^.*\.txt$", - test_artifact_utf8_bytes, - "tests/files", + with_contents::test_artifact_utf8_bytes, + maybe_include_dir!(), r"^.*\.txt$", ); diff --git a/tests/run_example.rs b/tests/run_example.rs index e3450afca..45767d774 100644 --- a/tests/run_example.rs +++ b/tests/run_example.rs @@ -2,23 +2,23 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 static EXPECTED_LINES: &[&str] = &[ - "datatest-stable::example test_artifact_bytes::a.txt", - "datatest-stable::example test_artifact_bytes::b.txt", - "datatest-stable::example test_artifact_bytes::c.skip.txt", - "datatest-stable::example test_artifact_string::a.txt", - "datatest-stable::example test_artifact_string::b.txt", - "datatest-stable::example test_artifact_string::c.skip.txt", - "datatest-stable::example test_artifact_utf8_bytes::a.txt", - "datatest-stable::example test_artifact_utf8_bytes::b.txt", - "datatest-stable::example test_artifact_utf8_bytes::c.skip.txt", - "datatest-stable::example test_artifact_utf8_string::a.txt", - "datatest-stable::example test_artifact_utf8_string::b.txt", - "datatest-stable::example test_artifact_utf8_string::c.skip.txt", "datatest-stable::example test_artifact_utf8::a.txt", "datatest-stable::example test_artifact_utf8::b.txt", "datatest-stable::example test_artifact_utf8::c.skip.txt", "datatest-stable::example test_artifact::a.txt", "datatest-stable::example test_artifact::b.txt", + "datatest-stable::example with_contents::test_artifact_bytes::a.txt", + "datatest-stable::example with_contents::test_artifact_bytes::b.txt", + "datatest-stable::example with_contents::test_artifact_bytes::c.skip.txt", + "datatest-stable::example with_contents::test_artifact_string::a.txt", + "datatest-stable::example with_contents::test_artifact_string::b.txt", + "datatest-stable::example with_contents::test_artifact_string::c.skip.txt", + "datatest-stable::example with_contents::test_artifact_utf8_bytes::a.txt", + "datatest-stable::example with_contents::test_artifact_utf8_bytes::b.txt", + "datatest-stable::example with_contents::test_artifact_utf8_bytes::c.skip.txt", + "datatest-stable::example with_contents::test_artifact_utf8_string::a.txt", + "datatest-stable::example with_contents::test_artifact_utf8_string::b.txt", + "datatest-stable::example with_contents::test_artifact_utf8_string::c.skip.txt", ]; #[test] @@ -58,26 +58,6 @@ mod unix { use camino_tempfile::Utf8TempDir; static EXPECTED_UNIX_LINES: &[&str] = &[ - "datatest-stable::example test_artifact_bytes::::colon::dir/::.txt", - "datatest-stable::example test_artifact_bytes::::colon::dir/a.txt", - "datatest-stable::example test_artifact_bytes::a.txt", - "datatest-stable::example test_artifact_bytes::b.txt", - "datatest-stable::example test_artifact_bytes::c.skip.txt", - "datatest-stable::example test_artifact_string::::colon::dir/::.txt", - "datatest-stable::example test_artifact_string::::colon::dir/a.txt", - "datatest-stable::example test_artifact_string::a.txt", - "datatest-stable::example test_artifact_string::b.txt", - "datatest-stable::example test_artifact_string::c.skip.txt", - "datatest-stable::example test_artifact_utf8_bytes::::colon::dir/::.txt", - "datatest-stable::example test_artifact_utf8_bytes::::colon::dir/a.txt", - "datatest-stable::example test_artifact_utf8_bytes::a.txt", - "datatest-stable::example test_artifact_utf8_bytes::b.txt", - "datatest-stable::example test_artifact_utf8_bytes::c.skip.txt", - "datatest-stable::example test_artifact_utf8_string::::colon::dir/::.txt", - "datatest-stable::example test_artifact_utf8_string::::colon::dir/a.txt", - "datatest-stable::example test_artifact_utf8_string::a.txt", - "datatest-stable::example test_artifact_utf8_string::b.txt", - "datatest-stable::example test_artifact_utf8_string::c.skip.txt", "datatest-stable::example test_artifact_utf8::::colon::dir/::.txt", "datatest-stable::example test_artifact_utf8::::colon::dir/a.txt", "datatest-stable::example test_artifact_utf8::a.txt", @@ -87,6 +67,26 @@ mod unix { "datatest-stable::example test_artifact::::colon::dir/a.txt", "datatest-stable::example test_artifact::a.txt", "datatest-stable::example test_artifact::b.txt", + "datatest-stable::example with_contents::test_artifact_bytes::::colon::dir/::.txt", + "datatest-stable::example with_contents::test_artifact_bytes::::colon::dir/a.txt", + "datatest-stable::example with_contents::test_artifact_bytes::a.txt", + "datatest-stable::example with_contents::test_artifact_bytes::b.txt", + "datatest-stable::example with_contents::test_artifact_bytes::c.skip.txt", + "datatest-stable::example with_contents::test_artifact_string::::colon::dir/::.txt", + "datatest-stable::example with_contents::test_artifact_string::::colon::dir/a.txt", + "datatest-stable::example with_contents::test_artifact_string::a.txt", + "datatest-stable::example with_contents::test_artifact_string::b.txt", + "datatest-stable::example with_contents::test_artifact_string::c.skip.txt", + "datatest-stable::example with_contents::test_artifact_utf8_bytes::::colon::dir/::.txt", + "datatest-stable::example with_contents::test_artifact_utf8_bytes::::colon::dir/a.txt", + "datatest-stable::example with_contents::test_artifact_utf8_bytes::a.txt", + "datatest-stable::example with_contents::test_artifact_utf8_bytes::b.txt", + "datatest-stable::example with_contents::test_artifact_utf8_bytes::c.skip.txt", + "datatest-stable::example with_contents::test_artifact_utf8_string::::colon::dir/::.txt", + "datatest-stable::example with_contents::test_artifact_utf8_string::::colon::dir/a.txt", + "datatest-stable::example with_contents::test_artifact_utf8_string::a.txt", + "datatest-stable::example with_contents::test_artifact_utf8_string::b.txt", + "datatest-stable::example with_contents::test_artifact_utf8_string::c.skip.txt", ]; #[test]