diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-04 12:41:35 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-04 12:41:35 +0000 |
commit | 7e5d7eea9c580ef4b41a765bde624af431942b96 (patch) | |
tree | 2c0d9ca12878fc4525650aa4e54d77a81a07cc09 /vendor/snapbox/src | |
parent | Adding debian version 1.70.0+dfsg1-9. (diff) | |
download | rustc-7e5d7eea9c580ef4b41a765bde624af431942b96.tar.xz rustc-7e5d7eea9c580ef4b41a765bde624af431942b96.zip |
Merging upstream version 1.70.0+dfsg2.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'vendor/snapbox/src')
-rw-r--r-- | vendor/snapbox/src/action.rs | 39 | ||||
-rw-r--r-- | vendor/snapbox/src/assert.rs | 527 | ||||
-rw-r--r-- | vendor/snapbox/src/bin/snap-fixture.rs | 60 | ||||
-rw-r--r-- | vendor/snapbox/src/cmd.rs | 1030 | ||||
-rw-r--r-- | vendor/snapbox/src/data.rs | 712 | ||||
-rw-r--r-- | vendor/snapbox/src/error.rs | 95 | ||||
-rw-r--r-- | vendor/snapbox/src/harness.rs | 212 | ||||
-rw-r--r-- | vendor/snapbox/src/lib.rs | 246 | ||||
-rw-r--r-- | vendor/snapbox/src/path.rs | 686 | ||||
-rw-r--r-- | vendor/snapbox/src/report/color.rs | 127 | ||||
-rw-r--r-- | vendor/snapbox/src/report/diff.rs | 384 | ||||
-rw-r--r-- | vendor/snapbox/src/report/mod.rs | 9 | ||||
-rw-r--r-- | vendor/snapbox/src/substitutions.rs | 420 | ||||
-rw-r--r-- | vendor/snapbox/src/utils/lines.rs | 31 | ||||
-rw-r--r-- | vendor/snapbox/src/utils/mod.rs | 30 |
15 files changed, 4608 insertions, 0 deletions
diff --git a/vendor/snapbox/src/action.rs b/vendor/snapbox/src/action.rs new file mode 100644 index 000000000..a4b849919 --- /dev/null +++ b/vendor/snapbox/src/action.rs @@ -0,0 +1,39 @@ +pub 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<std::ffi::OsStr>) -> Option<Self> { + 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<std::ffi::OsStr>) -> Option<Self> { + 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 + } +} diff --git a/vendor/snapbox/src/assert.rs b/vendor/snapbox/src/assert.rs new file mode 100644 index 000000000..ab87f1554 --- /dev/null +++ b/vendor/snapbox/src/assert.rs @@ -0,0 +1,527 @@ +use crate::data::{DataFormat, NormalizeMatches, NormalizeNewlines, NormalizePaths}; +use crate::Action; + +/// Snapshot assertion against a file's contents +/// +/// Useful for one-off assertions with the snapshot stored in a file +/// +/// # Examples +/// +/// ```rust,no_run +/// let actual = "..."; +/// snapbox::Assert::new() +/// .action_env("SNAPSHOTS") +/// .matches_path(actual, "tests/fixtures/help_output_is_clean.txt"); +/// ``` +#[derive(Clone, Debug)] +pub struct Assert { + action: Action, + action_var: Option<String>, + normalize_paths: bool, + substitutions: crate::Substitutions, + pub(crate) palette: crate::report::Palette, + pub(crate) data_format: Option<DataFormat>, +} + +/// # Assertions +impl Assert { + pub fn new() -> Self { + Default::default() + } + + /// Check if a value is the same as an expected value + /// + /// When the content is text, newlines are normalized. + /// + /// ```rust + /// let output = "something"; + /// let expected = "something"; + /// snapbox::Assert::new().eq(expected, output); + /// ``` + #[track_caller] + pub fn eq(&self, expected: impl Into<crate::Data>, actual: impl Into<crate::Data>) { + let expected = expected.into(); + let actual = actual.into(); + self.eq_inner(expected, actual); + } + + #[track_caller] + fn eq_inner(&self, expected: crate::Data, actual: crate::Data) { + let (pattern, actual) = self.normalize_eq(Ok(expected), actual); + if let Err(desc) = pattern.and_then(|p| self.try_verify(&p, &actual, None, None)) { + panic!("{}: {}", self.palette.error("Eq failed"), desc); + } + } + + /// Check if a value matches a pattern + /// + /// 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 + /// let output = "something"; + /// let expected = "so[..]g"; + /// snapbox::Assert::new().matches(expected, output); + /// ``` + #[track_caller] + pub fn matches(&self, pattern: impl Into<crate::Data>, actual: impl Into<crate::Data>) { + let pattern = pattern.into(); + let actual = actual.into(); + self.matches_inner(pattern, actual); + } + + #[track_caller] + fn matches_inner(&self, pattern: crate::Data, actual: crate::Data) { + let (pattern, actual) = self.normalize_match(Ok(pattern), actual); + if let Err(desc) = pattern.and_then(|p| self.try_verify(&p, &actual, None, None)) { + panic!("{}: {}", self.palette.error("Match failed"), desc); + } + } + + /// Check if a value matches the content of a file + /// + /// When the content is text, newlines are normalized. + /// + /// ```rust,no_run + /// let output = "something"; + /// let expected_path = "tests/snapshots/output.txt"; + /// snapbox::Assert::new().eq_path(output, expected_path); + /// ``` + #[track_caller] + pub fn eq_path( + &self, + expected_path: impl AsRef<std::path::Path>, + actual: impl Into<crate::Data>, + ) { + let expected_path = expected_path.as_ref(); + let actual = actual.into(); + self.eq_path_inner(expected_path, actual); + } + + #[track_caller] + fn eq_path_inner(&self, pattern_path: &std::path::Path, actual: crate::Data) { + match self.action { + Action::Skip => { + return; + } + Action::Ignore | Action::Verify | Action::Overwrite => {} + } + + let expected = crate::Data::read_from(pattern_path, self.data_format()); + let (expected, actual) = self.normalize_eq(expected, actual); + + self.do_action( + expected, + actual, + Some(&crate::path::display_relpath(pattern_path)), + Some(&"In-memory"), + pattern_path, + ); + } + + /// Check if a value matches the pattern in a file + /// + /// 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 (override with [`Assert::substitutions`]) + /// + /// Normalization: + /// - Newlines + /// - `\` to `/` + /// + /// ```rust,no_run + /// let output = "something"; + /// let expected_path = "tests/snapshots/output.txt"; + /// snapbox::Assert::new().matches_path(expected_path, output); + /// ``` + #[track_caller] + pub fn matches_path( + &self, + pattern_path: impl AsRef<std::path::Path>, + actual: impl Into<crate::Data>, + ) { + let pattern_path = pattern_path.as_ref(); + let actual = actual.into(); + self.matches_path_inner(pattern_path, actual); + } + + #[track_caller] + fn matches_path_inner(&self, pattern_path: &std::path::Path, actual: crate::Data) { + match self.action { + Action::Skip => { + return; + } + Action::Ignore | Action::Verify | Action::Overwrite => {} + } + + let expected = crate::Data::read_from(pattern_path, self.data_format()); + let (expected, actual) = self.normalize_match(expected, actual); + + self.do_action( + expected, + actual, + Some(&crate::path::display_relpath(pattern_path)), + Some(&"In-memory"), + pattern_path, + ); + } + + pub(crate) fn normalize_eq( + &self, + expected: crate::Result<crate::Data>, + mut actual: crate::Data, + ) -> (crate::Result<crate::Data>, crate::Data) { + let expected = expected.map(|d| d.normalize(NormalizeNewlines)); + // On `expected` being an error, make a best guess + let format = expected + .as_ref() + .map(|d| d.format()) + .unwrap_or(DataFormat::Text); + + actual = actual.try_coerce(format).normalize(NormalizeNewlines); + + (expected, actual) + } + + pub(crate) fn normalize_match( + &self, + expected: crate::Result<crate::Data>, + mut actual: crate::Data, + ) -> (crate::Result<crate::Data>, crate::Data) { + let expected = expected.map(|d| d.normalize(NormalizeNewlines)); + // On `expected` being an error, make a best guess + let format = expected.as_ref().map(|e| e.format()).unwrap_or_default(); + actual = actual.try_coerce(format); + + if self.normalize_paths { + actual = actual.normalize(NormalizePaths); + } + // Always normalize new lines + actual = actual.normalize(NormalizeNewlines); + + // If expected is not an error normalize matches + if let Ok(expected) = expected.as_ref() { + actual = actual.normalize(NormalizeMatches::new(&self.substitutions, expected)); + } + + (expected, actual) + } + + #[track_caller] + pub(crate) fn do_action( + &self, + expected: crate::Result<crate::Data>, + actual: crate::Data, + expected_name: Option<&dyn std::fmt::Display>, + actual_name: Option<&dyn std::fmt::Display>, + expected_path: &std::path::Path, + ) { + let result = + expected.and_then(|e| self.try_verify(&e, &actual, expected_name, actual_name)); + if let Err(err) = result { + match self.action { + Action::Skip => unreachable!("Bailed out earlier"), + Action::Ignore => { + use std::io::Write; + + let _ = writeln!( + std::io::stderr(), + "{}: {}", + self.palette.warn("Ignoring failure"), + err + ); + } + Action::Verify => { + use std::fmt::Write; + let mut buffer = String::new(); + write!(&mut buffer, "{}", err).unwrap(); + if let Some(action_var) = self.action_var.as_deref() { + writeln!( + &mut buffer, + "{}", + self.palette + .hint(format_args!("Update with {}=overwrite", action_var)) + ) + .unwrap(); + } + panic!("{}", buffer); + } + Action::Overwrite => { + use std::io::Write; + + let _ = writeln!( + std::io::stderr(), + "{}: {}", + self.palette.warn("Fixing"), + err + ); + actual.write_to(expected_path).unwrap(); + } + } + } + } + + pub(crate) fn try_verify( + &self, + expected: &crate::Data, + actual: &crate::Data, + expected_name: Option<&dyn std::fmt::Display>, + actual_name: Option<&dyn std::fmt::Display>, + ) -> crate::Result<()> { + if expected != actual { + let mut buf = String::new(); + crate::report::write_diff( + &mut buf, + expected, + actual, + expected_name, + actual_name, + self.palette, + ) + .map_err(|e| e.to_string())?; + Err(buf.into()) + } else { + Ok(()) + } + } +} + +/// # Directory Assertions +#[cfg(feature = "path")] +impl Assert { + #[track_caller] + pub fn subset_eq( + &self, + expected_root: impl Into<std::path::PathBuf>, + actual_root: impl Into<std::path::PathBuf>, + ) { + 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::path::PathDiff::subset_eq_iter_inner(expected_root, actual_root).collect(); + self.verify(checks); + } + + #[track_caller] + pub fn subset_matches( + &self, + pattern_root: impl Into<std::path::PathBuf>, + actual_root: impl Into<std::path::PathBuf>, + ) { + 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::path::PathDiff::subset_matches_iter_inner( + expected_root, + actual_root, + &self.substitutions, + ) + .collect(); + self.verify(checks); + } + + #[track_caller] + fn verify( + &self, + mut checks: Vec<Result<(std::path::PathBuf, std::path::PathBuf), crate::path::PathDiff>>, + ) { + 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!(std::io::stderr(), "{}", buffer); + match self.action { + Action::Skip => unreachable!("Bailed out earlier"), + Action::Ignore => { + let _ = write!( + std::io::stderr(), + "{}", + self.palette.warn("Ignoring above failures") + ); + } + Action::Verify => unreachable!("Something had to fail to get here"), + Action::Overwrite => { + let _ = write!( + std::io::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 {}=overwrite", action_var)) + ) + .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 [`Substitutions`][crate::Substitutions] + pub fn substitutions(mut self, substitutions: crate::Substitutions) -> Self { + self.substitutions = substitutions; + self + } + + /// 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 + } + + /// Specify whether the content should be treated as binary or not + /// + /// The default is to auto-detect + pub fn binary(mut self, yes: bool) -> Self { + self.data_format = if yes { + Some(DataFormat::Binary) + } else { + Some(DataFormat::Text) + }; + self + } + + pub(crate) fn data_format(&self) -> Option<DataFormat> { + self.data_format + } +} + +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::auto(), + data_format: Default::default(), + } + .substitutions(crate::Substitutions::with_exe()) + } +} diff --git a/vendor/snapbox/src/bin/snap-fixture.rs b/vendor/snapbox/src/bin/snap-fixture.rs new file mode 100644 index 000000000..6e13448a7 --- /dev/null +++ b/vendor/snapbox/src/bin/snap-fixture.rs @@ -0,0 +1,60 @@ +//! 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<dyn Error>> { + 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) = std::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::<i32>()) + .map_or(Ok(None), |r| r.map(Some))? + .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); +} diff --git a/vendor/snapbox/src/cmd.rs b/vendor/snapbox/src/cmd.rs new file mode 100644 index 000000000..72de3563c --- /dev/null +++ b/vendor/snapbox/src/cmd.rs @@ -0,0 +1,1030 @@ +//! Run commands and assert on their behavior + +/// Process spawning for testing of non-interactive commands +#[derive(Debug)] +pub struct Command { + cmd: std::process::Command, + stdin: Option<crate::Data>, + timeout: Option<std::time::Duration>, + _stderr_to_stdout: bool, + config: crate::Assert, +} + +/// # Builder API +impl Command { + pub fn new(program: impl AsRef<std::ffi::OsStr>) -> Self { + Self { + cmd: std::process::Command::new(program), + stdin: None, + timeout: None, + _stderr_to_stdout: false, + config: crate::Assert::new().action_env(crate::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::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<std::ffi::OsStr>) -> 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<Item = impl AsRef<std::ffi::OsStr>>) -> 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<std::ffi::OsStr>, + value: impl AsRef<std::ffi::OsStr>, + ) -> 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<String, String> = + /// 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<Item = (impl AsRef<std::ffi::OsStr>, impl AsRef<std::ffi::OsStr>)>, + ) -> 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<std::ffi::OsStr>) -> 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<std::path::Path>) -> 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 Into<crate::Data>) -> Self { + self.stdin = Some(stream.into()); + 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<std::process::Output, std::io::Error> { + if self._stderr_to_stdout { + self.single_output() + } else { + self.split_output() + } + } + + #[cfg(not(feature = "cmd"))] + pub fn output(self) -> Result<std::process::Output, std::io::Error> { + self.split_output() + } + + #[cfg(feature = "cmd")] + fn single_output(mut self) -> Result<std::process::Output, std::io::Error> { + 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 stdout = process_single_io( + &mut child, + reader, + self.stdin.as_ref().map(|d| d.to_bytes()), + )?; + + 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<std::process::Output, std::io::Error> { + 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 (stdout, stderr) = + process_split_io(&mut child, self.stdin.as_ref().map(|d| d.to_bytes()))?; + + 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<Vec<u8>>, +) -> std::io::Result<(Option<Stream>, Option<Stream>)> { + 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<Vec<u8>>, +) -> std::io::Result<Stream> { + 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<Result<Vec<u8>, std::io::Error>>; + +fn threaded_read<R>(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<std::process::Command> 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::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`. + /// + /// ```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("hello"); + /// ``` + #[track_caller] + pub fn stdout_eq(self, expected: impl Into<crate::Data>) -> Self { + let expected = expected.into(); + self.stdout_eq_inner(expected) + } + + #[track_caller] + fn stdout_eq_inner(self, expected: crate::Data) -> Self { + let actual = crate::Data::from(self.output.stdout.as_slice()); + let (pattern, actual) = self.config.normalize_eq(Ok(expected), actual); + if let Err(desc) = + pattern.and_then(|p| self.config.try_verify(&p, &actual, None, Some(&"stdout"))) + { + use std::fmt::Write; + let mut buf = String::new(); + write!(&mut buf, "{}", desc).unwrap(); + self.write_status(&mut buf).unwrap(); + self.write_stderr(&mut buf).unwrap(); + panic!("{}", buf); + } + + self + } + + /// Ensure the command wrote the expected data to `stdout`. + /// + /// ```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_path("tests/snapshots/output.txt"); + /// ``` + #[track_caller] + pub fn stdout_eq_path(self, expected_path: impl AsRef<std::path::Path>) -> Self { + let expected_path = expected_path.as_ref(); + self.stdout_eq_path_inner(expected_path) + } + + #[track_caller] + fn stdout_eq_path_inner(self, expected_path: &std::path::Path) -> Self { + let actual = crate::Data::from(self.output.stdout.as_slice()); + let expected = crate::Data::read_from(expected_path, self.config.data_format()); + let (pattern, actual) = self.config.normalize_eq(expected, actual); + self.config.do_action( + pattern, + actual, + Some(&crate::path::display_relpath(expected_path)), + Some(&"stdout"), + expected_path, + ); + + self + } + + /// Ensure the command wrote the expected data to `stdout`. + /// + /// ```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_matches("he[..]o"); + /// ``` + #[track_caller] + pub fn stdout_matches(self, expected: impl Into<crate::Data>) -> Self { + let expected = expected.into(); + self.stdout_matches_inner(expected) + } + + #[track_caller] + fn stdout_matches_inner(self, expected: crate::Data) -> Self { + let actual = crate::Data::from(self.output.stdout.as_slice()); + let (pattern, actual) = self.config.normalize_match(Ok(expected), actual); + if let Err(desc) = + pattern.and_then(|p| self.config.try_verify(&p, &actual, None, Some(&"stdout"))) + { + use std::fmt::Write; + let mut buf = String::new(); + write!(&mut buf, "{}", desc).unwrap(); + self.write_status(&mut buf).unwrap(); + self.write_stderr(&mut buf).unwrap(); + panic!("{}", buf); + } + + self + } + + /// Ensure the command wrote the expected data to `stdout`. + /// + /// ```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_matches_path("tests/snapshots/output.txt"); + /// ``` + #[track_caller] + pub fn stdout_matches_path(self, expected_path: impl AsRef<std::path::Path>) -> Self { + let expected_path = expected_path.as_ref(); + self.stdout_matches_path_inner(expected_path) + } + + #[track_caller] + fn stdout_matches_path_inner(self, expected_path: &std::path::Path) -> Self { + let actual = crate::Data::from(self.output.stdout.as_slice()); + let expected = crate::Data::read_from(expected_path, self.config.data_format()); + let (pattern, actual) = self.config.normalize_match(expected, actual); + self.config.do_action( + pattern, + actual, + Some(&expected_path.display()), + Some(&"stdout"), + expected_path, + ); + + self + } + + /// Ensure the command wrote the expected data to `stderr`. + /// + /// ```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("world"); + /// ``` + #[track_caller] + pub fn stderr_eq(self, expected: impl Into<crate::Data>) -> Self { + let expected = expected.into(); + self.stderr_eq_inner(expected) + } + + #[track_caller] + fn stderr_eq_inner(self, expected: crate::Data) -> Self { + let actual = crate::Data::from(self.output.stderr.as_slice()); + let (pattern, actual) = self.config.normalize_eq(Ok(expected), actual); + if let Err(desc) = + pattern.and_then(|p| self.config.try_verify(&p, &actual, None, Some(&"stderr"))) + { + use std::fmt::Write; + let mut buf = String::new(); + write!(&mut buf, "{}", desc).unwrap(); + self.write_status(&mut buf).unwrap(); + self.write_stdout(&mut buf).unwrap(); + panic!("{}", buf); + } + + self + } + + /// Ensure the command wrote the expected data to `stderr`. + /// + /// ```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_path("tests/snapshots/err.txt"); + /// ``` + #[track_caller] + pub fn stderr_eq_path(self, expected_path: impl AsRef<std::path::Path>) -> Self { + let expected_path = expected_path.as_ref(); + self.stderr_eq_path_inner(expected_path) + } + + #[track_caller] + fn stderr_eq_path_inner(self, expected_path: &std::path::Path) -> Self { + let actual = crate::Data::from(self.output.stderr.as_slice()); + let expected = crate::Data::read_from(expected_path, self.config.data_format()); + let (pattern, actual) = self.config.normalize_eq(expected, actual); + self.config.do_action( + pattern, + actual, + Some(&expected_path.display()), + Some(&"stderr"), + expected_path, + ); + + self + } + + /// Ensure the command wrote the expected data to `stderr`. + /// + /// ```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_matches("wo[..]d"); + /// ``` + #[track_caller] + pub fn stderr_matches(self, expected: impl Into<crate::Data>) -> Self { + let expected = expected.into(); + self.stderr_matches_inner(expected) + } + + #[track_caller] + fn stderr_matches_inner(self, expected: crate::Data) -> Self { + let actual = crate::Data::from(self.output.stderr.as_slice()); + let (pattern, actual) = self.config.normalize_match(Ok(expected), actual); + if let Err(desc) = + pattern.and_then(|p| self.config.try_verify(&p, &actual, None, Some(&"stderr"))) + { + use std::fmt::Write; + let mut buf = String::new(); + write!(&mut buf, "{}", desc).unwrap(); + self.write_status(&mut buf).unwrap(); + self.write_stdout(&mut buf).unwrap(); + panic!("{}", buf); + } + + self + } + + /// Ensure the command wrote the expected data to `stderr`. + /// + /// ```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_matches_path("tests/snapshots/err.txt"); + /// ``` + #[track_caller] + pub fn stderr_matches_path(self, expected_path: impl AsRef<std::path::Path>) -> Self { + let expected_path = expected_path.as_ref(); + self.stderr_matches_path_inner(expected_path) + } + + #[track_caller] + fn stderr_matches_path_inner(self, expected_path: &std::path::Path) -> Self { + let actual = crate::Data::from(self.output.stderr.as_slice()); + let expected = crate::Data::read_from(expected_path, self.config.data_format()); + let (pattern, actual) = self.config.normalize_match(expected, actual); + self.config.do_action( + pattern, + actual, + Some(&crate::path::display_relpath(expected_path)), + Some(&"stderr"), + expected_path, + ); + + self + } + + fn write_status(&self, writer: &mut dyn std::fmt::Write) -> Result<(), std::fmt::Error> { + writeln!( + writer, + "Exit status: {}", + display_exit_status(self.output.status) + )?; + Ok(()) + } + + 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<String> { + use std::os::unix::process::*; + + 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<String> { + use winapi::shared::minwindef::DWORD; + use winapi::um::winnt::*; + + let extra = match status.code().unwrap() as DWORD { + 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::time::Duration>, +) -> std::io::Result<std::process::ExitStatus> { + 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::time::Duration>, +) -> std::io::Result<std::process::ExitStatus> { + 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() +} diff --git a/vendor/snapbox/src/data.rs b/vendor/snapbox/src/data.rs new file mode 100644 index 000000000..aa5f9b1ed --- /dev/null +++ b/vendor/snapbox/src/data.rs @@ -0,0 +1,712 @@ +/// Test fixture, actual output, or expected result +/// +/// This provides conveniences for tracking the intended format (binary vs text). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Data { + inner: DataInner, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum DataInner { + Binary(Vec<u8>), + Text(String), + #[cfg(feature = "structured-data")] + Json(serde_json::Value), +} + +#[derive(Clone, Debug, PartialEq, Eq, Copy, Hash)] +pub enum DataFormat { + Binary, + Text, + #[cfg(feature = "json")] + Json, +} + +impl Default for DataFormat { + fn default() -> Self { + DataFormat::Text + } +} + +impl Data { + /// Mark the data as binary (no post-processing) + pub fn binary(raw: impl Into<Vec<u8>>) -> Self { + Self { + inner: DataInner::Binary(raw.into()), + } + } + + /// Mark the data as text (post-processing) + pub fn text(raw: impl Into<String>) -> Self { + Self { + inner: DataInner::Text(raw.into()), + } + } + + #[cfg(feature = "json")] + pub fn json(raw: impl Into<serde_json::Value>) -> Self { + Self { + inner: DataInner::Json(raw.into()), + } + } + + /// Empty test data + pub fn new() -> Self { + Self::text("") + } + + /// Load test data from a file + pub fn read_from( + path: &std::path::Path, + data_format: Option<DataFormat>, + ) -> Result<Self, crate::Error> { + let data = match data_format { + Some(df) => match df { + DataFormat::Binary => { + let data = std::fs::read(&path) + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + Self::binary(data) + } + DataFormat::Text => { + let data = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + Self::text(data) + } + #[cfg(feature = "json")] + DataFormat::Json => { + let data = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + Self::json(serde_json::from_str::<serde_json::Value>(&data).unwrap()) + } + }, + None => { + let data = std::fs::read(&path) + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + let data = Self::binary(data); + match path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default() + { + #[cfg(feature = "json")] + "json" => data.try_coerce(DataFormat::Json), + _ => data.try_coerce(DataFormat::Text), + } + } + }; + Ok(data) + } + + /// Overwrite a snapshot + pub fn write_to(&self, path: &std::path::Path) -> Result<(), crate::Error> { + 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) + })?; + } + std::fs::write(path, self.to_bytes()) + .map_err(|e| format!("Failed to write {}: {}", path.display(), e).into()) + } + + /// Post-process text + /// + /// See [utils][crate::utils] + pub fn normalize(self, op: impl Normalize) -> Self { + op.normalize(self) + } + + /// Return the underlying `String` + /// + /// Note: this will not inspect binary data for being a valid `String`. + pub fn render(&self) -> Option<String> { + match &self.inner { + DataInner::Binary(_) => None, + DataInner::Text(data) => Some(data.to_owned()), + #[cfg(feature = "json")] + DataInner::Json(value) => Some(serde_json::to_string_pretty(value).unwrap()), + } + } + + pub fn to_bytes(&self) -> Vec<u8> { + match &self.inner { + DataInner::Binary(data) => data.clone(), + DataInner::Text(data) => data.clone().into_bytes(), + #[cfg(feature = "json")] + DataInner::Json(value) => serde_json::to_vec_pretty(value).unwrap(), + } + } + + pub fn try_coerce(self, format: DataFormat) -> Self { + match (self.inner, format) { + (DataInner::Binary(inner), DataFormat::Binary) => Self::binary(inner), + (DataInner::Text(inner), DataFormat::Text) => Self::text(inner), + #[cfg(feature = "json")] + (DataInner::Json(inner), DataFormat::Json) => Self::json(inner), + (DataInner::Binary(inner), _) => { + if is_binary(&inner) { + Self::binary(inner) + } else { + match String::from_utf8(inner) { + Ok(str) => { + let coerced = Self::text(str).try_coerce(format); + // if the Text cannot be coerced into the correct format + // reset it back to Binary + if coerced.format() != format { + coerced.try_coerce(DataFormat::Binary) + } else { + coerced + } + } + Err(err) => { + let bin = err.into_bytes(); + Self::binary(bin) + } + } + } + } + #[cfg(feature = "json")] + (DataInner::Text(inner), DataFormat::Json) => { + match serde_json::from_str::<serde_json::Value>(&inner) { + Ok(json) => Self::json(json), + Err(_) => Self::text(inner), + } + } + (inner, DataFormat::Binary) => Self::binary(Self { inner }.to_bytes()), + // This variant is already covered unless structured data is enabled + #[cfg(feature = "structured-data")] + (inner, DataFormat::Text) => { + let remake = Self { inner }; + if let Some(str) = remake.render() { + Self::text(str) + } else { + remake + } + } + } + } + + /// Outputs the current `DataFormat` of the underlying data + pub fn format(&self) -> DataFormat { + match &self.inner { + DataInner::Binary(_) => DataFormat::Binary, + DataInner::Text(_) => DataFormat::Text, + #[cfg(feature = "json")] + DataInner::Json(_) => DataFormat::Json, + } + } +} + +impl std::fmt::Display for Data { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.inner { + 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), + } + } +} + +impl Default for Data { + fn default() -> Self { + Self::new() + } +} + +impl<'d> From<&'d Data> for Data { + fn from(other: &'d Data) -> Self { + other.clone() + } +} + +impl From<Vec<u8>> for Data { + fn from(other: Vec<u8>) -> Self { + Self::binary(other) + } +} + +impl<'b> From<&'b [u8]> for Data { + fn from(other: &'b [u8]) -> Self { + other.to_owned().into() + } +} + +impl From<String> for Data { + fn from(other: String) -> Self { + Self::text(other) + } +} + +impl<'s> From<&'s String> for Data { + fn from(other: &'s String) -> Self { + other.clone().into() + } +} + +impl<'s> From<&'s str> for Data { + fn from(other: &'s str) -> Self { + other.to_owned().into() + } +} + +pub trait Normalize { + fn normalize(&self, data: Data) -> Data; +} + +pub struct NormalizeNewlines; +impl Normalize for NormalizeNewlines { + fn normalize(&self, data: Data) -> Data { + match data.inner { + DataInner::Binary(bin) => Data::binary(bin), + DataInner::Text(text) => { + let lines = crate::utils::normalize_lines(&text); + Data::text(lines) + } + #[cfg(feature = "json")] + DataInner::Json(value) => { + let mut value = value; + normalize_value(&mut value, crate::utils::normalize_lines); + Data::json(value) + } + } + } +} + +pub struct NormalizePaths; +impl Normalize for NormalizePaths { + fn normalize(&self, data: Data) -> Data { + match data.inner { + DataInner::Binary(bin) => Data::binary(bin), + DataInner::Text(text) => { + let lines = crate::utils::normalize_paths(&text); + Data::text(lines) + } + #[cfg(feature = "json")] + DataInner::Json(value) => { + let mut value = value; + normalize_value(&mut value, crate::utils::normalize_paths); + Data::json(value) + } + } + } +} + +pub struct NormalizeMatches<'a> { + substitutions: &'a crate::Substitutions, + pattern: &'a Data, +} + +impl<'a> NormalizeMatches<'a> { + pub fn new(substitutions: &'a crate::Substitutions, pattern: &'a Data) -> Self { + NormalizeMatches { + substitutions, + pattern, + } + } +} + +impl Normalize for NormalizeMatches<'_> { + fn normalize(&self, data: Data) -> Data { + match data.inner { + DataInner::Binary(bin) => Data::binary(bin), + DataInner::Text(text) => { + let lines = self + .substitutions + .normalize(&text, &self.pattern.render().unwrap()); + Data::text(lines) + } + #[cfg(feature = "json")] + DataInner::Json(value) => { + let mut value = value; + if let DataInner::Json(exp) = &self.pattern.inner { + normalize_value_matches(&mut value, exp, self.substitutions); + } + Data::json(value) + } + } + } +} + +#[cfg(feature = "structured-data")] +fn normalize_value(value: &mut serde_json::Value, op: fn(&str) -> String) { + match value { + serde_json::Value::String(str) => { + *str = op(str); + } + serde_json::Value::Array(arr) => { + arr.iter_mut().for_each(|value| normalize_value(value, op)); + } + serde_json::Value::Object(obj) => { + obj.iter_mut() + .for_each(|(_, value)| normalize_value(value, op)); + } + _ => {} + } +} + +#[cfg(feature = "structured-data")] +fn normalize_value_matches( + actual: &mut serde_json::Value, + expected: &serde_json::Value, + substitutions: &crate::Substitutions, +) { + use serde_json::Value::*; + match (actual, expected) { + // "{...}" is a wildcard + (act, String(exp)) if exp == "{...}" => { + *act = serde_json::json!("{...}"); + } + (String(act), String(exp)) => { + *act = substitutions.normalize(act, exp); + } + (Array(act), Array(exp)) => { + act.iter_mut() + .zip(exp) + .for_each(|(a, e)| normalize_value_matches(a, e, substitutions)); + } + (Object(act), Object(exp)) => { + act.iter_mut() + .zip(exp) + .filter(|(a, e)| a.0 == e.0) + .for_each(|(a, e)| normalize_value_matches(a.1, e.1, substitutions)); + } + (_, _) => {} + } +} + +#[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 +} + +#[cfg(test)] +mod test { + use super::*; + #[cfg(feature = "json")] + use serde_json::json; + + // 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(); + 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(); + 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.try_coerce(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.try_coerce(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.try_coerce(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.try_coerce(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.try_coerce(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.try_coerce(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.try_coerce(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.try_coerce(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.try_coerce(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.try_coerce(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().try_coerce(DataFormat::Binary); + assert_eq!(Data::binary(d.to_bytes()), 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().try_coerce(DataFormat::Binary); + assert_eq!(Data::binary(d.to_bytes()), 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().try_coerce(DataFormat::Text); + assert_eq!(Data::text(d.render().unwrap()), text); + } + + // Tests for normalization on json + #[test] + #[cfg(feature = "json")] + fn json_normalize_paths_and_lines() { + let json = json!({"name": "John\\Doe\r\n"}); + let data = Data::json(json); + let data = data.normalize(NormalizePaths); + assert_eq!(Data::json(json!({"name": "John/Doe\r\n"})), data); + let data = data.normalize(NormalizeNewlines); + assert_eq!(Data::json(json!({"name": "John/Doe\n"})), data); + } + + #[test] + #[cfg(feature = "json")] + fn json_normalize_obj_paths_and_lines() { + let json = json!({ + "person": { + "name": "John\\Doe\r\n", + "nickname": "Jo\\hn\r\n", + } + }); + let data = Data::json(json); + let data = data.normalize(NormalizePaths); + let assert = json!({ + "person": { + "name": "John/Doe\r\n", + "nickname": "Jo/hn\r\n", + } + }); + assert_eq!(Data::json(assert), data); + let data = data.normalize(NormalizeNewlines); + 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_array_paths_and_lines() { + let json = json!({"people": ["John\\Doe\r\n", "Jo\\hn\r\n"]}); + let data = Data::json(json); + let data = data.normalize(NormalizePaths); + let paths = json!({"people": ["John/Doe\r\n", "Jo/hn\r\n"]}); + assert_eq!(Data::json(paths), data); + let data = data.normalize(NormalizeNewlines); + 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_array_obj_paths_and_lines() { + let json = json!({ + "people": [ + { + "name": "John\\Doe\r\n", + "nickname": "Jo\\hn\r\n", + } + ] + }); + let data = Data::json(json); + let data = data.normalize(NormalizePaths); + let paths = json!({ + "people": [ + { + "name": "John/Doe\r\n", + "nickname": "Jo/hn\r\n", + } + ] + }); + assert_eq!(Data::json(paths), data); + let data = data.normalize(NormalizeNewlines); + let new_lines = json!({ + "people": [ + { + "name": "John/Doe\n", + "nickname": "Jo/hn\n", + } + ] + }); + assert_eq!(Data::json(new_lines), data); + } + + #[test] + #[cfg(feature = "json")] + fn json_normalize_matches_string() { + let exp = json!({"name": "{...}"}); + let expected = Data::json(exp); + let actual = json!({"name": "JohnDoe"}); + let actual = Data::json(actual).normalize(NormalizeMatches { + substitutions: &Default::default(), + pattern: &expected, + }); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } + } + + #[test] + #[cfg(feature = "json")] + fn json_normalize_matches_array() { + let exp = json!({"people": "{...}"}); + let expected = Data::json(exp); + let actual = json!({ + "people": [ + { + "name": "JohnDoe", + "nickname": "John", + } + ] + }); + let actual = Data::json(actual).normalize(NormalizeMatches { + substitutions: &Default::default(), + pattern: &expected, + }); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } + } + + #[test] + #[cfg(feature = "json")] + fn json_normalize_matches_obj() { + let exp = json!({"people": "{...}"}); + let expected = Data::json(exp); + let actual = json!({ + "people": { + "name": "JohnDoe", + "nickname": "John", + } + }); + let actual = Data::json(actual).normalize(NormalizeMatches { + substitutions: &Default::default(), + pattern: &expected, + }); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } + } + + #[test] + #[cfg(feature = "json")] + fn json_normalize_matches_diff_order_array() { + let exp = json!({ + "people": ["John", "Jane"] + }); + let expected = Data::json(exp); + let actual = json!({ + "people": ["Jane", "John"] + }); + let actual = Data::json(actual).normalize(NormalizeMatches { + substitutions: &Default::default(), + pattern: &expected, + }); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_ne!(exp, act); + } + } +} diff --git a/vendor/snapbox/src/error.rs b/vendor/snapbox/src/error.rs new file mode 100644 index 000000000..55e901883 --- /dev/null +++ b/vendor/snapbox/src/error.rs @@ -0,0 +1,95 @@ +#[derive(Clone, Debug)] +pub struct Error { + inner: String, + backtrace: Option<Backtrace>, +} + +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(), + } + } +} + +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<String> 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<Self> { + 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<Self> { + None + } +} + +#[cfg(not(feature = "debug"))] +impl std::fmt::Display for Backtrace { + fn fmt(&self, _: &mut std::fmt::Formatter) -> std::fmt::Result { + Ok(()) + } +} diff --git a/vendor/snapbox/src/harness.rs b/vendor/snapbox/src/harness.rs new file mode 100644 index 000000000..ee1035aaa --- /dev/null +++ b/vendor/snapbox/src/harness.rs @@ -0,0 +1,212 @@ +//! [`Harness`] for discovering test inputs and asserting against snapshot files +//! +//! # Examples +//! +//! ```rust,no_run +//! snapbox::harness::Harness::new( +//! "tests/fixtures/invalid", +//! setup, +//! test, +//! ) +//! .select(["tests/cases/*.in"]) +//! .action_env("SNAPSHOTS") +//! .test(); +//! +//! fn setup(input_path: std::path::PathBuf) -> snapbox::harness::Case { +//! let name = input_path.file_name().unwrap().to_str().unwrap().to_owned(); +//! let expected = input_path.with_extension("out"); +//! snapbox::harness::Case { +//! name, +//! fixture: input_path, +//! expected, +//! } +//! } +//! +//! fn test(input_path: &std::path::Path) -> Result<usize, Box<dyn std::error::Error>> { +//! let raw = std::fs::read_to_string(input_path)?; +//! let num = raw.parse::<usize>()?; +//! +//! let actual = num + 10; +//! +//! Ok(actual) +//! } +//! ``` + +use crate::data::{DataFormat, NormalizeNewlines}; +use crate::Action; + +use libtest_mimic::Trial; + +pub struct Harness<S, T> { + root: std::path::PathBuf, + overrides: Option<ignore::overrides::Override>, + setup: S, + test: T, + action: Action, +} + +impl<S, T, I, E> Harness<S, T> +where + I: std::fmt::Display, + E: std::fmt::Display, + S: Fn(std::path::PathBuf) -> Case + Send + Sync + 'static, + T: Fn(&std::path::Path) -> Result<I, E> + Send + Sync + 'static + Clone, +{ + pub fn new(root: impl Into<std::path::PathBuf>, setup: S, test: T) -> Self { + Self { + root: root.into(), + overrides: None, + setup, + test, + action: Action::Verify, + } + } + + /// Path patterns for selecting input files + /// + /// This used gitignore syntax + pub fn select<'p>(mut self, patterns: impl IntoIterator<Item = &'p str>) -> Self { + let mut overrides = ignore::overrides::OverrideBuilder::new(&self.root); + for line in patterns { + overrides.add(line).unwrap(); + } + self.overrides = Some(overrides.build().unwrap()); + 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 + } + + /// Override the failure action + pub fn action(mut self, action: Action) -> Self { + self.action = action; + self + } + + /// Run tests + pub fn test(self) -> ! { + let mut walk = ignore::WalkBuilder::new(&self.root); + walk.standard_filters(false); + let tests = walk.build().filter_map(|entry| { + let entry = entry.unwrap(); + let is_dir = entry.file_type().map(|f| f.is_dir()).unwrap_or(false); + let path = entry.into_path(); + if let Some(overrides) = &self.overrides { + overrides + .matched(&path, is_dir) + .is_whitelist() + .then(|| path) + } else { + Some(path) + } + }); + + let tests: Vec<_> = tests + .into_iter() + .map(|path| { + let case = (self.setup)(path); + let test = self.test.clone(); + Trial::test(case.name.clone(), move || { + let actual = (test)(&case.fixture)?; + let actual = actual.to_string(); + let actual = crate::Data::text(actual).normalize(NormalizeNewlines); + let verify = Verifier::new() + .palette(crate::report::Palette::auto()) + .action(self.action); + verify.verify(&case.expected, actual)?; + Ok(()) + }) + .with_ignored_flag(self.action == Action::Ignore) + }) + .collect(); + + let args = libtest_mimic::Arguments::from_args(); + libtest_mimic::run(&args, tests).exit() + } +} + +struct Verifier { + palette: crate::report::Palette, + action: Action, +} + +impl Verifier { + fn new() -> Self { + Default::default() + } + + fn palette(mut self, palette: crate::report::Palette) -> Self { + self.palette = palette; + self + } + + fn action(mut self, action: Action) -> Self { + self.action = action; + self + } + + fn verify(&self, expected_path: &std::path::Path, actual: crate::Data) -> crate::Result<()> { + match self.action { + Action::Skip => Ok(()), + Action::Ignore => { + let _ = self.try_verify(expected_path, actual); + Ok(()) + } + Action::Verify => self.try_verify(expected_path, actual), + Action::Overwrite => self.try_overwrite(expected_path, actual), + } + } + + fn try_overwrite( + &self, + expected_path: &std::path::Path, + actual: crate::Data, + ) -> crate::Result<()> { + actual.write_to(expected_path)?; + Ok(()) + } + + fn try_verify( + &self, + expected_path: &std::path::Path, + actual: crate::Data, + ) -> crate::Result<()> { + let expected = crate::Data::read_from(expected_path, Some(DataFormat::Text))? + .normalize(NormalizeNewlines); + + if expected != actual { + let mut buf = String::new(); + crate::report::write_diff( + &mut buf, + &expected, + &actual, + Some(&expected_path.display()), + None, + self.palette, + ) + .map_err(|e| e.to_string())?; + Err(buf.into()) + } else { + Ok(()) + } + } +} + +impl Default for Verifier { + fn default() -> Self { + Self { + palette: crate::report::Palette::auto(), + action: Action::Verify, + } + } +} + +pub struct Case { + pub name: String, + pub fixture: std::path::PathBuf, + pub expected: std::path::PathBuf, +} diff --git a/vendor/snapbox/src/lib.rs b/vendor/snapbox/src/lib.rs new file mode 100644 index 000000000..61419fd5e --- /dev/null +++ b/vendor/snapbox/src/lib.rs @@ -0,0 +1,246 @@ +//! # 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 +//! - [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/) +//! - `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_eq`][crate::assert_eq] and [`assert_matches`] for reusing diffing / pattern matching for non-snapshot testing +//! - [`assert_eq_path`][crate::assert_eq_path] and [`assert_matches_path`] for one-off assertions with the snapshot stored in a file +//! - [`harness::Harness`] for discovering test inputs and asserting against snapshot files: +//! +//! 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: +//! - [`path::PathFixture`]: 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_matches`] +//! ```rust +//! snapbox::assert_matches("Hello [..] people!", "Hello many people!"); +//! ``` +//! +//! [`Assert`] +//! ```rust,no_run +//! let actual = "..."; +//! let expected_path = "tests/fixtures/help_output_is_clean.txt"; +//! snapbox::Assert::new() +//! .action_env("SNAPSHOTS") +//! .matches_path(expected_path, actual); +//! ``` +//! +//! [`harness::Harness`] +#![cfg_attr(not(feature = "harness"), doc = " ```rust,ignore")] +#![cfg_attr(feature = "harness", doc = " ```rust,no_run")] +//! snapbox::harness::Harness::new( +//! "tests/fixtures/invalid", +//! setup, +//! test, +//! ) +//! .select(["tests/cases/*.in"]) +//! .action_env("SNAPSHOTS") +//! .test(); +//! +//! fn setup(input_path: std::path::PathBuf) -> snapbox::harness::Case { +//! let name = input_path.file_name().unwrap().to_str().unwrap().to_owned(); +//! let expected = input_path.with_extension("out"); +//! snapbox::harness::Case { +//! name, +//! fixture: input_path, +//! expected, +//! } +//! } +//! +//! fn test(input_path: &std::path::Path) -> Result<usize, Box<dyn std::error::Error>> { +//! let raw = std::fs::read_to_string(input_path)?; +//! let num = raw.parse::<usize>()?; +//! +//! let actual = num + 10; +//! +//! Ok(actual) +//! } +//! ``` +//! +//! [trycmd]: https://docs.rs/trycmd + +#![cfg_attr(docsrs, feature(doc_auto_cfg))] + +mod action; +mod assert; +mod data; +mod error; +mod substitutions; + +pub mod cmd; +pub mod path; +pub mod report; +pub mod utils; + +#[cfg(feature = "harness")] +pub mod harness; + +pub use action::Action; +pub use action::DEFAULT_ACTION_ENV; +pub use assert::Assert; +pub use data::Data; +pub use data::DataFormat; +pub use data::{Normalize, NormalizeMatches, NormalizeNewlines, NormalizePaths}; +pub use error::Error; +pub use snapbox_macros::debug; +pub use substitutions::Substitutions; + +pub type Result<T, E = Error> = std::result::Result<T, E>; + +/// Check if a value is the same as an expected value +/// +/// When the content is text, newlines are normalized. +/// +/// ```rust +/// let output = "something"; +/// let expected = "something"; +/// snapbox::assert_matches(expected, output); +/// ``` +#[track_caller] +pub fn assert_eq(expected: impl Into<crate::Data>, actual: impl Into<crate::Data>) { + Assert::new().eq(expected, actual); +} + +/// Check if a value matches a pattern +/// +/// 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 +/// let output = "something"; +/// let expected = "so[..]g"; +/// snapbox::assert_matches(expected, output); +/// ``` +#[track_caller] +pub fn assert_matches(pattern: impl Into<crate::Data>, actual: impl Into<crate::Data>) { + Assert::new().matches(pattern, actual); +} + +/// Check if a value matches the content of a file +/// +/// When the content is text, newlines are normalized. +/// +/// ```rust,no_run +/// let output = "something"; +/// let expected_path = "tests/snapshots/output.txt"; +/// snapbox::assert_eq_path(expected_path, output); +/// ``` +#[track_caller] +pub fn assert_eq_path(expected_path: impl AsRef<std::path::Path>, actual: impl Into<crate::Data>) { + Assert::new() + .action_env(DEFAULT_ACTION_ENV) + .eq_path(expected_path, actual); +} + +/// Check if a value matches the pattern in a file +/// +/// 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 = "something"; +/// let expected_path = "tests/snapshots/output.txt"; +/// snapbox::assert_matches_path(expected_path, output); +/// ``` +#[track_caller] +pub fn assert_matches_path( + pattern_path: impl AsRef<std::path::Path>, + actual: impl Into<crate::Data>, +) { + Assert::new() + .action_env(DEFAULT_ACTION_ENV) + .matches_path(pattern_path, actual); +} + +/// 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 = "path")] +#[track_caller] +pub fn assert_subset_eq( + expected_root: impl Into<std::path::PathBuf>, + actual_root: impl Into<std::path::PathBuf>, +) { + Assert::new() + .action_env(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 = "path")] +#[track_caller] +pub fn assert_subset_matches( + pattern_root: impl Into<std::path::PathBuf>, + actual_root: impl Into<std::path::PathBuf>, +) { + Assert::new() + .action_env(DEFAULT_ACTION_ENV) + .subset_matches(pattern_root, actual_root); +} diff --git a/vendor/snapbox/src/path.rs b/vendor/snapbox/src/path.rs new file mode 100644 index 000000000..16e4ef653 --- /dev/null +++ b/vendor/snapbox/src/path.rs @@ -0,0 +1,686 @@ +//! Initialize working directories and assert on how they've changed + +use crate::data::{NormalizeMatches, NormalizeNewlines, NormalizePaths}; +/// Working directory for tests +#[derive(Debug)] +pub struct PathFixture(PathFixtureInner); + +#[derive(Debug)] +enum PathFixtureInner { + None, + Immutable(std::path::PathBuf), + #[cfg(feature = "path")] + MutablePath(std::path::PathBuf), + #[cfg(feature = "path")] + MutableTemp { + temp: tempfile::TempDir, + path: std::path::PathBuf, + }, +} + +impl PathFixture { + pub fn none() -> Self { + Self(PathFixtureInner::None) + } + + pub fn immutable(target: &std::path::Path) -> Self { + Self(PathFixtureInner::Immutable(target.to_owned())) + } + + #[cfg(feature = "path")] + pub fn mutable_temp() -> Result<Self, crate::Error> { + 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 = canonicalize(temp.path()) + .map_err(|e| format!("Failed to canonicalize {}: {}", temp.path().display(), e))?; + Ok(Self(PathFixtureInner::MutableTemp { temp, path })) + } + + #[cfg(feature = "path")] + pub fn mutable_at(target: &std::path::Path) -> Result<Self, crate::Error> { + 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(PathFixtureInner::MutablePath(target.to_owned()))) + } + + #[cfg(feature = "path")] + pub fn with_template(self, template_root: &std::path::Path) -> Result<Self, crate::Error> { + match &self.0 { + PathFixtureInner::None | PathFixtureInner::Immutable(_) => { + return Err("Sandboxing is disabled".into()); + } + PathFixtureInner::MutablePath(path) | PathFixtureInner::MutableTemp { path, .. } => { + crate::debug!( + "Initializing {} from {}", + path.display(), + template_root.display() + ); + copy_template(template_root, path)?; + } + } + + Ok(self) + } + + pub fn is_mutable(&self) -> bool { + match &self.0 { + PathFixtureInner::None | PathFixtureInner::Immutable(_) => false, + #[cfg(feature = "path")] + PathFixtureInner::MutablePath(_) => true, + #[cfg(feature = "path")] + PathFixtureInner::MutableTemp { .. } => true, + } + } + + pub fn path(&self) -> Option<&std::path::Path> { + match &self.0 { + PathFixtureInner::None => None, + PathFixtureInner::Immutable(path) => Some(path.as_path()), + #[cfg(feature = "path")] + PathFixtureInner::MutablePath(path) => Some(path.as_path()), + #[cfg(feature = "path")] + PathFixtureInner::MutableTemp { path, .. } => Some(path.as_path()), + } + } + + /// Explicitly close to report errors + pub fn close(self) -> Result<(), std::io::Error> { + match self.0 { + PathFixtureInner::None | PathFixtureInner::Immutable(_) => Ok(()), + #[cfg(feature = "path")] + PathFixtureInner::MutablePath(_) => Ok(()), + #[cfg(feature = "path")] + PathFixtureInner::MutableTemp { temp, .. } => temp.close(), + } + } +} + +impl Default for PathFixture { + fn default() -> Self { + Self::none() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PathDiff { + Failure(crate::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 = "path")] + pub fn subset_eq_iter( + pattern_root: impl Into<std::path::PathBuf>, + actual_root: impl Into<std::path::PathBuf>, + ) -> impl Iterator<Item = Result<(std::path::PathBuf, std::path::PathBuf), Self>> { + let pattern_root = pattern_root.into(); + let actual_root = actual_root.into(); + Self::subset_eq_iter_inner(pattern_root, actual_root) + } + + #[cfg(feature = "path")] + pub(crate) fn subset_eq_iter_inner( + expected_root: std::path::PathBuf, + actual_root: std::path::PathBuf, + ) -> impl Iterator<Item = Result<(std::path::PathBuf, std::path::PathBuf), Self>> { + let walker = 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::read_from(&actual_path, None).map_err(Self::Failure)?; + + let expected = crate::Data::read_from(&expected_path, None) + .map(|d| d.normalize(NormalizeNewlines)) + .map_err(Self::Failure)?; + + actual = actual + .try_coerce(expected.format()) + .normalize(NormalizeNewlines); + + 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 = "path")] + pub fn subset_matches_iter( + pattern_root: impl Into<std::path::PathBuf>, + actual_root: impl Into<std::path::PathBuf>, + substitutions: &crate::Substitutions, + ) -> impl Iterator<Item = Result<(std::path::PathBuf, std::path::PathBuf), Self>> + '_ { + let pattern_root = pattern_root.into(); + let actual_root = actual_root.into(); + Self::subset_matches_iter_inner(pattern_root, actual_root, substitutions) + } + + #[cfg(feature = "path")] + pub(crate) fn subset_matches_iter_inner( + expected_root: std::path::PathBuf, + actual_root: std::path::PathBuf, + substitutions: &crate::Substitutions, + ) -> impl Iterator<Item = Result<(std::path::PathBuf, std::path::PathBuf), Self>> + '_ { + let walker = 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::read_from(&actual_path, None).map_err(Self::Failure)?; + + let expected = crate::Data::read_from(&expected_path, None) + .map(|d| d.normalize(NormalizeNewlines)) + .map_err(Self::Failure)?; + + actual = actual + .try_coerce(expected.format()) + .normalize(NormalizePaths) + .normalize(NormalizeNewlines) + .normalize(NormalizeMatches::new(substitutions, &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::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 => {} + } + shallow_copy(expected_path, actual_path) + } + Self::LinkMismatch { + expected_path, + actual_path, + expected_target: _, + actual_target: _, + } => shallow_copy(expected_path, actual_path), + Self::ContentMismatch { + expected_path, + actual_path: _, + expected_content: _, + actual_content, + } => actual_content.write_to(expected_path), + } + } +} + +#[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) + } +} + +/// Recursively walk a path +/// +/// Note: Ignores `.keep` files +#[cfg(feature = "path")] +pub struct Walk { + inner: walkdir::IntoIter, +} + +#[cfg(feature = "path")] +impl Walk { + pub fn new(path: &std::path::Path) -> Self { + Self { + inner: walkdir::WalkDir::new(path).into_iter(), + } + } +} + +#[cfg(feature = "path")] +impl Iterator for Walk { + type Item = Result<std::path::PathBuf, std::io::Error>; + + fn next(&mut self) -> Option<Self::Item> { + 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 [`PathFixture`] +/// +/// Note: Generally you'll use [`PathFixture::with_template`] instead. +/// +/// Note: Ignores `.keep` files +#[cfg(feature = "path")] +pub fn copy_template( + source: impl AsRef<std::path::Path>, + dest: impl AsRef<std::path::Path>, +) -> Result<(), crate::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 +fn shallow_copy(source: &std::path::Path, dest: &std::path::Path) -> Result<(), crate::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 = "path")] +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 = "path"))] +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<std::path::Path>, +) -> Result<std::path::PathBuf, std::io::Error> { + 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) + } +} + +fn canonicalize(path: &std::path::Path) -> Result<std::path::PathBuf, std::io::Error> { + #[cfg(feature = "path")] + { + dunce::canonicalize(path) + } + #[cfg(not(feature = "path"))] + { + // 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() +} + +pub(crate) fn display_relpath(path: impl AsRef<std::path::Path>) -> 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() +} + +#[cfg(test)] +mod test { + use 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); + } +} diff --git a/vendor/snapbox/src/report/color.rs b/vendor/snapbox/src/report/color.rs new file mode 100644 index 000000000..f1cd363b4 --- /dev/null +++ b/vendor/snapbox/src/report/color.rs @@ -0,0 +1,127 @@ +#[derive(Copy, Clone, Debug)] +#[allow(dead_code)] +pub struct Palette { + pub(crate) info: styled::Style, + pub(crate) warn: styled::Style, + pub(crate) error: styled::Style, + pub(crate) hint: styled::Style, + pub(crate) expected: styled::Style, + pub(crate) actual: styled::Style, +} + +impl Palette { + #[cfg(feature = "color")] + pub fn always() -> Self { + Self { + info: styled::Style(yansi::Style::new(yansi::Color::Green)), + warn: styled::Style(yansi::Style::new(yansi::Color::Yellow)), + error: styled::Style(yansi::Style::new(yansi::Color::Red)), + hint: styled::Style(yansi::Style::new(yansi::Color::Unset).dimmed()), + expected: styled::Style(yansi::Style::new(yansi::Color::Green).underline()), + actual: styled::Style(yansi::Style::new(yansi::Color::Red).underline()), + } + } + + #[cfg(not(feature = "color"))] + pub fn always() -> Self { + Self::never() + } + + pub fn never() -> Self { + Self { + info: Default::default(), + warn: Default::default(), + error: Default::default(), + hint: Default::default(), + expected: Default::default(), + actual: Default::default(), + } + } + + pub fn auto() -> Self { + if is_colored() { + Self::always() + } else { + Self::never() + } + } + + pub fn info<D: std::fmt::Display>(self, item: D) -> Styled<D> { + self.info.paint(item) + } + + pub fn warn<D: std::fmt::Display>(self, item: D) -> Styled<D> { + self.warn.paint(item) + } + + pub fn error<D: std::fmt::Display>(self, item: D) -> Styled<D> { + self.error.paint(item) + } + + pub fn hint<D: std::fmt::Display>(self, item: D) -> Styled<D> { + self.hint.paint(item) + } + + pub fn expected<D: std::fmt::Display>(self, item: D) -> Styled<D> { + self.expected.paint(item) + } + + pub fn actual<D: std::fmt::Display>(self, item: D) -> Styled<D> { + self.actual.paint(item) + } +} + +fn is_colored() -> bool { + #[cfg(feature = "color")] + { + concolor::get(concolor::Stream::Either).ansi_color() + } + + #[cfg(not(feature = "color"))] + { + false + } +} + +pub(crate) use styled::Style; +pub use styled::Styled; + +#[cfg(feature = "color")] +mod styled { + #[derive(Copy, Clone, Debug, Default)] + pub(crate) struct Style(pub(crate) yansi::Style); + + impl Style { + pub(crate) fn paint<T: std::fmt::Display>(self, item: T) -> Styled<T> { + Styled(self.0.paint(item)) + } + } + + pub struct Styled<D: std::fmt::Display>(yansi::Paint<D>); + + impl<D: std::fmt::Display> std::fmt::Display for Styled<D> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } + } +} + +#[cfg(not(feature = "color"))] +mod styled { + #[derive(Copy, Clone, Debug, Default)] + pub(crate) struct Style; + + impl Style { + pub(crate) fn paint<T: std::fmt::Display>(self, item: T) -> Styled<T> { + Styled(item) + } + } + + pub struct Styled<D: std::fmt::Display>(D); + + impl<D: std::fmt::Display> std::fmt::Display for Styled<D> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } + } +} diff --git a/vendor/snapbox/src/report/diff.rs b/vendor/snapbox/src/report/diff.rs new file mode 100644 index 000000000..adc9f7935 --- /dev/null +++ b/vendor/snapbox/src/report/diff.rs @@ -0,0 +1,384 @@ +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), Some(actual)) = (expected.render(), actual.render()) { + write_diff_inner( + writer, + &expected, + &actual, + expected_name, + actual_name, + palette, + )?; + rendered = true; + } + + if !rendered { + if let Some(expected_name) = expected_name { + writeln!(writer, "{} {}:", expected_name, palette.info("(expected)"))?; + } else { + writeln!(writer, "{}:", palette.info("Expected"))?; + } + writeln!(writer, "{}", palette.info(&expected))?; + if let Some(actual_name) = actual_name { + writeln!(writer, "{} {}:", actual_name, palette.error("(actual)"))?; + } else { + writeln!(writer, "{}:", palette.error("Actual"))?; + } + writeln!(writer, "{}", palette.error(&actual))?; + } + Ok(()) +} + +#[cfg(feature = "diff")] +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, +) -> 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.info(format_args!("{:->4} expected: {}", "", expected_name)) + )?; + } else { + writeln!(writer, "{}", palette.info(format_args!("--- Expected")))?; + } + if let Some(actual_name) = actual_name { + writeln!( + writer, + "{}", + palette.error(format_args!("{:+>4} actual: {}", "", actual_name)) + )?; + } else { + writeln!(writer, "{}", palette.error(format_args!("+++ Actual")))?; + } + let changes = changes + .ops() + .iter() + .flat_map(|op| changes.iter_inline_changes(op)) + .collect::<Vec<_>>(); + 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.error, palette)?; + } + similar::ChangeTag::Delete => { + write_change(writer, change, "-", palette.expected, palette.info, palette)?; + } + similar::ChangeTag::Equal => { + write_change(writer, change, "|", palette.hint, palette.hint, palette)?; + } + } + } + } + + Ok(()) +} + +#[cfg(feature = "diff")] +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, +) -> Result<(), std::fmt::Error> { + if let Some(index) = change.old_index() { + write!(writer, "{:>4} ", palette.hint(index + 1),)?; + } else { + write!(writer, "{:>4} ", " ",)?; + } + if let Some(index) = change.new_index() { + write!(writer, "{:>4} ", palette.hint(index + 1),)?; + } else { + write!(writer, "{:>4} ", " ",)?; + } + write!(writer, "{} ", style.paint(sign))?; + for &(emphasized, change) in change.values() { + let cur_style = if emphasized { em_style } else { style }; + write!(writer, "{}", cur_style.paint(change))?; + } + if change.missing_newline() { + writeln!(writer, "{}", em_style.paint("∅"))?; + } + + 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::never(); + + let mut actual_diff = String::new(); + write_diff_inner( + &mut actual_diff, + expected, + actual, + Some(&expected_name), + Some(&actual_name), + palette, + ) + .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::never(); + + let mut actual_diff = String::new(); + write_diff_inner( + &mut actual_diff, + expected, + actual, + Some(&expected_name), + Some(&actual_name), + palette, + ) + .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::never(); + + let mut actual_diff = String::new(); + write_diff_inner( + &mut actual_diff, + expected, + actual, + Some(&expected_name), + Some(&actual_name), + palette, + ) + .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::never(); + + let mut actual_diff = String::new(); + write_diff_inner( + &mut actual_diff, + expected, + actual, + Some(&expected_name), + Some(&actual_name), + palette, + ) + .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::never(); + + let mut actual_diff = String::new(); + write_diff_inner( + &mut actual_diff, + &expected, + &actual, + Some(&expected_name), + Some(&actual_name), + palette, + ) + .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); + } +} diff --git a/vendor/snapbox/src/report/mod.rs b/vendor/snapbox/src/report/mod.rs new file mode 100644 index 000000000..6c9a238b8 --- /dev/null +++ b/vendor/snapbox/src/report/mod.rs @@ -0,0 +1,9 @@ +//! 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; diff --git a/vendor/snapbox/src/substitutions.rs b/vendor/snapbox/src/substitutions.rs new file mode 100644 index 000000000..9c228172b --- /dev/null +++ b/vendor/snapbox/src/substitutions.rs @@ -0,0 +1,420 @@ +use std::borrow::Cow; + +/// Match pattern expressions, see [`Assert`][crate::Assert] +/// +/// Built-in expressions: +/// - `...` on a line of its own: match multiple complete lines +/// - `[..]`: match multiple characters within a line +#[derive(Default, Clone, Debug, PartialEq, Eq)] +pub struct Substitutions { + vars: std::collections::BTreeMap<&'static str, Cow<'static, str>>, + unused: std::collections::BTreeSet<&'static str>, +} + +impl Substitutions { + pub fn new() -> Self { + Default::default() + } + + pub(crate) fn with_exe() -> Self { + let mut substitutions = Self::new(); + substitutions + .insert("[EXE]", std::env::consts::EXE_SUFFIX) + .unwrap(); + substitutions + } + + /// Insert an additional match pattern + /// + /// `key` must be enclosed in `[` and `]`. + /// + /// ```rust + /// let mut subst = snapbox::Substitutions::new(); + /// subst.insert("[EXE]", std::env::consts::EXE_SUFFIX); + /// ``` + pub fn insert( + &mut self, + key: &'static str, + value: impl Into<Cow<'static, str>>, + ) -> Result<(), crate::Error> { + let key = validate_key(key)?; + let value = value.into(); + if value.is_empty() { + self.unused.insert(key); + } else { + self.vars + .insert(key, crate::utils::normalize_text(value.as_ref()).into()); + } + Ok(()) + } + + /// Insert additional match patterns + /// + /// keys must be enclosed in `[` and `]`. + pub fn extend( + &mut self, + vars: impl IntoIterator<Item = (&'static str, impl Into<Cow<'static, str>>)>, + ) -> Result<(), crate::Error> { + for (key, value) in vars { + self.insert(key, value)?; + } + Ok(()) + } + + /// Apply match pattern to `input` + /// + /// If `pattern` matches `input`, then `pattern` is returned. + /// + /// Otherwise, `input`, with as many patterns replaced as possible, will be returned. + /// + /// ```rust + /// let subst = snapbox::Substitutions::new(); + /// let output = subst.normalize("Hello World!", "Hello [..]!"); + /// assert_eq!(output, "Hello [..]!"); + /// ``` + pub fn normalize(&self, input: &str, pattern: &str) -> String { + normalize(input, pattern, self) + } + + fn substitute<'v>(&self, value: &'v str) -> Cow<'v, str> { + let mut value = Cow::Borrowed(value); + for (var, replace) in self.vars.iter() { + debug_assert!(!replace.is_empty()); + value = Cow::Owned(value.replace(replace.as_ref(), var)); + } + value + } + + fn clear<'v>(&self, pattern: &'v str) -> Cow<'v, str> { + if pattern.contains('[') { + let mut pattern = Cow::Borrowed(pattern); + for var in self.unused.iter() { + pattern = Cow::Owned(pattern.replace(var, "")); + } + pattern + } else { + Cow::Borrowed(pattern) + } + } +} + +fn validate_key(key: &'static str) -> Result<&'static str, crate::Error> { + if !key.starts_with('[') || !key.ends_with(']') { + return Err(format!("Key `{}` is not enclosed in []", key).into()); + } + + if key[1..(key.len() - 1)] + .find(|c: char| !c.is_ascii_uppercase()) + .is_some() + { + return Err(format!("Key `{}` can only be A-Z but ", key).into()); + } + + Ok(key) +} + +fn normalize(input: &str, pattern: &str, substitutions: &Substitutions) -> String { + if input == pattern { + return input.to_owned(); + } + + let mut normalized: Vec<Cow<str>> = Vec::new(); + let input_lines: Vec<_> = crate::utils::LinesWithTerminator::new(input).collect(); + let pattern_lines: Vec<_> = crate::utils::LinesWithTerminator::new(pattern).collect(); + + let mut input_index = 0; + let mut pattern_index = 0; + 'outer: loop { + let pattern_line = if let Some(pattern_line) = pattern_lines.get(pattern_index) { + *pattern_line + } else { + normalized.extend( + input_lines[input_index..] + .iter() + .copied() + .map(|s| substitutions.substitute(s)), + ); + break 'outer; + }; + let next_pattern_index = pattern_index + 1; + + let input_line = if let Some(input_line) = input_lines.get(input_index) { + *input_line + } else { + break 'outer; + }; + let next_input_index = input_index + 1; + + if line_matches(input_line, pattern_line, substitutions) { + pattern_index = next_pattern_index; + input_index = next_input_index; + normalized.push(Cow::Borrowed(pattern_line)); + continue 'outer; + } else if is_line_elide(pattern_line) { + let next_pattern_line: &str = + if let Some(pattern_line) = pattern_lines.get(next_pattern_index) { + pattern_line + } else { + normalized.push(Cow::Borrowed(pattern_line)); + break 'outer; + }; + if let Some(future_input_index) = input_lines[input_index..] + .iter() + .enumerate() + .find(|(_, l)| **l == next_pattern_line) + .map(|(i, _)| input_index + i) + { + normalized.push(Cow::Borrowed(pattern_line)); + pattern_index = next_pattern_index; + input_index = future_input_index; + continue 'outer; + } else { + normalized.extend( + input_lines[input_index..] + .iter() + .copied() + .map(|s| substitutions.substitute(s)), + ); + break 'outer; + } + } else { + // Find where we can pick back up for normalizing + for future_input_index in next_input_index..input_lines.len() { + let future_input_line = input_lines[future_input_index]; + if let Some(future_pattern_index) = pattern_lines[next_pattern_index..] + .iter() + .enumerate() + .find(|(_, l)| **l == future_input_line || is_line_elide(**l)) + .map(|(i, _)| next_pattern_index + i) + { + normalized.extend( + input_lines[input_index..future_input_index] + .iter() + .copied() + .map(|s| substitutions.substitute(s)), + ); + pattern_index = future_pattern_index; + input_index = future_input_index; + continue 'outer; + } + } + + normalized.extend( + input_lines[input_index..] + .iter() + .copied() + .map(|s| substitutions.substitute(s)), + ); + break 'outer; + } + } + + normalized.join("") +} + +fn is_line_elide(line: &str) -> bool { + line == "...\n" || line == "..." +} + +fn line_matches(line: &str, pattern: &str, substitutions: &Substitutions) -> bool { + if line == pattern { + return true; + } + + let subbed = substitutions.substitute(line); + let mut line = subbed.as_ref(); + + let pattern = substitutions.clear(pattern); + + let mut sections = pattern.split("[..]").peekable(); + while let Some(section) = sections.next() { + if let Some(remainder) = line.strip_prefix(section) { + if let Some(next_section) = sections.peek() { + if next_section.is_empty() { + line = ""; + } else if let Some(restart_index) = remainder.find(next_section) { + line = &remainder[restart_index..]; + } + } else { + return remainder.is_empty(); + } + } else { + return false; + } + } + + false +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn empty() { + let input = ""; + let pattern = ""; + let expected = ""; + let actual = normalize(input, pattern, &Substitutions::new()); + assert_eq!(expected, actual); + } + + #[test] + fn literals_match() { + let input = "Hello\nWorld"; + let pattern = "Hello\nWorld"; + let expected = "Hello\nWorld"; + let actual = normalize(input, pattern, &Substitutions::new()); + assert_eq!(expected, actual); + } + + #[test] + fn pattern_shorter() { + let input = "Hello\nWorld"; + let pattern = "Hello\n"; + let expected = "Hello\nWorld"; + let actual = normalize(input, pattern, &Substitutions::new()); + assert_eq!(expected, actual); + } + + #[test] + fn input_shorter() { + let input = "Hello\n"; + let pattern = "Hello\nWorld"; + let expected = "Hello\n"; + let actual = normalize(input, pattern, &Substitutions::new()); + assert_eq!(expected, actual); + } + + #[test] + fn all_different() { + let input = "Hello\nWorld"; + let pattern = "Goodbye\nMoon"; + let expected = "Hello\nWorld"; + let actual = normalize(input, pattern, &Substitutions::new()); + assert_eq!(expected, actual); + } + + #[test] + fn middles_diverge() { + let input = "Hello\nWorld\nGoodbye"; + let pattern = "Hello\nMoon\nGoodbye"; + let expected = "Hello\nWorld\nGoodbye"; + let actual = normalize(input, pattern, &Substitutions::new()); + assert_eq!(expected, actual); + } + + #[test] + fn leading_elide() { + let input = "Hello\nWorld\nGoodbye"; + let pattern = "...\nGoodbye"; + let expected = "...\nGoodbye"; + let actual = normalize(input, pattern, &Substitutions::new()); + assert_eq!(expected, actual); + } + + #[test] + fn trailing_elide() { + let input = "Hello\nWorld\nGoodbye"; + let pattern = "Hello\n..."; + let expected = "Hello\n..."; + let actual = normalize(input, pattern, &Substitutions::new()); + assert_eq!(expected, actual); + } + + #[test] + fn middle_elide() { + let input = "Hello\nWorld\nGoodbye"; + let pattern = "Hello\n...\nGoodbye"; + let expected = "Hello\n...\nGoodbye"; + let actual = normalize(input, pattern, &Substitutions::new()); + assert_eq!(expected, actual); + } + + #[test] + fn post_elide_diverge() { + let input = "Hello\nSun\nAnd\nWorld"; + let pattern = "Hello\n...\nMoon"; + let expected = "Hello\nSun\nAnd\nWorld"; + let actual = normalize(input, pattern, &Substitutions::new()); + assert_eq!(expected, actual); + } + + #[test] + fn post_diverge_elide() { + let input = "Hello\nWorld\nGoodbye\nSir"; + let pattern = "Hello\nMoon\nGoodbye\n..."; + let expected = "Hello\nWorld\nGoodbye\n..."; + let actual = normalize(input, pattern, &Substitutions::new()); + assert_eq!(expected, actual); + } + + #[test] + fn inline_elide() { + let input = "Hello\nWorld\nGoodbye\nSir"; + let pattern = "Hello\nW[..]d\nGoodbye\nSir"; + let expected = "Hello\nW[..]d\nGoodbye\nSir"; + let actual = normalize(input, pattern, &Substitutions::new()); + assert_eq!(expected, actual); + } + + #[test] + fn 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, &Substitutions::new()); + assert_eq!(expected, actual, "line={:?} pattern={:?}", line, pattern); + } + } + + #[test] + fn test_validate_key() { + let cases = [ + ("[HELLO", false), + ("HELLO]", false), + ("[HELLO]", true), + ("[hello]", false), + ("[HE O]", false), + ]; + for (key, expected) in cases { + let actual = validate_key(key).is_ok(); + assert_eq!(expected, actual, "key={:?}", key); + } + } +} diff --git a/vendor/snapbox/src/utils/lines.rs b/vendor/snapbox/src/utils/lines.rs new file mode 100644 index 000000000..f56408483 --- /dev/null +++ b/vendor/snapbox/src/utils/lines.rs @@ -0,0 +1,31 @@ +#[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) + } + } + } +} diff --git a/vendor/snapbox/src/utils/mod.rs b/vendor/snapbox/src/utils/mod.rs new file mode 100644 index 000000000..d51924196 --- /dev/null +++ b/vendor/snapbox/src/utils/mod.rs @@ -0,0 +1,30 @@ +mod lines; + +pub use lines::LinesWithTerminator; + +/// Normalize line endings +pub fn normalize_lines(data: &str) -> String { + normalize_lines_chars(data.chars()).collect() +} + +fn normalize_lines_chars(data: impl Iterator<Item = char>) -> impl Iterator<Item = char> { + normalize_line_endings::normalized(data) +} + +/// Normalize path separators +pub fn normalize_paths(data: &str) -> String { + normalize_paths_chars(data.chars()).collect() +} + +fn normalize_paths_chars(data: impl Iterator<Item = char>) -> impl Iterator<Item = char> { + data.map(|c| if c == '\\' { '/' } else { c }) +} + +/// "Smart" text normalization +/// +/// This includes +/// - Line endings +/// - Path separators +pub fn normalize_text(data: &str) -> String { + normalize_paths_chars(normalize_lines_chars(data.chars())).collect() +} |