diff --git a/crates/snapbox/src/assert.rs b/crates/snapbox/src/assert.rs index f0267c07..0ad169bf 100644 --- a/crates/snapbox/src/assert.rs +++ b/crates/snapbox/src/assert.rs @@ -5,7 +5,7 @@ use anstream::stderr; #[cfg(not(feature = "color"))] use std::io::stderr; -use crate::data::{DataFormat, NormalizeMatches, NormalizeNewlines, NormalizePaths}; +use crate::data::{NormalizeMatches, NormalizeNewlines, NormalizePaths}; use crate::Action; /// Snapshot assertion against a file's contents @@ -15,10 +15,10 @@ use crate::Action; /// # Examples /// /// ```rust,no_run -/// let actual = "..."; -/// snapbox::Assert::new() -/// .action_env("SNAPSHOTS") -/// .matches_path(actual, "tests/fixtures/help_output_is_clean.txt"); +/// # use snapbox::Assert; +/// # use snapbox::expect_file; +/// let actual = "something"; +/// Assert::new().matches(expect_file!["output.txt"], actual); /// ``` #[derive(Clone, Debug)] pub struct Assert { @@ -27,7 +27,6 @@ pub struct Assert { normalize_paths: bool, substitutions: crate::Substitutions, pub(crate) palette: crate::report::Palette, - pub(crate) data_format: Option, } /// # Assertions @@ -41,9 +40,18 @@ impl Assert { /// When the content is text, newlines are normalized. /// /// ```rust - /// let output = "something"; + /// # use snapbox::Assert; + /// let actual = "something"; /// let expected = "something"; - /// snapbox::Assert::new().eq(expected, output); + /// Assert::new().eq(expected, actual); + /// ``` + /// + /// Can combine this with [`expect_file!`][crate::expect_file] + /// ```rust,no_run + /// # use snapbox::Assert; + /// # use snapbox::expect_file; + /// let actual = "something"; + /// Assert::new().eq(expect_file!["output.txt"], actual); /// ``` #[track_caller] pub fn eq(&self, expected: impl Into, actual: impl Into) { @@ -54,65 +62,6 @@ impl Assert { #[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, actual: impl Into) { - 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, - actual: impl Into, - ) { - 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; @@ -120,47 +69,45 @@ impl Assert { 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, - ); + self.do_action(expected, actual, Some(&"In-memory")); } - /// Check if a value matches the pattern in a file + /// 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 (override with [`Assert::substitutions`]) + /// - `[EXE]` matches `.exe` on Windows /// /// Normalization: /// - Newlines /// - `\` to `/` /// + /// ```rust + /// # use snapbox::Assert; + /// let actual = "something"; + /// let expected = "so[..]g"; + /// Assert::new().matches(expected, actual); + /// ``` + /// + /// Can combine this with [`expect_file!`][crate::expect_file] /// ```rust,no_run - /// let output = "something"; - /// let expected_path = "tests/snapshots/output.txt"; - /// snapbox::Assert::new().matches_path(expected_path, output); + /// # use snapbox::Assert; + /// # use snapbox::expect_file; + /// let actual = "something"; + /// Assert::new().matches(expect_file!["output.txt"], actual); /// ``` #[track_caller] - pub fn matches_path( - &self, - pattern_path: impl AsRef, - actual: impl Into, - ) { - let pattern_path = pattern_path.as_ref(); + pub fn matches(&self, pattern: impl Into, actual: impl Into) { + let pattern = pattern.into(); let actual = actual.into(); - self.matches_path_inner(pattern_path, actual); + self.matches_inner(pattern, actual); } #[track_caller] - fn matches_path_inner(&self, pattern_path: &std::path::Path, actual: crate::Data) { + fn matches_inner(&self, pattern: crate::Data, actual: crate::Data) { match self.action { Action::Skip => { return; @@ -168,29 +115,19 @@ impl Assert { 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); + let (expected, actual) = self.normalize_match(pattern, actual); - self.do_action( - expected, - actual, - Some(&crate::path::display_relpath(pattern_path)), - Some(&"In-memory"), - pattern_path, - ); + self.do_action(expected, actual, Some(&"In-memory")); } pub(crate) fn normalize_eq( &self, - expected: crate::Result, + expected: crate::Data, mut actual: crate::Data, - ) -> (crate::Result, crate::Data) { - let expected = expected.map(|d| d.normalize(NormalizeNewlines)); + ) -> (crate::Data, crate::Data) { + let expected = expected.normalize(NormalizeNewlines); // On `expected` being an error, make a best guess - let format = expected - .as_ref() - .map(|d| d.format()) - .unwrap_or(DataFormat::Text); + let format = expected.format(); actual = actual.try_coerce(format).normalize(NormalizeNewlines); @@ -199,12 +136,12 @@ impl Assert { pub(crate) fn normalize_match( &self, - expected: crate::Result, + expected: crate::Data, mut actual: crate::Data, - ) -> (crate::Result, crate::Data) { - let expected = expected.map(|d| d.normalize(NormalizeNewlines)); + ) -> (crate::Data, crate::Data) { + let expected = expected.normalize(NormalizeNewlines); // On `expected` being an error, make a best guess - let format = expected.as_ref().map(|e| e.format()).unwrap_or_default(); + let format = expected.format(); actual = actual.try_coerce(format); if self.normalize_paths { @@ -214,9 +151,7 @@ impl Assert { 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)); - } + actual = actual.normalize(NormalizeMatches::new(&self.substitutions, &expected)); (expected, actual) } @@ -224,14 +159,11 @@ impl Assert { #[track_caller] pub(crate) fn do_action( &self, - expected: crate::Result, + expected: 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)); + let result = self.try_verify(&expected, &actual, actual_name); if let Err(err) = result { match self.action { Action::Skip => unreachable!("Bailed out earlier"), @@ -246,7 +178,9 @@ impl Assert { ); } Action::Verify => { - let message = if let Some(action_var) = self.action_var.as_deref() { + let message = if expected.source().is_none() { + crate::report::Styled::new(String::new(), Default::default()) + } else if let Some(action_var) = self.action_var.as_deref() { self.palette .hint(format!("Update with {}=overwrite", action_var)) } else { @@ -257,8 +191,12 @@ impl Assert { Action::Overwrite => { use std::io::Write; - let _ = writeln!(stderr(), "{}: {}", self.palette.warn("Fixing"), err); - actual.write_to(expected_path).unwrap(); + if let Some(source) = expected.source() { + let _ = writeln!(stderr(), "{}: {}", self.palette.warn("Fixing"), err); + actual.write_to(source).unwrap(); + } else { + panic!("{err}"); + } } } } @@ -268,7 +206,6 @@ impl Assert { &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 { @@ -277,7 +214,7 @@ impl Assert { &mut buf, expected, actual, - expected_name, + expected.source().map(|s| s as &dyn std::fmt::Display), actual_name, self.palette, ) @@ -487,22 +424,6 @@ impl Assert { 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 { - self.data_format - } } impl Default for Assert { @@ -513,7 +434,6 @@ impl Default for Assert { normalize_paths: true, substitutions: Default::default(), palette: crate::report::Palette::color(), - data_format: Default::default(), } .substitutions(crate::Substitutions::with_exe()) } diff --git a/crates/snapbox/src/cmd.rs b/crates/snapbox/src/cmd.rs index 3b2a19f7..39b170a5 100644 --- a/crates/snapbox/src/cmd.rs +++ b/crates/snapbox/src/cmd.rs @@ -326,11 +326,13 @@ impl Command { // 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 stdin = self + .stdin + .as_ref() + .map(|d| d.to_bytes()) + .transpose() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?; + let stdout = process_single_io(&mut child, reader, stdin)?; let status = wait(child, self.timeout)?; let stdout = stdout.join().unwrap().ok().unwrap_or_default(); @@ -348,8 +350,13 @@ impl Command { 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 stdin = self + .stdin + .as_ref() + .map(|d| d.to_bytes()) + .transpose() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?; + let (stdout, stderr) = process_split_io(&mut child, stdin)?; let status = wait(child, self.timeout)?; let stdout = stdout @@ -591,60 +598,30 @@ impl OutputAssert { /// .assert() /// .stdout_eq("hello"); /// ``` - #[track_caller] - pub fn stdout_eq(self, expected: impl Into) -> 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`. /// + /// Can combine this with [`expect_file!`][crate::expect_file] /// ```rust,no_run /// use snapbox::cmd::Command; /// use snapbox::cmd::cargo_bin; + /// use snapbox::expect_file; /// /// let assert = Command::new(cargo_bin("snap-fixture")) /// .env("stdout", "hello") /// .env("stderr", "world") /// .assert() - /// .stdout_eq_path("tests/snapshots/output.txt"); + /// .stdout_eq(expect_file!["stdout.log"]); /// ``` #[track_caller] - pub fn stdout_eq_path(self, expected_path: impl AsRef) -> Self { - let expected_path = expected_path.as_ref(); - self.stdout_eq_path_inner(expected_path) + pub fn stdout_eq(self, expected: impl Into) -> Self { + let expected = expected.into(); + self.stdout_eq_inner(expected) } #[track_caller] - fn stdout_eq_path_inner(self, expected_path: &std::path::Path) -> Self { + fn stdout_eq_inner(self, expected: crate::Data) -> 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.config.do_action(pattern, actual, Some(&"stdout")); self } @@ -661,60 +638,30 @@ impl OutputAssert { /// .assert() /// .stdout_matches("he[..]o"); /// ``` - #[track_caller] - pub fn stdout_matches(self, expected: impl Into) -> 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`. /// + /// Can combine this with [`expect_file!`][crate::expect_file] /// ```rust,no_run /// use snapbox::cmd::Command; /// use snapbox::cmd::cargo_bin; + /// use snapbox::expect_file; /// /// let assert = Command::new(cargo_bin("snap-fixture")) /// .env("stdout", "hello") /// .env("stderr", "world") /// .assert() - /// .stdout_matches_path("tests/snapshots/output.txt"); + /// .stdout_matches(expect_file!["stdout.log"]); /// ``` #[track_caller] - pub fn stdout_matches_path(self, expected_path: impl AsRef) -> Self { - let expected_path = expected_path.as_ref(); - self.stdout_matches_path_inner(expected_path) + pub fn stdout_matches(self, expected: impl Into) -> Self { + let expected = expected.into(); + self.stdout_matches_inner(expected) } #[track_caller] - fn stdout_matches_path_inner(self, expected_path: &std::path::Path) -> Self { + fn stdout_matches_inner(self, expected: crate::Data) -> 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.config.do_action(pattern, actual, Some(&"stdout")); self } @@ -731,60 +678,30 @@ impl OutputAssert { /// .assert() /// .stderr_eq("world"); /// ``` - #[track_caller] - pub fn stderr_eq(self, expected: impl Into) -> 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`. /// + /// Can combine this with [`expect_file!`][crate::expect_file] /// ```rust,no_run /// use snapbox::cmd::Command; /// use snapbox::cmd::cargo_bin; + /// use snapbox::expect_file; /// /// let assert = Command::new(cargo_bin("snap-fixture")) /// .env("stdout", "hello") /// .env("stderr", "world") /// .assert() - /// .stderr_eq_path("tests/snapshots/err.txt"); + /// .stderr_eq(expect_file!["stderr.log"]); /// ``` #[track_caller] - pub fn stderr_eq_path(self, expected_path: impl AsRef) -> Self { - let expected_path = expected_path.as_ref(); - self.stderr_eq_path_inner(expected_path) + pub fn stderr_eq(self, expected: impl Into) -> Self { + let expected = expected.into(); + self.stderr_eq_inner(expected) } #[track_caller] - fn stderr_eq_path_inner(self, expected_path: &std::path::Path) -> Self { + fn stderr_eq_inner(self, expected: crate::Data) -> 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.config.do_action(pattern, actual, Some(&"stderr")); self } @@ -801,73 +718,34 @@ impl OutputAssert { /// .assert() /// .stderr_matches("wo[..]d"); /// ``` - #[track_caller] - pub fn stderr_matches(self, expected: impl Into) -> 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`. /// + /// Can combine this with [`expect_file!`][crate::expect_file] /// ```rust,no_run /// use snapbox::cmd::Command; /// use snapbox::cmd::cargo_bin; + /// use snapbox::expect_file; /// /// let assert = Command::new(cargo_bin("snap-fixture")) /// .env("stdout", "hello") /// .env("stderr", "world") /// .assert() - /// .stderr_matches_path("tests/snapshots/err.txt"); + /// .stderr_matches(expect_file!["stderr.log"]); /// ``` #[track_caller] - pub fn stderr_matches_path(self, expected_path: impl AsRef) -> Self { - let expected_path = expected_path.as_ref(); - self.stderr_matches_path_inner(expected_path) + pub fn stderr_matches(self, expected: impl Into) -> Self { + let expected = expected.into(); + self.stderr_matches_inner(expected) } #[track_caller] - fn stderr_matches_path_inner(self, expected_path: &std::path::Path) -> Self { + fn stderr_matches_inner(self, expected: crate::Data) -> 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.config.do_action(pattern, actual, Some(&"stderr")); 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:")?; diff --git a/crates/snapbox/src/data.rs b/crates/snapbox/src/data.rs index b892ffd1..c47c87d1 100644 --- a/crates/snapbox/src/data.rs +++ b/crates/snapbox/src/data.rs @@ -1,13 +1,38 @@ +/// Declare an expected value for an assert from a file +/// +/// This is relative to the source file the macro is run from +/// +/// ``` +/// # use snapbox::expect_file; +/// expect_file!["./test_data/bar.html"]; +/// expect_file![]; +/// ``` +#[macro_export] +macro_rules! expect_file { + [$path:expr] => {{ + let mut path = $crate::current_dir!(); + path.push($path); + $crate::Data::read_from(&path, None) + }}; + [] => {{ + let path = std::path::Path::new(file!()).file_stem().unwrap(); + let path = format!("snapshots/{}-{}.txt", path.to_str().unwrap(), line!()); + $crate::expect_file![path] + }}; +} + /// Test fixture, actual output, or expected result /// /// This provides conveniences for tracking the intended format (binary vs text). -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug)] pub struct Data { inner: DataInner, + source: Option, } #[derive(Clone, Debug, PartialEq, Eq)] enum DataInner { + Error(crate::Error), Binary(Vec), Text(String), #[cfg(feature = "json")] @@ -16,6 +41,7 @@ enum DataInner { #[derive(Clone, Debug, PartialEq, Eq, Copy, Hash, Default)] pub enum DataFormat { + Error, Binary, #[default] Text, @@ -28,6 +54,7 @@ impl Data { pub fn binary(raw: impl Into>) -> Self { Self { inner: DataInner::Binary(raw.into()), + source: None, } } @@ -35,6 +62,7 @@ impl Data { pub fn text(raw: impl Into) -> Self { Self { inner: DataInner::Text(raw.into()), + source: None, } } @@ -42,6 +70,14 @@ impl Data { pub fn json(raw: impl Into) -> Self { Self { inner: DataInner::Json(raw.into()), + source: None, + } + } + + fn error(raw: impl Into) -> Self { + Self { + inner: DataInner::Error(raw.into()), + source: None, } } @@ -50,13 +86,27 @@ impl Data { Self::text("") } + fn with_path(mut self, path: impl Into) -> Self { + self.source = Some(DataSource::path(path)); + self + } + + /// Load test data from a file + pub fn read_from(path: &std::path::Path, data_format: Option) -> Self { + match Self::try_read_from(path, data_format) { + Ok(data) => data, + Err(err) => Self::error(err), + } + } + /// Load test data from a file - pub fn read_from( + pub fn try_read_from( path: &std::path::Path, data_format: Option, ) -> Result { let data = match data_format { Some(df) => match df { + DataFormat::Error => Self::error("unknown error"), DataFormat::Binary => { let data = std::fs::read(path) .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; @@ -89,17 +139,30 @@ impl Data { } } }; - Ok(data) + Ok(data.with_path(path)) + } + + /// Location the data came from + pub fn source(&self) -> Option<&DataSource> { + self.source.as_ref() + } + + /// Overwrite a snapshot + pub fn write_to(&self, source: &DataSource) -> Result<(), crate::Error> { + match &source.inner { + DataSourceInner::Path(p) => self.write_to_path(p), + } } /// Overwrite a snapshot - pub fn write_to(&self, path: &std::path::Path) -> Result<(), crate::Error> { + pub fn write_to_path(&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()) + let bytes = self.to_bytes()?; + std::fs::write(path, bytes) .map_err(|e| format!("Failed to write {}: {}", path.display(), e).into()) } @@ -115,6 +178,7 @@ impl Data { /// Note: this will not inspect binary data for being a valid `String`. pub fn render(&self) -> Option { match &self.inner { + DataInner::Error(_) => None, DataInner::Binary(_) => None, DataInner::Text(data) => Some(data.to_owned()), #[cfg(feature = "json")] @@ -122,17 +186,25 @@ impl Data { } } - pub fn to_bytes(&self) -> Vec { + pub fn to_bytes(&self) -> Result, crate::Error> { match &self.inner { - DataInner::Binary(data) => data.clone(), - DataInner::Text(data) => data.clone().into_bytes(), + DataInner::Error(err) => Err(err.clone()), + DataInner::Binary(data) => Ok(data.clone()), + DataInner::Text(data) => Ok(data.clone().into_bytes()), #[cfg(feature = "json")] - DataInner::Json(value) => serde_json::to_vec_pretty(value).unwrap(), + DataInner::Json(value) => { + serde_json::to_vec_pretty(value).map_err(|err| format!("{err}").into()) + } } } pub fn try_coerce(self, format: DataFormat) -> Self { - match (self.inner, format) { + let mut data = match (self.inner, format) { + (DataInner::Error(inner), _) => Self::error(inner), + (inner, DataFormat::Error) => Self { + inner, + source: None, + }, (DataInner::Binary(inner), DataFormat::Binary) => Self::binary(inner), (DataInner::Text(inner), DataFormat::Text) => Self::text(inner), #[cfg(feature = "json")] @@ -166,23 +238,36 @@ impl Data { Err(_) => Self::text(inner), } } - (inner, DataFormat::Binary) => Self::binary(Self { inner }.to_bytes()), + (inner, DataFormat::Binary) => Self::binary( + Self { + inner, + source: None, + } + .to_bytes() + .expect("error case handled"), + ), // This variant is already covered unless structured data is enabled #[cfg(feature = "structured-data")] (inner, DataFormat::Text) => { - let remake = Self { inner }; + let remake = Self { + inner, + source: None, + }; if let Some(str) = remake.render() { Self::text(str) } else { remake } } - } + }; + data.source = self.source; + data } /// Outputs the current `DataFormat` of the underlying data pub fn format(&self) -> DataFormat { match &self.inner { + DataInner::Error(_) => DataFormat::Error, DataInner::Binary(_) => DataFormat::Binary, DataInner::Text(_) => DataFormat::Text, #[cfg(feature = "json")] @@ -194,6 +279,7 @@ impl Data { impl std::fmt::Display for Data { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.inner { + DataInner::Error(err) => err.fmt(f), DataInner::Binary(data) => String::from_utf8_lossy(data).fmt(f), DataInner::Text(data) => data.fmt(f), #[cfg(feature = "json")] @@ -202,6 +288,14 @@ impl std::fmt::Display for Data { } } +impl PartialEq for Data { + fn eq(&self, other: &Data) -> bool { + self.inner == other.inner + } +} + +impl Eq for Data {} + impl Default for Data { fn default() -> Self { Self::new() @@ -244,6 +338,42 @@ impl<'s> From<&'s str> for Data { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DataSource { + inner: DataSourceInner, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum DataSourceInner { + Path(std::path::PathBuf), +} + +impl DataSource { + pub fn path(path: impl Into) -> Self { + Self { + inner: DataSourceInner::Path(path.into()), + } + } + + pub fn is_path(&self) -> bool { + self.as_path().is_some() + } + + pub fn as_path(&self) -> Option<&std::path::Path> { + match &self.inner { + DataSourceInner::Path(path) => Some(path.as_ref()), + } + } +} + +impl std::fmt::Display for DataSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.inner { + DataSourceInner::Path(path) => crate::path::display_relpath(path).fmt(f), + } + } +} + pub trait Normalize { fn normalize(&self, data: Data) -> Data; } @@ -251,7 +381,8 @@ pub trait Normalize { pub struct NormalizeNewlines; impl Normalize for NormalizeNewlines { fn normalize(&self, data: Data) -> Data { - match data.inner { + let mut new = match data.inner { + DataInner::Error(err) => Data::error(err), DataInner::Binary(bin) => Data::binary(bin), DataInner::Text(text) => { let lines = crate::utils::normalize_lines(&text); @@ -263,14 +394,17 @@ impl Normalize for NormalizeNewlines { normalize_value(&mut value, crate::utils::normalize_lines); Data::json(value) } - } + }; + new.source = data.source; + new } } pub struct NormalizePaths; impl Normalize for NormalizePaths { fn normalize(&self, data: Data) -> Data { - match data.inner { + let mut new = match data.inner { + DataInner::Error(err) => Data::error(err), DataInner::Binary(bin) => Data::binary(bin), DataInner::Text(text) => { let lines = crate::utils::normalize_paths(&text); @@ -282,7 +416,9 @@ impl Normalize for NormalizePaths { normalize_value(&mut value, crate::utils::normalize_paths); Data::json(value) } - } + }; + new.source = data.source; + new } } @@ -302,7 +438,8 @@ impl<'a> NormalizeMatches<'a> { impl Normalize for NormalizeMatches<'_> { fn normalize(&self, data: Data) -> Data { - match data.inner { + let mut new = match data.inner { + DataInner::Error(err) => Data::error(err), DataInner::Binary(bin) => Data::binary(bin), DataInner::Text(text) => { let lines = self @@ -318,7 +455,9 @@ impl Normalize for NormalizeMatches<'_> { } Data::json(value) } - } + }; + new.source = data.source; + new } } @@ -434,7 +573,7 @@ mod test { #[test] fn text_to_bytes_render() { let d = Data::text(String::from("test")); - let bytes = d.to_bytes(); + let bytes = d.to_bytes().unwrap(); let bytes = String::from_utf8(bytes).unwrap(); let rendered = d.render().unwrap(); assert_eq!(bytes, rendered); @@ -444,7 +583,7 @@ mod 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 = d.to_bytes().unwrap(); let bytes = String::from_utf8(bytes).unwrap(); let rendered = d.render().unwrap(); assert_eq!(bytes, rendered); @@ -553,7 +692,7 @@ mod test { 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); + assert_eq!(Data::binary(d.to_bytes().unwrap()), binary); } #[test] @@ -562,7 +701,7 @@ mod test { 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); + assert_eq!(Data::binary(d.to_bytes().unwrap()), binary); } #[test] diff --git a/crates/snapbox/src/harness.rs b/crates/snapbox/src/harness.rs index 77e085eb..24b2785d 100644 --- a/crates/snapbox/src/harness.rs +++ b/crates/snapbox/src/harness.rs @@ -167,7 +167,7 @@ impl Verifier { expected_path: &std::path::Path, actual: crate::Data, ) -> crate::Result<()> { - actual.write_to(expected_path)?; + actual.write_to_path(expected_path)?; Ok(()) } @@ -176,7 +176,7 @@ impl Verifier { expected_path: &std::path::Path, actual: crate::Data, ) -> crate::Result<()> { - let expected = crate::Data::read_from(expected_path, Some(DataFormat::Text))? + let expected = crate::Data::read_from(expected_path, Some(DataFormat::Text)) .normalize(NormalizeNewlines); if expected != actual { diff --git a/crates/snapbox/src/lib.rs b/crates/snapbox/src/lib.rs index ef6f51e1..96459279 100644 --- a/crates/snapbox/src/lib.rs +++ b/crates/snapbox/src/lib.rs @@ -26,7 +26,6 @@ //! //! 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: @@ -53,10 +52,9 @@ //! [`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); +//! .matches(snapbox::expect_file!["help_output_is_clean.txt"], actual); //! ``` //! //! [`harness::Harness`] @@ -99,6 +97,7 @@ mod action; mod assert; mod data; mod error; +mod macros; mod substitutions; pub mod cmd; @@ -114,6 +113,7 @@ pub use action::DEFAULT_ACTION_ENV; pub use assert::Assert; pub use data::Data; pub use data::DataFormat; +pub use data::DataSource; pub use data::{Normalize, NormalizeMatches, NormalizeNewlines, NormalizePaths}; pub use error::Error; pub use snapbox_macros::debug; @@ -126,9 +126,18 @@ pub type Result = std::result::Result; /// When the content is text, newlines are normalized. /// /// ```rust +/// # use snapbox::assert_eq; /// let output = "something"; /// let expected = "something"; -/// snapbox::assert_eq(expected, output); +/// assert_eq(expected, output); +/// ``` +/// +/// Can combine this with [`expect_file!`] +/// ```rust,no_run +/// # use snapbox::assert_eq; +/// # use snapbox::expect_file; +/// let actual = "something"; +/// assert_eq(expect_file!["output.txt"], actual); /// ``` #[track_caller] pub fn assert_eq(expected: impl Into, actual: impl Into) { @@ -147,55 +156,22 @@ pub fn assert_eq(expected: impl Into, actual: impl Into, actual: impl Into) { - 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, actual: impl Into) { - 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 `/` /// +/// Can combine this with [`expect_file!`] /// ```rust,no_run -/// let output = "something"; -/// let expected_path = "tests/snapshots/output.txt"; -/// snapbox::assert_matches_path(expected_path, output); +/// # use snapbox::assert_matches; +/// # use snapbox::expect_file; +/// let actual = "something"; +/// assert_matches(expect_file!["output.txt"], actual); /// ``` #[track_caller] -pub fn assert_matches_path( - pattern_path: impl AsRef, - actual: impl Into, -) { - Assert::new() - .action_env(DEFAULT_ACTION_ENV) - .matches_path(pattern_path, actual); +pub fn assert_matches(pattern: impl Into, actual: impl Into) { + Assert::new().matches(pattern, actual); } /// Check if a path matches the content of another path, recursively diff --git a/crates/snapbox/src/macros.rs b/crates/snapbox/src/macros.rs new file mode 100644 index 00000000..18f88e47 --- /dev/null +++ b/crates/snapbox/src/macros.rs @@ -0,0 +1,19 @@ +/// Find the directory for your source file +#[doc(hidden)] // forced to be visible in intended location +#[macro_export] +macro_rules! current_dir { + () => {{ + let root = if let Some(rustc_root) = option_env!("CARGO_RUSTC_CURRENT_DIR") { + std::path::Path::new(rustc_root) + } else { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + manifest_dir + .ancestors() + .filter(|it| it.join("Cargo.toml").exists()) + .last() + .unwrap() + }; + let rel_path = std::path::Path::new(file!()).parent().unwrap(); + root.join(rel_path) + }}; +} diff --git a/crates/snapbox/src/path.rs b/crates/snapbox/src/path.rs index 523b0382..ac0c887d 100644 --- a/crates/snapbox/src/path.rs +++ b/crates/snapbox/src/path.rs @@ -1,5 +1,8 @@ //! Initialize working directories and assert on how they've changed +#[doc(inline)] +pub use crate::current_dir; + #[cfg(feature = "path")] use crate::data::{NormalizeMatches, NormalizeNewlines, NormalizePaths}; /// Working directory for tests @@ -178,11 +181,10 @@ impl PathDiff { } FileType::File => { let mut actual = - crate::Data::read_from(&actual_path, None).map_err(Self::Failure)?; + crate::Data::try_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)?; + let expected = + crate::Data::read_from(&expected_path, None).normalize(NormalizeNewlines); actual = actual .try_coerce(expected.format()) @@ -256,11 +258,10 @@ impl PathDiff { } FileType::File => { let mut actual = - crate::Data::read_from(&actual_path, None).map_err(Self::Failure)?; + crate::Data::try_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)?; + let expected = + crate::Data::read_from(&expected_path, None).normalize(NormalizeNewlines); actual = actual .try_coerce(expected.format()) @@ -400,11 +401,11 @@ impl PathDiff { actual_target: _, } => shallow_copy(expected_path, actual_path), Self::ContentMismatch { - expected_path, + expected_path: _, actual_path: _, - expected_content: _, + expected_content, actual_content, - } => actual_content.write_to(expected_path), + } => actual_content.write_to(expected_content.source().unwrap()), } } } diff --git a/schema.json b/schema.json index 773001e7..c88afc34 100644 --- a/schema.json +++ b/schema.json @@ -259,4 +259,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/runner.rs b/src/runner.rs index 486a91dd..23c4572c 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -480,7 +480,7 @@ impl Case { } }; let stream_path = root.join(file_name); - stream.content.write_to(&stream_path).map_err(|e| { + stream.content.write_to_path(&stream_path).map_err(|e| { let mut stream = stream.clone(); if stream.is_ok() { stream.status = StreamStatus::Failure(e); diff --git a/src/schema.rs b/src/schema.rs index 852c12f0..d15d5e14 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -29,7 +29,7 @@ impl TryCmd { let stdin_path = path.with_extension("stdin"); let stdin = if stdin_path.exists() { // No `map_text` as we will trust what the user inputted - Some(crate::Data::read_from(&stdin_path, Some(is_binary))?) + Some(crate::Data::try_read_from(&stdin_path, Some(is_binary))?) } else { None }; @@ -40,7 +40,7 @@ impl TryCmd { let stdout_path = path.with_extension("stdout"); let stdout = if stdout_path.exists() { Some( - crate::Data::read_from(&stdout_path, Some(is_binary))? + crate::Data::read_from(&stdout_path, Some(is_binary)) .normalize(NormalizePaths) .normalize(NormalizeNewlines), ) @@ -54,7 +54,7 @@ impl TryCmd { let stderr_path = path.with_extension("stderr"); let stderr = if stderr_path.exists() { Some( - crate::Data::read_from(&stderr_path, Some(is_binary))? + crate::Data::read_from(&stderr_path, Some(is_binary)) .normalize(NormalizePaths) .normalize(NormalizeNewlines), ) @@ -341,9 +341,8 @@ fn overwrite_toml_output( output_field: &str, ) -> Result<(), crate::Error> { if let Some(output) = output { - let output_path = path.with_extension(output_ext); - if output_path.exists() { - output.write_to(&output_path)?; + if let Some(source) = output.source() { + output.write_to(source)?; } else if let Some(output) = output.render() { let raw = std::fs::read_to_string(path) .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; @@ -356,7 +355,8 @@ fn overwrite_toml_output( std::fs::write(path, doc.to_string()) .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?; } else { - output.write_to(&output_path)?; + let output_path = path.with_extension(output_ext); + output.write_to_path(&output_path)?; let raw = std::fs::read_to_string(path) .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; diff --git a/tests/schema.rs b/tests/schema.rs index 00422c00..70b2d299 100644 --- a/tests/schema.rs +++ b/tests/schema.rs @@ -5,5 +5,5 @@ fn dump_schema() { snapbox::cmd::Command::new(bin_path) .assert() .success() - .stdout_eq_path("schema.json"); + .stdout_eq(snapbox::expect_file!["../schema.json"]); }