diff --git a/crates/snapbox/Cargo.toml b/crates/snapbox/Cargo.toml index 4f0245dd..9e5760e8 100644 --- a/crates/snapbox/Cargo.toml +++ b/crates/snapbox/Cargo.toml @@ -36,8 +36,10 @@ default = ["color-auto", "diff"] harness = ["dep:libtest-mimic", "dep:ignore"] ## Smarter binary file detection detect-encoding = ["dep:content_inspector"] -## Snapshotting of paths -path = ["dep:tempfile", "dep:walkdir", "dep:dunce", "detect-encoding", "dep:filetime"] +## Snapshotting of directories +dir = ["dep:tempfile", "dep:walkdir", "dep:dunce", "detect-encoding", "dep:filetime"] +## Deprecated since 0.5.11, replaced with `dir` +path = ["dir"] ## Snapshotting of commands cmd = ["dep:os_pipe", "dep:wait-timeout", "dep:libc", "dep:windows-sys"] ## Building of examples for snapshotting diff --git a/crates/snapbox/src/action.rs b/crates/snapbox/src/assert/action.rs similarity index 100% rename from crates/snapbox/src/action.rs rename to crates/snapbox/src/assert/action.rs diff --git a/crates/snapbox/src/error.rs b/crates/snapbox/src/assert/error.rs similarity index 97% rename from crates/snapbox/src/error.rs rename to crates/snapbox/src/assert/error.rs index 55e90188..e48b37e0 100644 --- a/crates/snapbox/src/error.rs +++ b/crates/snapbox/src/assert/error.rs @@ -1,3 +1,5 @@ +pub type Result = std::result::Result; + #[derive(Clone, Debug)] pub struct Error { inner: String, diff --git a/crates/snapbox/src/assert.rs b/crates/snapbox/src/assert/mod.rs similarity index 94% rename from crates/snapbox/src/assert.rs rename to crates/snapbox/src/assert/mod.rs index 295fa1e1..4b466a02 100644 --- a/crates/snapbox/src/assert.rs +++ b/crates/snapbox/src/assert/mod.rs @@ -1,3 +1,6 @@ +mod action; +mod error; + #[cfg(feature = "color")] use anstream::panic; #[cfg(feature = "color")] @@ -5,8 +8,12 @@ use anstream::stderr; #[cfg(not(feature = "color"))] use std::io::stderr; -use crate::data::{NormalizeMatches, NormalizeNewlines, NormalizePaths}; -use crate::Action; +use crate::filter::{Filter as _, FilterNewlines, FilterPaths, FilterRedactions}; + +pub use action::Action; +pub use action::DEFAULT_ACTION_ENV; +pub use error::Error; +pub use error::Result; /// Snapshot assertion against a file's contents /// @@ -25,7 +32,7 @@ pub struct Assert { action: Action, action_var: Option, normalize_paths: bool, - substitutions: crate::Substitutions, + substitutions: crate::Redactions, pub(crate) palette: crate::report::Palette, } @@ -131,11 +138,11 @@ impl Assert { expected: crate::Data, mut actual: crate::Data, ) -> (crate::Data, crate::Data) { - let expected = expected.normalize(NormalizeNewlines); + let expected = FilterNewlines.filter(expected); // On `expected` being an error, make a best guess let format = expected.intended_format(); - actual = actual.coerce_to(format).normalize(NormalizeNewlines); + actual = FilterNewlines.filter(actual.coerce_to(format)); (expected, actual) } @@ -145,19 +152,19 @@ impl Assert { expected: crate::Data, mut actual: crate::Data, ) -> (crate::Data, crate::Data) { - let expected = expected.normalize(NormalizeNewlines); + let expected = FilterNewlines.filter(expected); // On `expected` being an error, make a best guess let format = expected.intended_format(); actual = actual.coerce_to(format); if self.normalize_paths { - actual = actual.normalize(NormalizePaths); + actual = FilterPaths.filter(actual); } // Always normalize new lines - actual = actual.normalize(NormalizeNewlines); + actual = FilterNewlines.filter(actual); // If expected is not an error normalize matches - actual = actual.normalize(NormalizeMatches::new(&self.substitutions, &expected)); + actual = FilterRedactions::new(&self.substitutions, &expected).filter(actual); (expected, actual) } @@ -213,7 +220,7 @@ impl Assert { expected: &crate::Data, actual: &crate::Data, actual_name: Option<&dyn std::fmt::Display>, - ) -> crate::Result<()> { + ) -> crate::assert::Result<()> { if expected != actual { let mut buf = String::new(); crate::report::write_diff( @@ -233,7 +240,7 @@ impl Assert { } /// # Directory Assertions -#[cfg(feature = "path")] +#[cfg(feature = "dir")] impl Assert { #[track_caller] pub fn subset_eq( @@ -418,8 +425,8 @@ impl Assert { self } - /// Override the default [`Substitutions`][crate::Substitutions] - pub fn substitutions(mut self, substitutions: crate::Substitutions) -> Self { + /// Override the default [`Redactions`][crate::Redactions] + pub fn substitutions(mut self, substitutions: crate::Redactions) -> Self { self.substitutions = substitutions; self } @@ -442,6 +449,6 @@ impl Default for Assert { substitutions: Default::default(), palette: crate::report::Palette::color(), } - .substitutions(crate::Substitutions::with_exe()) + .substitutions(crate::Redactions::with_exe()) } } diff --git a/crates/snapbox/src/cmd.rs b/crates/snapbox/src/cmd.rs index 354ea153..e5091d04 100644 --- a/crates/snapbox/src/cmd.rs +++ b/crates/snapbox/src/cmd.rs @@ -21,7 +21,7 @@ impl Command { stdin: None, timeout: None, _stderr_to_stdout: false, - config: crate::Assert::new().action_env(crate::DEFAULT_ACTION_ENV), + config: crate::Assert::new().action_env(crate::assert::DEFAULT_ACTION_ENV), } } @@ -32,7 +32,7 @@ impl Command { stdin: None, timeout: None, _stderr_to_stdout: false, - config: crate::Assert::new().action_env(crate::DEFAULT_ACTION_ENV), + config: crate::Assert::new().action_env(crate::assert::DEFAULT_ACTION_ENV), } } @@ -454,7 +454,7 @@ impl OutputAssert { pub fn new(output: std::process::Output) -> Self { Self { output, - config: crate::Assert::new().action_env(crate::DEFAULT_ACTION_ENV), + config: crate::Assert::new().action_env(crate::assert::DEFAULT_ACTION_ENV), } } @@ -929,7 +929,7 @@ pub(crate) mod examples { pub fn compile_example<'a>( target_name: &str, args: impl IntoIterator, - ) -> Result { + ) -> crate::assert::Result { crate::debug!("Compiling example {}", target_name); let messages = escargot::CargoBuild::new() .current_target() @@ -937,12 +937,12 @@ pub(crate) mod examples { .example(target_name) .args(args) .exec() - .map_err(|e| crate::Error::new(e.to_string()))?; + .map_err(|e| crate::assert::Error::new(e.to_string()))?; for message in messages { - let message = message.map_err(|e| crate::Error::new(e.to_string()))?; + let message = message.map_err(|e| crate::assert::Error::new(e.to_string()))?; let message = message .decode() - .map_err(|e| crate::Error::new(e.to_string()))?; + .map_err(|e| crate::assert::Error::new(e.to_string()))?; crate::debug!("Message: {:?}", message); if let Some(bin) = decode_example_message(&message) { let (name, bin) = bin?; @@ -951,7 +951,7 @@ pub(crate) mod examples { } } - Err(crate::Error::new(format!( + Err(crate::assert::Error::new(format!( "Unknown error building example {}", target_name ))) @@ -971,9 +971,8 @@ pub(crate) mod examples { #[cfg(feature = "examples")] pub fn compile_examples<'a>( args: impl IntoIterator, - ) -> Result< - impl Iterator)>, - crate::Error, + ) -> crate::assert::Result< + impl Iterator)>, > { crate::debug!("Compiling examples"); let mut examples = std::collections::BTreeMap::new(); @@ -984,12 +983,12 @@ pub(crate) mod examples { .examples() .args(args) .exec() - .map_err(|e| crate::Error::new(e.to_string()))?; + .map_err(|e| crate::assert::Error::new(e.to_string()))?; for message in messages { - let message = message.map_err(|e| crate::Error::new(e.to_string()))?; + let message = message.map_err(|e| crate::assert::Error::new(e.to_string()))?; let message = message .decode() - .map_err(|e| crate::Error::new(e.to_string()))?; + .map_err(|e| crate::assert::Error::new(e.to_string()))?; crate::debug!("Message: {:?}", message); if let Some(bin) = decode_example_message(&message) { let (name, bin) = bin?; @@ -1003,7 +1002,7 @@ pub(crate) mod examples { #[allow(clippy::type_complexity)] fn decode_example_message<'m>( message: &'m escargot::format::Message, - ) -> Option), crate::Error>> { + ) -> Option)>> { match message { escargot::format::Message::CompilerMessage(msg) => { let level = msg.message.level; @@ -1017,10 +1016,10 @@ pub(crate) mod examples { .unwrap_or_else(|| msg.message.message.as_ref()) .to_owned(); if is_example_target(&msg.target) { - let bin = Err(crate::Error::new(output)); + let bin = Err(crate::assert::Error::new(output)); Some(Ok((msg.target.name.as_ref(), bin))) } else { - Some(Err(crate::Error::new(output))) + Some(Err(crate::assert::Error::new(output))) } } else { None diff --git a/crates/snapbox/src/data/mod.rs b/crates/snapbox/src/data/mod.rs index 1d32dc39..486ab28c 100644 --- a/crates/snapbox/src/data/mod.rs +++ b/crates/snapbox/src/data/mod.rs @@ -9,8 +9,11 @@ mod tests; pub use format::DataFormat; pub use normalize::Normalize; +#[allow(deprecated)] pub use normalize::NormalizeMatches; +#[allow(deprecated)] pub use normalize::NormalizeNewlines; +#[allow(deprecated)] pub use normalize::NormalizePaths; pub use source::DataSource; pub use source::Inline; @@ -69,12 +72,12 @@ macro_rules! file { $crate::Data::read_from(&path, Some($crate::data::DataFormat:: $type)) }}; [$path:literal] => {{ - let mut path = $crate::current_dir!(); + let mut path = $crate::utils::current_dir!(); path.push($path); $crate::Data::read_from(&path, None) }}; [$path:literal : $type:ident] => {{ - let mut path = $crate::current_dir!(); + let mut path = $crate::utils::current_dir!(); path.push($path); $crate::Data::read_from(&path, Some($crate::data::DataFormat:: $type)) }}; @@ -98,7 +101,7 @@ macro_rules! str { [$data:literal] => { $crate::str![[$data]] }; [[$data:literal]] => {{ let position = $crate::data::Position { - file: $crate::path::current_rs!(), + file: $crate::utils::current_rs!(), line: line!(), column: column!(), }; @@ -118,8 +121,8 @@ macro_rules! str { /// This provides conveniences for tracking the intended format (binary vs text). #[derive(Clone, Debug)] pub struct Data { - inner: DataInner, - source: Option, + pub(crate) inner: DataInner, + pub(crate) source: Option, } #[derive(Clone, Debug)] @@ -155,7 +158,7 @@ impl Data { Self::with_inner(DataInner::Json(raw.into())) } - fn error(raw: impl Into, intended: DataFormat) -> Self { + fn error(raw: impl Into, intended: DataFormat) -> Self { Self::with_inner(DataInner::Error(DataError { error: raw.into(), intended, @@ -201,7 +204,7 @@ impl Data { pub fn try_read_from( path: &std::path::Path, data_format: Option, - ) -> Result { + ) -> crate::assert::Result { let data = std::fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; let data = Self::binary(data); @@ -225,7 +228,7 @@ impl Data { } /// Overwrite a snapshot - pub fn write_to(&self, source: &DataSource) -> Result<(), crate::Error> { + pub fn write_to(&self, source: &DataSource) -> crate::assert::Result<()> { match &source.inner { source::DataSourceInner::Path(p) => self.write_to_path(p), source::DataSourceInner::Inline(p) => runtime::get() @@ -235,7 +238,7 @@ impl Data { } /// Overwrite a snapshot - pub fn write_to_path(&self, path: &std::path::Path) -> Result<(), crate::Error> { + pub fn write_to_path(&self, path: &std::path::Path) -> crate::assert::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| { format!("Failed to create parent dir for {}: {}", path.display(), e) @@ -249,8 +252,9 @@ impl Data { /// Post-process text /// /// See [utils][crate::utils] + #[deprecated(since = "0.5.11", note = "Replaced with `Normalize::normalize`")] pub fn normalize(self, op: impl Normalize) -> Self { - op.normalize(self) + op.filter(self) } /// Return the underlying `String` @@ -268,7 +272,7 @@ impl Data { } } - pub fn to_bytes(&self) -> Result, crate::Error> { + pub fn to_bytes(&self) -> crate::assert::Result> { match &self.inner { DataInner::Error(err) => Err(err.error.clone()), DataInner::Binary(data) => Ok(data.clone()), @@ -292,7 +296,7 @@ impl Data { } } - fn try_is(self, format: DataFormat) -> Result { + fn try_is(self, format: DataFormat) -> crate::assert::Result { let original = self.format(); let source = self.source; let inner = match (self.inner, format) { @@ -487,7 +491,7 @@ impl PartialEq for Data { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct DataError { - error: crate::Error, + error: crate::assert::Error, intended: DataFormat, } diff --git a/crates/snapbox/src/data/normalize.rs b/crates/snapbox/src/data/normalize.rs index fc8339c7..48c092cc 100644 --- a/crates/snapbox/src/data/normalize.rs +++ b/crates/snapbox/src/data/normalize.rs @@ -1,196 +1,24 @@ -//! Normalize `actual` or `expected` [`Data`] -//! -//! This can be done for -//! - Making snapshots consistent across platforms or conditional compilation -//! - Focusing snapshots on the characteristics of the data being tested +#![allow(deprecated)] use super::Data; -use super::DataInner; -pub trait Normalize { - fn normalize(&self, data: Data) -> Data; -} +pub use crate::filter::Filter as Normalize; +#[deprecated(since = "0.5.11", note = "Replaced with `filter::FilterNewlines")] pub struct NormalizeNewlines; impl Normalize for NormalizeNewlines { fn normalize(&self, data: Data) -> Data { - let source = data.source; - let inner = match data.inner { - DataInner::Error(err) => DataInner::Error(err), - DataInner::Binary(bin) => DataInner::Binary(bin), - DataInner::Text(text) => { - let lines = crate::utils::normalize_lines(&text); - DataInner::Text(lines) - } - #[cfg(feature = "json")] - DataInner::Json(value) => { - let mut value = value; - normalize_value(&mut value, crate::utils::normalize_lines); - DataInner::Json(value) - } - #[cfg(feature = "term-svg")] - DataInner::TermSvg(text) => { - let lines = crate::utils::normalize_lines(&text); - DataInner::TermSvg(lines) - } - }; - Data { inner, source } + crate::filter::FilterNewlines.normalize(data) } } +#[deprecated(since = "0.5.11", note = "Replaced with `filter::FilterPaths")] pub struct NormalizePaths; impl Normalize for NormalizePaths { fn normalize(&self, data: Data) -> Data { - let source = data.source; - let inner = match data.inner { - DataInner::Error(err) => DataInner::Error(err), - DataInner::Binary(bin) => DataInner::Binary(bin), - DataInner::Text(text) => { - let lines = crate::utils::normalize_paths(&text); - DataInner::Text(lines) - } - #[cfg(feature = "json")] - DataInner::Json(value) => { - let mut value = value; - normalize_value(&mut value, crate::utils::normalize_paths); - DataInner::Json(value) - } - #[cfg(feature = "term-svg")] - DataInner::TermSvg(text) => { - let lines = crate::utils::normalize_paths(&text); - DataInner::TermSvg(lines) - } - }; - Data { inner, source } - } -} - -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 { - let source = data.source; - let inner = match data.inner { - DataInner::Error(err) => DataInner::Error(err), - DataInner::Binary(bin) => DataInner::Binary(bin), - DataInner::Text(text) => { - if let Some(pattern) = self.pattern.render() { - let lines = self.substitutions.normalize(&text, &pattern); - DataInner::Text(lines) - } else { - DataInner::Text(text) - } - } - #[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); - } - DataInner::Json(value) - } - #[cfg(feature = "term-svg")] - DataInner::TermSvg(text) => { - if let Some(pattern) = self.pattern.render() { - let lines = self.substitutions.normalize(&text, &pattern); - DataInner::TermSvg(lines) - } else { - DataInner::TermSvg(text) - } - } - }; - Data { inner, source } + crate::filter::FilterPaths.normalize(data) } } -#[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) => { - for value in arr.iter_mut() { - normalize_value(value, op) - } - } - serde_json::Value::Object(obj) => { - for (_, value) in obj.iter_mut() { - 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::*; - - const VALUE_WILDCARD: &str = "{...}"; - - match (actual, expected) { - (act, String(exp)) if exp == VALUE_WILDCARD => { - *act = serde_json::json!(VALUE_WILDCARD); - } - (String(act), String(exp)) => { - *act = substitutions.normalize(act, exp); - } - (Array(act), Array(exp)) => { - let mut sections = exp.split(|e| e == VALUE_WILDCARD).peekable(); - let mut processed = 0; - while let Some(expected_subset) = sections.next() { - // Process all values in the current section - if !expected_subset.is_empty() { - let actual_subset = &mut act[processed..processed + expected_subset.len()]; - for (a, e) in actual_subset.iter_mut().zip(expected_subset) { - normalize_value_matches(a, e, substitutions); - } - processed += expected_subset.len(); - } - - if let Some(next_section) = sections.peek() { - // If the next section has nothing in it, replace from processed to end with - // a single "{...}" - if next_section.is_empty() { - act.splice(processed.., vec![String(VALUE_WILDCARD.to_owned())]); - processed += 1; - } else { - let first = next_section.first().unwrap(); - // Replace everything up until the value we are looking for with - // a single "{...}". - if let Some(index) = act.iter().position(|v| v == first) { - act.splice(processed..index, vec![String(VALUE_WILDCARD.to_owned())]); - processed += 1; - } else { - // If we cannot find the value we are looking for return early - break; - } - } - } - } - } - (Object(act), Object(exp)) => { - for (a, e) in act.iter_mut().zip(exp).filter(|(a, e)| a.0 == e.0) { - normalize_value_matches(a.1, e.1, substitutions) - } - } - (_, _) => {} - } -} +#[deprecated(since = "0.5.11", note = "Replaced with `filter::FilterRedactions")] +pub type NormalizeMatches<'a> = crate::filter::FilterRedactions<'a>; diff --git a/crates/snapbox/src/data/source.rs b/crates/snapbox/src/data/source.rs index cf8162c2..b48873c1 100644 --- a/crates/snapbox/src/data/source.rs +++ b/crates/snapbox/src/data/source.rs @@ -63,7 +63,7 @@ impl From for DataSource { impl std::fmt::Display for DataSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.inner { - DataSourceInner::Path(value) => crate::path::display_relpath(value).fmt(f), + DataSourceInner::Path(value) => crate::dir::display_relpath(value).fmt(f), DataSourceInner::Inline(value) => value.fmt(f), } } diff --git a/crates/snapbox/src/data/tests.rs b/crates/snapbox/src/data/tests.rs index 6f5eda11..79139bf3 100644 --- a/crates/snapbox/src/data/tests.rs +++ b/crates/snapbox/src/data/tests.rs @@ -215,352 +215,6 @@ fn json_to_text_coerce_equals_render() { 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::new(&Default::default(), &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::new(&Default::default(), &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::new(&Default::default(), &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::new(&Default::default(), &expected)); - if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { - assert_ne!(exp, act); - } -} - -#[test] -#[cfg(feature = "json")] -fn json_normalize_wildcard_object_first() { - let exp = json!({ - "people": [ - "{...}", - { - "name": "three", - "nickname": "3", - } - ] - }); - let expected = Data::json(exp); - let actual = json!({ - "people": [ - { - "name": "one", - "nickname": "1", - }, - { - "name": "two", - "nickname": "2", - }, - { - "name": "three", - "nickname": "3", - } - ] - }); - let actual = - Data::json(actual).normalize(NormalizeMatches::new(&Default::default(), &expected)); - if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { - assert_eq!(exp, act); - } -} - -#[test] -#[cfg(feature = "json")] -fn json_normalize_wildcard_array_first() { - let exp = json!([ - "{...}", - { - "name": "three", - "nickname": "3", - } - ]); - let expected = Data::json(exp); - let actual = json!([ - { - "name": "one", - "nickname": "1", - }, - { - "name": "two", - "nickname": "2", - }, - { - "name": "three", - "nickname": "3", - } - ]); - let actual = - Data::json(actual).normalize(NormalizeMatches::new(&Default::default(), &expected)); - if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { - assert_eq!(exp, act); - } -} - -#[test] -#[cfg(feature = "json")] -fn json_normalize_wildcard_array_first_last() { - let exp = json!([ - "{...}", - { - "name": "two", - "nickname": "2", - }, - "{...}" - ]); - let expected = Data::json(exp); - let actual = json!([ - { - "name": "one", - "nickname": "1", - }, - { - "name": "two", - "nickname": "2", - }, - { - "name": "three", - "nickname": "3", - }, - { - "name": "four", - "nickname": "4", - } - ]); - let actual = - Data::json(actual).normalize(NormalizeMatches::new(&Default::default(), &expected)); - if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { - assert_eq!(exp, act); - } -} - -#[test] -#[cfg(feature = "json")] -fn json_normalize_wildcard_array_middle_last() { - let exp = json!([ - { - "name": "one", - "nickname": "1", - }, - "{...}", - { - "name": "three", - "nickname": "3", - }, - "{...}" - ]); - let expected = Data::json(exp); - let actual = json!([ - { - "name": "one", - "nickname": "1", - }, - { - "name": "two", - "nickname": "2", - }, - { - "name": "three", - "nickname": "3", - }, - { - "name": "four", - "nickname": "4", - }, - { - "name": "five", - "nickname": "5", - } - ]); - let actual = - Data::json(actual).normalize(NormalizeMatches::new(&Default::default(), &expected)); - if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { - assert_eq!(exp, act); - } -} - -#[test] -#[cfg(feature = "json")] -fn json_normalize_wildcard_array_middle_last_early_return() { - let exp = json!([ - { - "name": "one", - "nickname": "1", - }, - "{...}", - { - "name": "three", - "nickname": "3", - }, - "{...}" - ]); - let expected = Data::json(exp); - let actual = json!([ - { - "name": "one", - "nickname": "1", - }, - { - "name": "two", - "nickname": "2", - }, - { - "name": "four", - "nickname": "4", - }, - { - "name": "five", - "nickname": "5", - } - ]); - let actual_normalized = - Data::json(actual.clone()).normalize(NormalizeMatches::new(&Default::default(), &expected)); - if let DataInner::Json(act) = actual_normalized.inner { - assert_eq!(act, actual); - } -} - #[cfg(feature = "term-svg")] mod text_elem { use super::super::*; diff --git a/crates/snapbox/src/dir/diff.rs b/crates/snapbox/src/dir/diff.rs new file mode 100644 index 00000000..dafc7860 --- /dev/null +++ b/crates/snapbox/src/dir/diff.rs @@ -0,0 +1,358 @@ +#[cfg(feature = "dir")] +use crate::filter::{Filter as _, FilterNewlines, FilterPaths, FilterRedactions}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PathDiff { + Failure(crate::assert::Error), + TypeMismatch { + expected_path: std::path::PathBuf, + actual_path: std::path::PathBuf, + expected_type: FileType, + actual_type: FileType, + }, + LinkMismatch { + expected_path: std::path::PathBuf, + actual_path: std::path::PathBuf, + expected_target: std::path::PathBuf, + actual_target: std::path::PathBuf, + }, + ContentMismatch { + expected_path: std::path::PathBuf, + actual_path: std::path::PathBuf, + expected_content: crate::Data, + actual_content: crate::Data, + }, +} + +impl PathDiff { + /// Report differences between `actual_root` and `pattern_root` + /// + /// Note: Requires feature flag `path` + #[cfg(feature = "dir")] + pub fn subset_eq_iter( + pattern_root: impl Into, + actual_root: impl Into, + ) -> impl Iterator> { + let pattern_root = pattern_root.into(); + let actual_root = actual_root.into(); + Self::subset_eq_iter_inner(pattern_root, actual_root) + } + + #[cfg(feature = "dir")] + pub(crate) fn subset_eq_iter_inner( + expected_root: std::path::PathBuf, + actual_root: std::path::PathBuf, + ) -> impl Iterator> { + let walker = crate::dir::Walk::new(&expected_root); + walker.map(move |r| { + let expected_path = r.map_err(|e| Self::Failure(e.to_string().into()))?; + let rel = expected_path.strip_prefix(&expected_root).unwrap(); + let actual_path = actual_root.join(rel); + + let expected_type = FileType::from_path(&expected_path); + let actual_type = FileType::from_path(&actual_path); + if expected_type != actual_type { + return Err(Self::TypeMismatch { + expected_path, + actual_path, + expected_type, + actual_type, + }); + } + + match expected_type { + FileType::Symlink => { + let expected_target = std::fs::read_link(&expected_path).ok(); + let actual_target = std::fs::read_link(&actual_path).ok(); + if expected_target != actual_target { + return Err(Self::LinkMismatch { + expected_path, + actual_path, + expected_target: expected_target.unwrap(), + actual_target: actual_target.unwrap(), + }); + } + } + FileType::File => { + let mut actual = + crate::Data::try_read_from(&actual_path, None).map_err(Self::Failure)?; + + let expected = + FilterNewlines.filter(crate::Data::read_from(&expected_path, None)); + + actual = FilterNewlines.filter(actual.coerce_to(expected.intended_format())); + + if expected != actual { + return Err(Self::ContentMismatch { + expected_path, + actual_path, + expected_content: expected, + actual_content: actual, + }); + } + } + FileType::Dir | FileType::Unknown | FileType::Missing => {} + } + + Ok((expected_path, actual_path)) + }) + } + + /// Report differences between `actual_root` and `pattern_root` + /// + /// Note: Requires feature flag `path` + #[cfg(feature = "dir")] + pub fn subset_matches_iter( + pattern_root: impl Into, + actual_root: impl Into, + substitutions: &crate::Redactions, + ) -> impl Iterator> + '_ { + let pattern_root = pattern_root.into(); + let actual_root = actual_root.into(); + Self::subset_matches_iter_inner(pattern_root, actual_root, substitutions, true) + } + + #[cfg(feature = "dir")] + pub(crate) fn subset_matches_iter_inner( + expected_root: std::path::PathBuf, + actual_root: std::path::PathBuf, + substitutions: &crate::Redactions, + normalize_paths: bool, + ) -> impl Iterator> + '_ { + let walker = crate::dir::Walk::new(&expected_root); + walker.map(move |r| { + let expected_path = r.map_err(|e| Self::Failure(e.to_string().into()))?; + let rel = expected_path.strip_prefix(&expected_root).unwrap(); + let actual_path = actual_root.join(rel); + + let expected_type = FileType::from_path(&expected_path); + let actual_type = FileType::from_path(&actual_path); + if expected_type != actual_type { + return Err(Self::TypeMismatch { + expected_path, + actual_path, + expected_type, + actual_type, + }); + } + + match expected_type { + FileType::Symlink => { + let expected_target = std::fs::read_link(&expected_path).ok(); + let actual_target = std::fs::read_link(&actual_path).ok(); + if expected_target != actual_target { + return Err(Self::LinkMismatch { + expected_path, + actual_path, + expected_target: expected_target.unwrap(), + actual_target: actual_target.unwrap(), + }); + } + } + FileType::File => { + let mut actual = + crate::Data::try_read_from(&actual_path, None).map_err(Self::Failure)?; + + let expected = + FilterNewlines.filter(crate::Data::read_from(&expected_path, None)); + + actual = actual.coerce_to(expected.intended_format()); + if normalize_paths { + actual = FilterPaths.filter(actual); + } + actual = FilterRedactions::new(substitutions, &expected) + .filter(FilterNewlines.filter(actual)); + + if expected != actual { + return Err(Self::ContentMismatch { + expected_path, + actual_path, + expected_content: expected, + actual_content: actual, + }); + } + } + FileType::Dir | FileType::Unknown | FileType::Missing => {} + } + + Ok((expected_path, actual_path)) + }) + } +} + +impl PathDiff { + pub fn expected_path(&self) -> Option<&std::path::Path> { + match &self { + Self::Failure(_msg) => None, + Self::TypeMismatch { + expected_path, + actual_path: _, + expected_type: _, + actual_type: _, + } => Some(expected_path), + Self::LinkMismatch { + expected_path, + actual_path: _, + expected_target: _, + actual_target: _, + } => Some(expected_path), + Self::ContentMismatch { + expected_path, + actual_path: _, + expected_content: _, + actual_content: _, + } => Some(expected_path), + } + } + + pub fn write( + &self, + f: &mut dyn std::fmt::Write, + palette: crate::report::Palette, + ) -> Result<(), std::fmt::Error> { + match &self { + Self::Failure(msg) => { + writeln!(f, "{}", palette.error(msg))?; + } + Self::TypeMismatch { + expected_path, + actual_path: _actual_path, + expected_type, + actual_type, + } => { + writeln!( + f, + "{}: Expected {}, was {}", + expected_path.display(), + palette.info(expected_type), + palette.error(actual_type) + )?; + } + Self::LinkMismatch { + expected_path, + actual_path: _actual_path, + expected_target, + actual_target, + } => { + writeln!( + f, + "{}: Expected {}, was {}", + expected_path.display(), + palette.info(expected_target.display()), + palette.error(actual_target.display()) + )?; + } + Self::ContentMismatch { + expected_path, + actual_path, + expected_content, + actual_content, + } => { + crate::report::write_diff( + f, + expected_content, + actual_content, + Some(&expected_path.display()), + Some(&actual_path.display()), + palette, + )?; + } + } + + Ok(()) + } + + pub fn overwrite(&self) -> Result<(), crate::assert::Error> { + match self { + // Not passing the error up because users most likely want to treat a processing error + // differently than an overwrite error + Self::Failure(_err) => Ok(()), + Self::TypeMismatch { + expected_path, + actual_path, + expected_type: _, + actual_type, + } => { + match actual_type { + FileType::Dir => { + std::fs::remove_dir_all(expected_path).map_err(|e| { + format!("Failed to remove {}: {}", expected_path.display(), e) + })?; + } + FileType::File | FileType::Symlink => { + std::fs::remove_file(expected_path).map_err(|e| { + format!("Failed to remove {}: {}", expected_path.display(), e) + })?; + } + FileType::Unknown | FileType::Missing => {} + } + super::shallow_copy(expected_path, actual_path) + } + Self::LinkMismatch { + expected_path, + actual_path, + expected_target: _, + actual_target: _, + } => super::shallow_copy(expected_path, actual_path), + Self::ContentMismatch { + expected_path: _, + actual_path: _, + expected_content, + actual_content, + } => actual_content.write_to(expected_content.source().unwrap()), + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum FileType { + Dir, + File, + Symlink, + Unknown, + Missing, +} + +impl FileType { + pub fn from_path(path: &std::path::Path) -> Self { + let meta = path.symlink_metadata(); + match meta { + Ok(meta) => { + if meta.is_dir() { + Self::Dir + } else if meta.is_file() { + Self::File + } else { + let target = std::fs::read_link(path).ok(); + if target.is_some() { + Self::Symlink + } else { + Self::Unknown + } + } + } + Err(err) => match err.kind() { + std::io::ErrorKind::NotFound => Self::Missing, + _ => Self::Unknown, + }, + } + } +} + +impl FileType { + fn as_str(self) -> &'static str { + match self { + Self::Dir => "dir", + Self::File => "file", + Self::Symlink => "symlink", + Self::Unknown => "unknown", + Self::Missing => "missing", + } + } +} + +impl std::fmt::Display for FileType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_str().fmt(f) + } +} diff --git a/crates/snapbox/src/dir/mod.rs b/crates/snapbox/src/dir/mod.rs new file mode 100644 index 00000000..56c84870 --- /dev/null +++ b/crates/snapbox/src/dir/mod.rs @@ -0,0 +1,22 @@ +//! Initialize working directories and assert on how they've changed + +mod diff; +mod ops; +mod root; +#[cfg(test)] +mod tests; + +pub use diff::FileType; +pub use diff::PathDiff; +#[cfg(feature = "dir")] +pub use ops::copy_template; +pub use ops::resolve_dir; +pub use ops::strip_trailing_slash; +#[cfg(feature = "dir")] +pub use ops::Walk; +pub use root::DirRoot; + +#[cfg(feature = "dir")] +pub(crate) use ops::canonicalize; +pub(crate) use ops::display_relpath; +pub(crate) use ops::shallow_copy; diff --git a/crates/snapbox/src/dir/ops.rs b/crates/snapbox/src/dir/ops.rs new file mode 100644 index 00000000..036a4524 --- /dev/null +++ b/crates/snapbox/src/dir/ops.rs @@ -0,0 +1,186 @@ +/// Recursively walk a path +/// +/// Note: Ignores `.keep` files +#[cfg(feature = "dir")] +pub struct Walk { + inner: walkdir::IntoIter, +} + +#[cfg(feature = "dir")] +impl Walk { + pub fn new(path: &std::path::Path) -> Self { + Self { + inner: walkdir::WalkDir::new(path).into_iter(), + } + } +} + +#[cfg(feature = "dir")] +impl Iterator for Walk { + type Item = Result; + + fn next(&mut self) -> Option { + while let Some(entry) = self.inner.next().map(|e| { + e.map(walkdir::DirEntry::into_path) + .map_err(std::io::Error::from) + }) { + if entry.as_ref().ok().and_then(|e| e.file_name()) + != Some(std::ffi::OsStr::new(".keep")) + { + return Some(entry); + } + } + None + } +} + +/// Copy a template into a [`DirRoot`][super::DirRoot] +/// +/// Note: Generally you'll use [`DirRoot::with_template`][super::DirRoot::with_template] instead. +/// +/// Note: Ignores `.keep` files +#[cfg(feature = "dir")] +pub fn copy_template( + source: impl AsRef, + dest: impl AsRef, +) -> Result<(), crate::assert::Error> { + let source = source.as_ref(); + let dest = dest.as_ref(); + let source = canonicalize(source) + .map_err(|e| format!("Failed to canonicalize {}: {}", source.display(), e))?; + std::fs::create_dir_all(dest) + .map_err(|e| format!("Failed to create {}: {}", dest.display(), e))?; + let dest = canonicalize(dest) + .map_err(|e| format!("Failed to canonicalize {}: {}", dest.display(), e))?; + + for current in Walk::new(&source) { + let current = current.map_err(|e| e.to_string())?; + let rel = current.strip_prefix(&source).unwrap(); + let target = dest.join(rel); + + shallow_copy(¤t, &target)?; + } + + Ok(()) +} + +/// Copy a file system entry, without recursing +pub(crate) fn shallow_copy( + source: &std::path::Path, + dest: &std::path::Path, +) -> Result<(), crate::assert::Error> { + let meta = source + .symlink_metadata() + .map_err(|e| format!("Failed to read metadata from {}: {}", source.display(), e))?; + if meta.is_dir() { + std::fs::create_dir_all(dest) + .map_err(|e| format!("Failed to create {}: {}", dest.display(), e))?; + } else if meta.is_file() { + std::fs::copy(source, dest).map_err(|e| { + format!( + "Failed to copy {} to {}: {}", + source.display(), + dest.display(), + e + ) + })?; + // Avoid a mtime check race where: + // - Copy files + // - Test checks mtime + // - Test writes + // - Test checks mtime + // + // If all of this happens too close to each other, then the second mtime check will think + // nothing was written by the test. + // + // Instead of just setting 1s in the past, we'll just respect the existing mtime. + copy_stats(&meta, dest).map_err(|e| { + format!( + "Failed to copy {} metadata to {}: {}", + source.display(), + dest.display(), + e + ) + })?; + } else if let Ok(target) = std::fs::read_link(source) { + symlink_to_file(dest, &target) + .map_err(|e| format!("Failed to create symlink {}: {}", dest.display(), e))?; + } + + Ok(()) +} + +#[cfg(feature = "dir")] +fn copy_stats( + source_meta: &std::fs::Metadata, + dest: &std::path::Path, +) -> Result<(), std::io::Error> { + let src_mtime = filetime::FileTime::from_last_modification_time(source_meta); + filetime::set_file_mtime(dest, src_mtime)?; + + Ok(()) +} + +#[cfg(not(feature = "dir"))] +fn copy_stats( + _source_meta: &std::fs::Metadata, + _dest: &std::path::Path, +) -> Result<(), std::io::Error> { + Ok(()) +} + +#[cfg(windows)] +fn symlink_to_file(link: &std::path::Path, target: &std::path::Path) -> Result<(), std::io::Error> { + std::os::windows::fs::symlink_file(target, link) +} + +#[cfg(not(windows))] +fn symlink_to_file(link: &std::path::Path, target: &std::path::Path) -> Result<(), std::io::Error> { + std::os::unix::fs::symlink(target, link) +} + +pub fn resolve_dir( + path: impl AsRef, +) -> Result { + let path = path.as_ref(); + let meta = std::fs::symlink_metadata(path)?; + if meta.is_dir() { + canonicalize(path) + } else if meta.is_file() { + // Git might checkout symlinks as files + let target = std::fs::read_to_string(path)?; + let target_path = path.parent().unwrap().join(target); + resolve_dir(target_path) + } else { + canonicalize(path) + } +} + +pub(crate) fn canonicalize(path: &std::path::Path) -> Result { + #[cfg(feature = "dir")] + { + dunce::canonicalize(path) + } + #[cfg(not(feature = "dir"))] + { + // Hope for the best + Ok(strip_trailing_slash(path).to_owned()) + } +} + +pub fn strip_trailing_slash(path: &std::path::Path) -> &std::path::Path { + path.components().as_path() +} + +pub(crate) fn display_relpath(path: impl AsRef) -> String { + let path = path.as_ref(); + let relpath = if let Ok(cwd) = std::env::current_dir() { + match path.strip_prefix(cwd) { + Ok(path) => path, + Err(_) => path, + } + } else { + path + }; + relpath.display().to_string() +} diff --git a/crates/snapbox/src/dir/root.rs b/crates/snapbox/src/dir/root.rs new file mode 100644 index 00000000..1981a700 --- /dev/null +++ b/crates/snapbox/src/dir/root.rs @@ -0,0 +1,104 @@ +/// Working directory for tests +#[derive(Debug)] +pub struct DirRoot(DirRootInner); + +#[derive(Debug)] +enum DirRootInner { + None, + Immutable(std::path::PathBuf), + #[cfg(feature = "dir")] + MutablePath(std::path::PathBuf), + #[cfg(feature = "dir")] + MutableTemp { + temp: tempfile::TempDir, + path: std::path::PathBuf, + }, +} + +impl DirRoot { + pub fn none() -> Self { + Self(DirRootInner::None) + } + + pub fn immutable(target: &std::path::Path) -> Self { + Self(DirRootInner::Immutable(target.to_owned())) + } + + #[cfg(feature = "dir")] + pub fn mutable_temp() -> Result { + let temp = tempfile::tempdir().map_err(|e| e.to_string())?; + // We need to get the `/private` prefix on Mac so variable substitutions work + // correctly + let path = crate::dir::canonicalize(temp.path()) + .map_err(|e| format!("Failed to canonicalize {}: {}", temp.path().display(), e))?; + Ok(Self(DirRootInner::MutableTemp { temp, path })) + } + + #[cfg(feature = "dir")] + pub fn mutable_at(target: &std::path::Path) -> Result { + let _ = std::fs::remove_dir_all(target); + std::fs::create_dir_all(target) + .map_err(|e| format!("Failed to create {}: {}", target.display(), e))?; + Ok(Self(DirRootInner::MutablePath(target.to_owned()))) + } + + #[cfg(feature = "dir")] + pub fn with_template( + self, + template_root: &std::path::Path, + ) -> Result { + match &self.0 { + DirRootInner::None | DirRootInner::Immutable(_) => { + return Err("Sandboxing is disabled".into()); + } + DirRootInner::MutablePath(path) | DirRootInner::MutableTemp { path, .. } => { + crate::debug!( + "Initializing {} from {}", + path.display(), + template_root.display() + ); + super::copy_template(template_root, path)?; + } + } + + Ok(self) + } + + pub fn is_mutable(&self) -> bool { + match &self.0 { + DirRootInner::None | DirRootInner::Immutable(_) => false, + #[cfg(feature = "dir")] + DirRootInner::MutablePath(_) => true, + #[cfg(feature = "dir")] + DirRootInner::MutableTemp { .. } => true, + } + } + + pub fn path(&self) -> Option<&std::path::Path> { + match &self.0 { + DirRootInner::None => None, + DirRootInner::Immutable(path) => Some(path.as_path()), + #[cfg(feature = "dir")] + DirRootInner::MutablePath(path) => Some(path.as_path()), + #[cfg(feature = "dir")] + DirRootInner::MutableTemp { path, .. } => Some(path.as_path()), + } + } + + /// Explicitly close to report errors + pub fn close(self) -> Result<(), std::io::Error> { + match self.0 { + DirRootInner::None | DirRootInner::Immutable(_) => Ok(()), + #[cfg(feature = "dir")] + DirRootInner::MutablePath(_) => Ok(()), + #[cfg(feature = "dir")] + DirRootInner::MutableTemp { temp, .. } => temp.close(), + } + } +} + +impl Default for DirRoot { + fn default() -> Self { + Self::none() + } +} diff --git a/crates/snapbox/src/dir/tests.rs b/crates/snapbox/src/dir/tests.rs new file mode 100644 index 00000000..f235eae2 --- /dev/null +++ b/crates/snapbox/src/dir/tests.rs @@ -0,0 +1,36 @@ +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/crates/snapbox/src/filter/mod.rs b/crates/snapbox/src/filter/mod.rs new file mode 100644 index 00000000..7d58818a --- /dev/null +++ b/crates/snapbox/src/filter/mod.rs @@ -0,0 +1,230 @@ +//! Filter `actual` or `expected` [`Data`] +//! +//! This can be done for +//! - Making snapshots consistent across platforms or conditional compilation +//! - Focusing snapshots on the characteristics of the data being tested + +mod redactions; +#[cfg(test)] +mod test; + +use crate::data::DataInner; +use crate::Data; + +pub use redactions::Redactions; + +pub trait Filter { + #[deprecated(since = "0.5.11", note = "Replaced with `Filter::filter`")] + fn normalize(&self, data: Data) -> Data; + fn filter(&self, data: Data) -> Data { + #[allow(deprecated)] + self.normalize(data) + } +} + +pub struct FilterNewlines; +impl Filter for FilterNewlines { + fn normalize(&self, data: Data) -> Data { + let source = data.source; + let inner = match data.inner { + DataInner::Error(err) => DataInner::Error(err), + DataInner::Binary(bin) => DataInner::Binary(bin), + DataInner::Text(text) => { + let lines = normalize_lines(&text); + DataInner::Text(lines) + } + #[cfg(feature = "json")] + DataInner::Json(value) => { + let mut value = value; + normalize_value(&mut value, normalize_lines); + DataInner::Json(value) + } + #[cfg(feature = "term-svg")] + DataInner::TermSvg(text) => { + let lines = normalize_lines(&text); + DataInner::TermSvg(lines) + } + }; + Data { inner, source } + } +} + +/// Normalize line endings +pub fn normalize_lines(data: &str) -> String { + normalize_lines_chars(data.chars()).collect() +} + +fn normalize_lines_chars(data: impl Iterator) -> impl Iterator { + normalize_line_endings::normalized(data) +} + +pub struct FilterPaths; +impl Filter for FilterPaths { + fn normalize(&self, data: Data) -> Data { + let source = data.source; + let inner = match data.inner { + DataInner::Error(err) => DataInner::Error(err), + DataInner::Binary(bin) => DataInner::Binary(bin), + DataInner::Text(text) => { + let lines = normalize_paths(&text); + DataInner::Text(lines) + } + #[cfg(feature = "json")] + DataInner::Json(value) => { + let mut value = value; + normalize_value(&mut value, normalize_paths); + DataInner::Json(value) + } + #[cfg(feature = "term-svg")] + DataInner::TermSvg(text) => { + let lines = normalize_paths(&text); + DataInner::TermSvg(lines) + } + }; + Data { inner, source } + } +} + +/// Normalize path separators +/// +/// [`std::path::MAIN_SEPARATOR`] can vary by platform, so make it consistent +/// +/// Note: this cannot distinguish between when a character is being used as a path separator or not +/// and can "normalize" unrelated data +pub fn normalize_paths(data: &str) -> String { + normalize_paths_chars(data.chars()).collect() +} + +fn normalize_paths_chars(data: impl Iterator) -> impl Iterator { + data.map(|c| if c == '\\' { '/' } else { c }) +} + +pub struct FilterRedactions<'a> { + substitutions: &'a crate::Redactions, + pattern: &'a Data, +} + +impl<'a> FilterRedactions<'a> { + pub fn new(substitutions: &'a crate::Redactions, pattern: &'a Data) -> Self { + FilterRedactions { + substitutions, + pattern, + } + } +} + +impl Filter for FilterRedactions<'_> { + fn normalize(&self, data: Data) -> Data { + let source = data.source; + let inner = match data.inner { + DataInner::Error(err) => DataInner::Error(err), + DataInner::Binary(bin) => DataInner::Binary(bin), + DataInner::Text(text) => { + if let Some(pattern) = self.pattern.render() { + let lines = self.substitutions.normalize(&text, &pattern); + DataInner::Text(lines) + } else { + DataInner::Text(text) + } + } + #[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); + } + DataInner::Json(value) + } + #[cfg(feature = "term-svg")] + DataInner::TermSvg(text) => { + if let Some(pattern) = self.pattern.render() { + let lines = self.substitutions.normalize(&text, &pattern); + DataInner::TermSvg(lines) + } else { + DataInner::TermSvg(text) + } + } + }; + Data { inner, source } + } +} + +#[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) => { + for value in arr.iter_mut() { + normalize_value(value, op) + } + } + serde_json::Value::Object(obj) => { + for (_, value) in obj.iter_mut() { + normalize_value(value, op) + } + } + _ => {} + } +} + +#[cfg(feature = "structured-data")] +fn normalize_value_matches( + actual: &mut serde_json::Value, + expected: &serde_json::Value, + substitutions: &crate::Redactions, +) { + use serde_json::Value::*; + + const VALUE_WILDCARD: &str = "{...}"; + + match (actual, expected) { + (act, String(exp)) if exp == VALUE_WILDCARD => { + *act = serde_json::json!(VALUE_WILDCARD); + } + (String(act), String(exp)) => { + *act = substitutions.normalize(act, exp); + } + (Array(act), Array(exp)) => { + let mut sections = exp.split(|e| e == VALUE_WILDCARD).peekable(); + let mut processed = 0; + while let Some(expected_subset) = sections.next() { + // Process all values in the current section + if !expected_subset.is_empty() { + let actual_subset = &mut act[processed..processed + expected_subset.len()]; + for (a, e) in actual_subset.iter_mut().zip(expected_subset) { + normalize_value_matches(a, e, substitutions); + } + processed += expected_subset.len(); + } + + if let Some(next_section) = sections.peek() { + // If the next section has nothing in it, replace from processed to end with + // a single "{...}" + if next_section.is_empty() { + act.splice(processed.., vec![String(VALUE_WILDCARD.to_owned())]); + processed += 1; + } else { + let first = next_section.first().unwrap(); + // Replace everything up until the value we are looking for with + // a single "{...}". + if let Some(index) = act.iter().position(|v| v == first) { + act.splice(processed..index, vec![String(VALUE_WILDCARD.to_owned())]); + processed += 1; + } else { + // If we cannot find the value we are looking for return early + break; + } + } + } + } + } + (Object(act), Object(exp)) => { + for (a, e) in act.iter_mut().zip(exp).filter(|(a, e)| a.0 == e.0) { + normalize_value_matches(a.1, e.1, substitutions) + } + } + (_, _) => {} + } +} diff --git a/crates/snapbox/src/substitutions.rs b/crates/snapbox/src/filter/redactions.rs similarity index 81% rename from crates/snapbox/src/substitutions.rs rename to crates/snapbox/src/filter/redactions.rs index 914e9f55..086315f3 100644 --- a/crates/snapbox/src/substitutions.rs +++ b/crates/snapbox/src/filter/redactions.rs @@ -2,16 +2,16 @@ use std::borrow::Cow; /// Match pattern expressions, see [`Assert`][crate::Assert] /// -/// Built-in expressions: +/// Built-in placeholders: /// - `...` 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 { +pub struct Redactions { vars: std::collections::BTreeMap<&'static str, std::collections::BTreeSet>>, unused: std::collections::BTreeSet<&'static str>, } -impl Substitutions { +impl Redactions { pub fn new() -> Self { Default::default() } @@ -26,24 +26,25 @@ impl Substitutions { /// Insert an additional match pattern /// - /// `key` must be enclosed in `[` and `]`. + /// `placeholder` must be enclosed in `[` and `]`. /// /// ```rust - /// let mut subst = snapbox::Substitutions::new(); + /// let mut subst = snapbox::Redactions::new(); /// subst.insert("[EXE]", std::env::consts::EXE_SUFFIX); /// ``` pub fn insert( &mut self, - key: &'static str, + placeholder: &'static str, value: impl Into>, - ) -> Result<(), crate::Error> { - let key = validate_key(key)?; + ) -> crate::assert::Result<()> { + let placeholder = validate_placeholder(placeholder)?; let value = value.into(); if value.is_empty() { - self.unused.insert(key); + self.unused.insert(placeholder); } else { + #[allow(deprecated)] self.vars - .entry(key) + .entry(placeholder) .or_default() .insert(crate::utils::normalize_text(value.as_ref()).into()); } @@ -52,20 +53,20 @@ impl Substitutions { /// Insert additional match patterns /// - /// keys must be enclosed in `[` and `]`. + /// placeholders must be enclosed in `[` and `]`. pub fn extend( &mut self, vars: impl IntoIterator>)>, - ) -> Result<(), crate::Error> { - for (key, value) in vars { - self.insert(key, value)?; + ) -> crate::assert::Result<()> { + for (placeholder, value) in vars { + self.insert(placeholder, value)?; } Ok(()) } - pub fn remove(&mut self, key: &'static str) -> Result<(), crate::Error> { - let key = validate_key(key)?; - self.vars.remove(key); + pub fn remove(&mut self, placeholder: &'static str) -> crate::assert::Result<()> { + let placeholder = validate_placeholder(placeholder)?; + self.vars.remove(placeholder); Ok(()) } @@ -76,7 +77,7 @@ impl Substitutions { /// Otherwise, `input`, with as many patterns replaced as possible, will be returned. /// /// ```rust - /// let subst = snapbox::Substitutions::new(); + /// let subst = snapbox::Redactions::new(); /// let output = subst.normalize("Hello World!", "Hello [..]!"); /// assert_eq!(output, "Hello [..]!"); /// ``` @@ -108,22 +109,22 @@ impl Substitutions { } } -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()); +fn validate_placeholder(placeholder: &'static str) -> crate::assert::Result<&'static str> { + if !placeholder.starts_with('[') || !placeholder.ends_with(']') { + return Err(format!("Key `{}` is not enclosed in []", placeholder).into()); } - if key[1..(key.len() - 1)] + if placeholder[1..(placeholder.len() - 1)] .find(|c: char| !c.is_ascii_uppercase()) .is_some() { - return Err(format!("Key `{}` can only be A-Z but ", key).into()); + return Err(format!("Key `{}` can only be A-Z but ", placeholder).into()); } - Ok(key) + Ok(placeholder) } -fn normalize(input: &str, pattern: &str, substitutions: &Substitutions) -> String { +fn normalize(input: &str, pattern: &str, substitutions: &Redactions) -> String { if input == pattern { return input.to_owned(); } @@ -226,7 +227,7 @@ fn is_line_elide(line: &str) -> bool { line == "...\n" || line == "..." } -fn line_matches(line: &str, pattern: &str, substitutions: &Substitutions) -> bool { +fn line_matches(line: &str, pattern: &str, substitutions: &Redactions) -> bool { if line == pattern { return true; } @@ -265,7 +266,7 @@ mod test { let input = ""; let pattern = ""; let expected = ""; - let actual = normalize(input, pattern, &Substitutions::new()); + let actual = normalize(input, pattern, &Redactions::new()); assert_eq!(expected, actual); } @@ -274,7 +275,7 @@ mod test { let input = "Hello\nWorld"; let pattern = "Hello\nWorld"; let expected = "Hello\nWorld"; - let actual = normalize(input, pattern, &Substitutions::new()); + let actual = normalize(input, pattern, &Redactions::new()); assert_eq!(expected, actual); } @@ -283,7 +284,7 @@ mod test { let input = "Hello\nWorld"; let pattern = "Hello\n"; let expected = "Hello\nWorld"; - let actual = normalize(input, pattern, &Substitutions::new()); + let actual = normalize(input, pattern, &Redactions::new()); assert_eq!(expected, actual); } @@ -292,7 +293,7 @@ mod test { let input = "Hello\n"; let pattern = "Hello\nWorld"; let expected = "Hello\n"; - let actual = normalize(input, pattern, &Substitutions::new()); + let actual = normalize(input, pattern, &Redactions::new()); assert_eq!(expected, actual); } @@ -301,7 +302,7 @@ mod test { let input = "Hello\nWorld"; let pattern = "Goodbye\nMoon"; let expected = "Hello\nWorld"; - let actual = normalize(input, pattern, &Substitutions::new()); + let actual = normalize(input, pattern, &Redactions::new()); assert_eq!(expected, actual); } @@ -310,7 +311,7 @@ mod test { let input = "Hello\nWorld\nGoodbye"; let pattern = "Hello\nMoon\nGoodbye"; let expected = "Hello\nWorld\nGoodbye"; - let actual = normalize(input, pattern, &Substitutions::new()); + let actual = normalize(input, pattern, &Redactions::new()); assert_eq!(expected, actual); } @@ -319,7 +320,7 @@ mod test { let input = "Hello\nWorld\nGoodbye"; let pattern = "...\nGoodbye"; let expected = "...\nGoodbye"; - let actual = normalize(input, pattern, &Substitutions::new()); + let actual = normalize(input, pattern, &Redactions::new()); assert_eq!(expected, actual); } @@ -328,7 +329,7 @@ mod test { let input = "Hello\nWorld\nGoodbye"; let pattern = "Hello\n..."; let expected = "Hello\n..."; - let actual = normalize(input, pattern, &Substitutions::new()); + let actual = normalize(input, pattern, &Redactions::new()); assert_eq!(expected, actual); } @@ -337,7 +338,7 @@ mod test { let input = "Hello\nWorld\nGoodbye"; let pattern = "Hello\n...\nGoodbye"; let expected = "Hello\n...\nGoodbye"; - let actual = normalize(input, pattern, &Substitutions::new()); + let actual = normalize(input, pattern, &Redactions::new()); assert_eq!(expected, actual); } @@ -346,7 +347,7 @@ mod test { let input = "Hello\nSun\nAnd\nWorld"; let pattern = "Hello\n...\nMoon"; let expected = "Hello\nSun\nAnd\nWorld"; - let actual = normalize(input, pattern, &Substitutions::new()); + let actual = normalize(input, pattern, &Redactions::new()); assert_eq!(expected, actual); } @@ -355,7 +356,7 @@ mod test { 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()); + let actual = normalize(input, pattern, &Redactions::new()); assert_eq!(expected, actual); } @@ -364,7 +365,7 @@ mod test { 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()); + let actual = normalize(input, pattern, &Redactions::new()); assert_eq!(expected, actual); } @@ -408,13 +409,13 @@ mod test { ("hello world, goodbye moon", "hello [..], [..] world", false), ]; for (line, pattern, expected) in cases { - let actual = line_matches(line, pattern, &Substitutions::new()); + let actual = line_matches(line, pattern, &Redactions::new()); assert_eq!(expected, actual, "line={:?} pattern={:?}", line, pattern); } } #[test] - fn test_validate_key() { + fn test_validate_placeholder() { let cases = [ ("[HELLO", false), ("HELLO]", false), @@ -422,9 +423,9 @@ mod test { ("[hello]", false), ("[HE O]", false), ]; - for (key, expected) in cases { - let actual = validate_key(key).is_ok(); - assert_eq!(expected, actual, "key={:?}", key); + for (placeholder, expected) in cases { + let actual = validate_placeholder(placeholder).is_ok(); + assert_eq!(expected, actual, "placeholder={:?}", placeholder); } } } diff --git a/crates/snapbox/src/filter/test.rs b/crates/snapbox/src/filter/test.rs new file mode 100644 index 00000000..0f8d1332 --- /dev/null +++ b/crates/snapbox/src/filter/test.rs @@ -0,0 +1,343 @@ +#[cfg(feature = "json")] +use serde_json::json; + +#[cfg(feature = "json")] +use super::*; + +// Tests for normalization on json +#[test] +#[cfg(feature = "json")] +fn json_normalize_paths_and_lines() { + let json = json!({"name": "John\\Doe\r\n"}); + let data = Data::json(json); + let data = FilterPaths.filter(data); + assert_eq!(Data::json(json!({"name": "John/Doe\r\n"})), data); + let data = FilterNewlines.filter(data); + assert_eq!(Data::json(json!({"name": "John/Doe\n"})), data); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_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 = FilterPaths.filter(data); + let assert = json!({ + "person": { + "name": "John/Doe\r\n", + "nickname": "Jo/hn\r\n", + } + }); + assert_eq!(Data::json(assert), data); + let data = FilterNewlines.filter(data); + let assert = json!({ + "person": { + "name": "John/Doe\n", + "nickname": "Jo/hn\n", + } + }); + assert_eq!(Data::json(assert), data); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_array_paths_and_lines() { + let json = json!({"people": ["John\\Doe\r\n", "Jo\\hn\r\n"]}); + let data = Data::json(json); + let data = FilterPaths.filter(data); + let paths = json!({"people": ["John/Doe\r\n", "Jo/hn\r\n"]}); + assert_eq!(Data::json(paths), data); + let data = FilterNewlines.filter(data); + let new_lines = json!({"people": ["John/Doe\n", "Jo/hn\n"]}); + assert_eq!(Data::json(new_lines), data); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_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 = FilterPaths.filter(data); + let paths = json!({ + "people": [ + { + "name": "John/Doe\r\n", + "nickname": "Jo/hn\r\n", + } + ] + }); + assert_eq!(Data::json(paths), data); + let data = FilterNewlines.filter(data); + let new_lines = json!({ + "people": [ + { + "name": "John/Doe\n", + "nickname": "Jo/hn\n", + } + ] + }); + assert_eq!(Data::json(new_lines), data); +} + +#[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 = FilterRedactions::new(&Default::default(), &expected).filter(Data::json(actual)); + 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 = FilterRedactions::new(&Default::default(), &expected).filter(Data::json(actual)); + 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 = FilterRedactions::new(&Default::default(), &expected).filter(Data::json(actual)); + 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 = FilterRedactions::new(&Default::default(), &expected).filter(Data::json(actual)); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_ne!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_wildcard_object_first() { + let exp = json!({ + "people": [ + "{...}", + { + "name": "three", + "nickname": "3", + } + ] + }); + let expected = Data::json(exp); + let actual = json!({ + "people": [ + { + "name": "one", + "nickname": "1", + }, + { + "name": "two", + "nickname": "2", + }, + { + "name": "three", + "nickname": "3", + } + ] + }); + let actual = FilterRedactions::new(&Default::default(), &expected).filter(Data::json(actual)); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_wildcard_array_first() { + let exp = json!([ + "{...}", + { + "name": "three", + "nickname": "3", + } + ]); + let expected = Data::json(exp); + let actual = json!([ + { + "name": "one", + "nickname": "1", + }, + { + "name": "two", + "nickname": "2", + }, + { + "name": "three", + "nickname": "3", + } + ]); + let actual = FilterRedactions::new(&Default::default(), &expected).filter(Data::json(actual)); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_wildcard_array_first_last() { + let exp = json!([ + "{...}", + { + "name": "two", + "nickname": "2", + }, + "{...}" + ]); + let expected = Data::json(exp); + let actual = json!([ + { + "name": "one", + "nickname": "1", + }, + { + "name": "two", + "nickname": "2", + }, + { + "name": "three", + "nickname": "3", + }, + { + "name": "four", + "nickname": "4", + } + ]); + let actual = FilterRedactions::new(&Default::default(), &expected).filter(Data::json(actual)); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_wildcard_array_middle_last() { + let exp = json!([ + { + "name": "one", + "nickname": "1", + }, + "{...}", + { + "name": "three", + "nickname": "3", + }, + "{...}" + ]); + let expected = Data::json(exp); + let actual = json!([ + { + "name": "one", + "nickname": "1", + }, + { + "name": "two", + "nickname": "2", + }, + { + "name": "three", + "nickname": "3", + }, + { + "name": "four", + "nickname": "4", + }, + { + "name": "five", + "nickname": "5", + } + ]); + let actual = FilterRedactions::new(&Default::default(), &expected).filter(Data::json(actual)); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_wildcard_array_middle_last_early_return() { + let exp = json!([ + { + "name": "one", + "nickname": "1", + }, + "{...}", + { + "name": "three", + "nickname": "3", + }, + "{...}" + ]); + let expected = Data::json(exp); + let actual = json!([ + { + "name": "one", + "nickname": "1", + }, + { + "name": "two", + "nickname": "2", + }, + { + "name": "four", + "nickname": "4", + }, + { + "name": "five", + "nickname": "5", + } + ]); + let actual_normalized = + FilterRedactions::new(&Default::default(), &expected).filter(Data::json(actual.clone())); + if let DataInner::Json(act) = actual_normalized.inner { + assert_eq!(act, actual); + } +} diff --git a/crates/snapbox/src/harness.rs b/crates/snapbox/src/harness.rs index 506fd180..0ade60f7 100644 --- a/crates/snapbox/src/harness.rs +++ b/crates/snapbox/src/harness.rs @@ -35,7 +35,8 @@ //! } //! ``` -use crate::data::{DataFormat, NormalizeNewlines}; +use crate::data::DataFormat; +use crate::filter::{Filter as _, FilterNewlines}; use crate::Action; use libtest_mimic::Trial; @@ -125,7 +126,7 @@ where 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 actual = FilterNewlines.filter(crate::Data::text(actual)); #[allow(deprecated)] let verify = Verifier::new() .palette(crate::report::Palette::auto()) @@ -162,7 +163,11 @@ impl Verifier { self } - fn verify(&self, expected_path: &std::path::Path, actual: crate::Data) -> crate::Result<()> { + fn verify( + &self, + expected_path: &std::path::Path, + actual: crate::Data, + ) -> crate::assert::Result<()> { match self.action { Action::Skip => Ok(()), Action::Ignore => { @@ -178,7 +183,7 @@ impl Verifier { &self, expected_path: &std::path::Path, actual: crate::Data, - ) -> crate::Result<()> { + ) -> crate::assert::Result<()> { actual.write_to_path(expected_path)?; Ok(()) } @@ -187,9 +192,11 @@ impl Verifier { &self, expected_path: &std::path::Path, actual: crate::Data, - ) -> crate::Result<()> { - let expected = crate::Data::read_from(expected_path, Some(DataFormat::Text)) - .normalize(NormalizeNewlines); + ) -> crate::assert::Result<()> { + let expected = FilterNewlines.filter(crate::Data::read_from( + expected_path, + Some(DataFormat::Text), + )); if expected != actual { let mut buf = String::new(); diff --git a/crates/snapbox/src/lib.rs b/crates/snapbox/src/lib.rs index ce9fb75b..a6b2f0cd 100644 --- a/crates/snapbox/src/lib.rs +++ b/crates/snapbox/src/lib.rs @@ -95,14 +95,13 @@ #![warn(clippy::print_stderr)] #![warn(clippy::print_stdout)] -mod action; -mod assert; -mod error; mod macros; -mod substitutions; +pub mod assert; pub mod cmd; pub mod data; +pub mod dir; +pub mod filter; pub mod path; pub mod report; pub mod utils; @@ -110,16 +109,24 @@ pub mod utils; #[cfg(feature = "harness")] pub mod harness; -pub use action::Action; -pub use action::DEFAULT_ACTION_ENV; +#[deprecated(since = "0.5.11", note = "Replaced with `assert::Assert`")] +pub use assert::Action; pub use assert::Assert; pub use data::Data; pub use data::ToDebug; -pub use error::Error; +pub use filter::Redactions; pub use snapbox_macros::debug; -pub use substitutions::Substitutions; -pub type Result = std::result::Result; +#[deprecated(since = "0.5.11", note = "Replaced with `Redactions`")] +pub type Substitutions = filter::Redactions; + +#[deprecated(since = "0.5.11", note = "Replaced with `assert::DEFAULT_ACTION_ENV`")] +pub const DEFAULT_ACTION_ENV: &str = assert::DEFAULT_ACTION_ENV; + +#[deprecated(since = "0.5.11", note = "Replaced with `assert::Result`")] +pub type Result = std::result::Result; +#[deprecated(since = "0.5.11", note = "Replaced with `assert::Error`")] +pub type Error = assert::Error; /// Check if a value is the same as an expected value /// @@ -142,7 +149,7 @@ pub type Result = std::result::Result; #[track_caller] pub fn assert_eq(expected: impl Into, actual: impl Into) { Assert::new() - .action_env(DEFAULT_ACTION_ENV) + .action_env(assert::DEFAULT_ACTION_ENV) .eq(expected, actual); } @@ -174,7 +181,7 @@ pub fn assert_eq(expected: impl Into, actual: impl Into, actual: impl Into) { Assert::new() - .action_env(DEFAULT_ACTION_ENV) + .action_env(assert::DEFAULT_ACTION_ENV) .matches(pattern, actual); } @@ -187,14 +194,14 @@ pub fn assert_matches(pattern: impl Into, actual: impl Into, actual_root: impl Into, ) { Assert::new() - .action_env(DEFAULT_ACTION_ENV) + .action_env(assert::DEFAULT_ACTION_ENV) .subset_eq(expected_root, actual_root); } @@ -214,13 +221,13 @@ pub fn assert_subset_eq( /// let expected_root = "tests/snapshots/output.txt"; /// snapbox::assert_subset_matches(expected_root, output_root); /// ``` -#[cfg(feature = "path")] +#[cfg(feature = "dir")] #[track_caller] pub fn assert_subset_matches( pattern_root: impl Into, actual_root: impl Into, ) { Assert::new() - .action_env(DEFAULT_ACTION_ENV) + .action_env(assert::DEFAULT_ACTION_ENV) .subset_matches(pattern_root, actual_root); } diff --git a/crates/snapbox/src/macros.rs b/crates/snapbox/src/macros.rs index 5a72d665..3078bb25 100644 --- a/crates/snapbox/src/macros.rs +++ b/crates/snapbox/src/macros.rs @@ -3,7 +3,7 @@ #[macro_export] macro_rules! current_dir { () => {{ - let root = $crate::path::cargo_rustc_current_dir!(); + let root = $crate::utils::cargo_rustc_current_dir!(); let file = ::std::file!(); let rel_path = ::std::path::Path::new(file).parent().unwrap(); root.join(rel_path) @@ -15,7 +15,7 @@ macro_rules! current_dir { #[macro_export] macro_rules! current_rs { () => {{ - let root = $crate::path::cargo_rustc_current_dir!(); + let root = $crate::utils::cargo_rustc_current_dir!(); let file = ::std::file!(); let rel_path = ::std::path::Path::new(file); root.join(rel_path) diff --git a/crates/snapbox/src/path.rs b/crates/snapbox/src/path.rs index 8d5d0919..de357475 100644 --- a/crates/snapbox/src/path.rs +++ b/crates/snapbox/src/path.rs @@ -7,690 +7,42 @@ pub use crate::current_dir; #[doc(inline)] pub use crate::current_rs; -#[cfg(feature = "path")] -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 { - 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 { - 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 { - 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, - actual_root: impl Into, - ) -> impl Iterator> { - let pattern_root = pattern_root.into(); - let actual_root = actual_root.into(); - Self::subset_eq_iter_inner(pattern_root, actual_root) - } - - #[cfg(feature = "path")] - pub(crate) fn subset_eq_iter_inner( - expected_root: std::path::PathBuf, - actual_root: std::path::PathBuf, - ) -> impl Iterator> { - 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::try_read_from(&actual_path, None).map_err(Self::Failure)?; - - let expected = - crate::Data::read_from(&expected_path, None).normalize(NormalizeNewlines); - - actual = actual - .coerce_to(expected.intended_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, - actual_root: impl Into, - substitutions: &crate::Substitutions, - ) -> impl Iterator> + '_ { - let pattern_root = pattern_root.into(); - let actual_root = actual_root.into(); - Self::subset_matches_iter_inner(pattern_root, actual_root, substitutions, true) - } - - #[cfg(feature = "path")] - pub(crate) fn subset_matches_iter_inner( - expected_root: std::path::PathBuf, - actual_root: std::path::PathBuf, - substitutions: &crate::Substitutions, - normalize_paths: bool, - ) -> impl Iterator> + '_ { - 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::try_read_from(&actual_path, None).map_err(Self::Failure)?; - - let expected = - crate::Data::read_from(&expected_path, None).normalize(NormalizeNewlines); - - actual = actual.coerce_to(expected.intended_format()); - if normalize_paths { - actual = actual.normalize(NormalizePaths); - } - actual = actual - .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_content.source().unwrap()), - } - } -} +#[deprecated(since = "0.5.11", note = "Replaced with dir::DirRoot")] +pub type PathFixture = crate::dir::DirRoot; -#[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) - } -} +pub use crate::dir::FileType; +pub use crate::dir::PathDiff; /// 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; - - fn next(&mut self) -> Option { - while let Some(entry) = self.inner.next().map(|e| { - e.map(walkdir::DirEntry::into_path) - .map_err(std::io::Error::from) - }) { - if entry.as_ref().ok().and_then(|e| e.file_name()) - != Some(std::ffi::OsStr::new(".keep")) - { - return Some(entry); - } - } - None - } -} +#[deprecated(since = "0.5.11", note = "Replaced with dir::Walk")] +#[cfg(feature = "dir")] +pub type Walk = crate::dir::Walk; /// Copy a template into a [`PathFixture`] /// /// Note: Generally you'll use [`PathFixture::with_template`] instead. /// /// Note: Ignores `.keep` files -#[cfg(feature = "path")] +#[deprecated(since = "0.5.11", note = "Replaced with dir::copy_template")] +#[cfg(feature = "dir")] pub fn copy_template( source: impl AsRef, dest: impl AsRef, -) -> 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) +) -> crate::assert::Result<()> { + crate::dir::copy_template(source, dest) } +#[deprecated(since = "0.5.11", note = "Replaced with dir::resolve_dir")] pub fn resolve_dir( path: impl AsRef, ) -> Result { - let path = path.as_ref(); - let meta = std::fs::symlink_metadata(path)?; - if meta.is_dir() { - canonicalize(path) - } else if meta.is_file() { - // Git might checkout symlinks as files - let target = std::fs::read_to_string(path)?; - let target_path = path.parent().unwrap().join(target); - resolve_dir(target_path) - } else { - canonicalize(path) - } -} - -fn canonicalize(path: &std::path::Path) -> Result { - #[cfg(feature = "path")] - { - dunce::canonicalize(path) - } - #[cfg(not(feature = "path"))] - { - // Hope for the best - Ok(strip_trailing_slash(path).to_owned()) - } + crate::dir::resolve_dir(path) } +#[deprecated(since = "0.5.11", note = "Replaced with dir::strip_trailing_slash")] pub fn strip_trailing_slash(path: &std::path::Path) -> &std::path::Path { - path.components().as_path() -} - -pub(crate) fn display_relpath(path: impl AsRef) -> String { - let path = path.as_ref(); - let relpath = if let Ok(cwd) = std::env::current_dir() { - match path.strip_prefix(cwd) { - Ok(path) => path, - Err(_) => path, - } - } else { - path - }; - relpath.display().to_string() -} - -#[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); - } + crate::dir::strip_trailing_slash(path) } diff --git a/crates/snapbox/src/utils/mod.rs b/crates/snapbox/src/utils/mod.rs index d5192419..2dbb862e 100644 --- a/crates/snapbox/src/utils/mod.rs +++ b/crates/snapbox/src/utils/mod.rs @@ -2,22 +2,21 @@ mod lines; pub use lines::LinesWithTerminator; -/// Normalize line endings -pub fn normalize_lines(data: &str) -> String { - normalize_lines_chars(data.chars()).collect() -} +#[doc(inline)] +pub use crate::cargo_rustc_current_dir; +#[doc(inline)] +pub use crate::current_dir; +#[doc(inline)] +pub use crate::current_rs; -fn normalize_lines_chars(data: impl Iterator) -> impl Iterator { - normalize_line_endings::normalized(data) +#[deprecated(since = "0.5.11", note = "Replaced with `filter::normalize_lines`")] +pub fn normalize_lines(data: &str) -> String { + crate::filter::normalize_lines(data) } -/// Normalize path separators +#[deprecated(since = "0.5.11", note = "Replaced with `filter::normalize_paths`")] pub fn normalize_paths(data: &str) -> String { - normalize_paths_chars(data.chars()).collect() -} - -fn normalize_paths_chars(data: impl Iterator) -> impl Iterator { - data.map(|c| if c == '\\' { '/' } else { c }) + crate::filter::normalize_paths(data) } /// "Smart" text normalization @@ -25,6 +24,11 @@ fn normalize_paths_chars(data: impl Iterator) -> impl Iterator String { - normalize_paths_chars(normalize_lines_chars(data.chars())).collect() + #[allow(deprecated)] + normalize_paths(&normalize_lines(data)) } diff --git a/crates/trycmd/Cargo.toml b/crates/trycmd/Cargo.toml index 1b23ca51..0b4624bf 100644 --- a/crates/trycmd/Cargo.toml +++ b/crates/trycmd/Cargo.toml @@ -33,7 +33,7 @@ default = ["color-auto", "filesystem", "diff"] color = ["snapbox/color", "dep:anstream"] color-auto = ["snapbox/color-auto"] diff = ["snapbox/diff"] -filesystem = ["snapbox/path"] +filesystem = ["snapbox/dir"] schema = ["dep:schemars", "dep:serde_json"] examples = ["snapbox/examples"] diff --git a/crates/trycmd/src/cases.rs b/crates/trycmd/src/cases.rs index 405c425a..196891d7 100644 --- a/crates/trycmd/src/cases.rs +++ b/crates/trycmd/src/cases.rs @@ -5,7 +5,7 @@ use std::borrow::Cow; pub struct TestCases { runner: std::cell::RefCell, bins: std::cell::RefCell, - substitutions: std::cell::RefCell, + substitutions: std::cell::RefCell, has_run: std::cell::Cell, } diff --git a/crates/trycmd/src/lib.rs b/crates/trycmd/src/lib.rs index 70d9848a..8efd3a81 100644 --- a/crates/trycmd/src/lib.rs +++ b/crates/trycmd/src/lib.rs @@ -238,7 +238,7 @@ mod runner; mod spec; pub use cases::TestCases; -pub use snapbox::Error; +pub use snapbox::assert::Error; pub(crate) use registry::BinRegistry; pub(crate) use runner::{Case, Mode, Runner}; diff --git a/crates/trycmd/src/runner.rs b/crates/trycmd/src/runner.rs index d175cca4..0d405fd4 100644 --- a/crates/trycmd/src/runner.rs +++ b/crates/trycmd/src/runner.rs @@ -12,8 +12,9 @@ use std::eprintln; use std::io::stderr; use rayon::prelude::*; -use snapbox::data::{DataFormat, NormalizeNewlines, NormalizePaths}; -use snapbox::path::FileType; +use snapbox::data::DataFormat; +use snapbox::dir::FileType; +use snapbox::filter::{Filter as _, FilterNewlines, FilterPaths, FilterRedactions}; #[derive(Debug)] pub(crate) struct Runner { @@ -35,7 +36,7 @@ impl Runner { &self, mode: &Mode, bins: &crate::BinRegistry, - substitutions: &snapbox::Substitutions, + substitutions: &snapbox::Redactions, ) { let palette = snapbox::report::Palette::color(); @@ -139,7 +140,7 @@ impl Case { &self, mode: &Mode, bins: &crate::BinRegistry, - substitutions: &snapbox::Substitutions, + substitutions: &snapbox::Redactions, ) -> Vec> { if self.expected == Some(crate::schema::CommandStatus::Skipped) { let output = Output::sequence(self.path.clone()); @@ -186,7 +187,7 @@ impl Case { .map(|p| { sequence.fs.rel_cwd().map(|rel| { let p = p.join(rel); - snapbox::path::strip_trailing_slash(&p).to_owned() + snapbox::dir::strip_trailing_slash(&p).to_owned() }) }) .transpose() @@ -308,7 +309,7 @@ impl Case { step: &mut crate::schema::Step, cwd: Option<&std::path::Path>, bins: &crate::BinRegistry, - substitutions: &snapbox::Substitutions, + substitutions: &snapbox::Redactions, ) -> Result { let output = if let Some(id) = step.id.clone() { Output::step(self.path.clone(), id) @@ -406,7 +407,7 @@ impl Case { &self, mut output: Output, step: &crate::schema::Step, - substitutions: &snapbox::Substitutions, + substitutions: &snapbox::Redactions, ) -> Output { output.stdout = self.validate_stream( output.stdout, @@ -429,7 +430,7 @@ impl Case { stream: Option, expected_content: Option<&crate::Data>, binary: bool, - substitutions: &snapbox::Substitutions, + substitutions: &snapbox::Redactions, ) -> Option { let mut stream = stream?; @@ -441,12 +442,8 @@ impl Case { } if let Some(expected_content) = expected_content { - stream.content = stream - .content - .normalize(snapbox::data::NormalizeMatches::new( - substitutions, - expected_content, - )); + stream.content = + FilterRedactions::new(substitutions, expected_content).filter(stream.content); if stream.content != *expected_content { stream.status = StreamStatus::Expected(expected_content.clone()); @@ -500,17 +497,17 @@ impl Case { actual_root: &std::path::Path, mut fs: Filesystem, mode: &Mode, - substitutions: &snapbox::Substitutions, + substitutions: &snapbox::Redactions, ) -> Result { let mut ok = true; #[cfg(feature = "filesystem")] if let Mode::Dump(_) = mode { - // Handled as part of PathFixture + // Handled as part of DirRoot } else { let fixture_root = self.path.with_extension("out"); if fixture_root.exists() { - for status in snapbox::path::PathDiff::subset_matches_iter( + for status in snapbox::dir::PathDiff::subset_matches_iter( fixture_root, actual_root, substitutions, @@ -740,9 +737,7 @@ impl Stream { if content.format() != DataFormat::Text { self.status = StreamStatus::Failure("Unable to convert underlying Data to Text".into()); } - self.content = content - .normalize(NormalizePaths) - .normalize(NormalizeNewlines); + self.content = FilterNewlines.filter(FilterPaths.filter(content)); self } @@ -886,11 +881,11 @@ impl FileStatus { } } -impl From for FileStatus { - fn from(other: snapbox::path::PathDiff) -> Self { +impl From for FileStatus { + fn from(other: snapbox::dir::PathDiff) -> Self { match other { - snapbox::path::PathDiff::Failure(err) => FileStatus::Failure(err), - snapbox::path::PathDiff::TypeMismatch { + snapbox::dir::PathDiff::Failure(err) => FileStatus::Failure(err), + snapbox::dir::PathDiff::TypeMismatch { expected_path, actual_path, expected_type, @@ -901,7 +896,7 @@ impl From for FileStatus { actual_type, expected_type, }, - snapbox::path::PathDiff::LinkMismatch { + snapbox::dir::PathDiff::LinkMismatch { expected_path, actual_path, expected_target, @@ -912,7 +907,7 @@ impl From for FileStatus { actual_target, expected_target, }, - snapbox::path::PathDiff::ContentMismatch { + snapbox::dir::PathDiff::ContentMismatch { expected_path, actual_path, expected_content, @@ -1024,20 +1019,20 @@ fn fs_context( cwd: Option<&std::path::Path>, sandbox: bool, mode: &crate::Mode, -) -> Result { +) -> Result { if sandbox { #[cfg(feature = "filesystem")] match mode { crate::Mode::Dump(root) => { let target = root.join(path.with_extension("out").file_name().unwrap()); - let mut context = snapbox::path::PathFixture::mutable_at(&target)?; + let mut context = snapbox::dir::DirRoot::mutable_at(&target)?; if let Some(cwd) = cwd { context = context.with_template(cwd)?; } Ok(context) } crate::Mode::Fail | crate::Mode::Overwrite => { - let mut context = snapbox::path::PathFixture::mutable_temp()?; + let mut context = snapbox::dir::DirRoot::mutable_temp()?; if let Some(cwd) = cwd { context = context.with_template(cwd)?; } @@ -1048,7 +1043,7 @@ fn fs_context( Err("Sandboxing is disabled".into()) } else { Ok(cwd - .map(snapbox::path::PathFixture::immutable) - .unwrap_or_else(snapbox::path::PathFixture::none)) + .map(snapbox::dir::DirRoot::immutable) + .unwrap_or_else(snapbox::dir::DirRoot::none)) } } diff --git a/crates/trycmd/src/schema.rs b/crates/trycmd/src/schema.rs index 35afed44..78e47975 100644 --- a/crates/trycmd/src/schema.rs +++ b/crates/trycmd/src/schema.rs @@ -2,7 +2,7 @@ //! //! [`OneShot`] is the top-level item in the `cmd.toml` files. -use snapbox::data::{NormalizeNewlines, NormalizePaths}; +use snapbox::filter::{Filter as _, FilterNewlines, FilterPaths}; use std::collections::BTreeMap; use std::collections::VecDeque; @@ -40,9 +40,10 @@ 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)) - .normalize(NormalizePaths) - .normalize(NormalizeNewlines), + FilterNewlines.filter( + FilterPaths + .filter(crate::Data::read_from(&stdout_path, Some(is_binary))), + ), ) } else { None @@ -54,9 +55,10 @@ 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)) - .normalize(NormalizePaths) - .normalize(NormalizeNewlines), + FilterNewlines.filter( + FilterPaths + .filter(crate::Data::read_from(&stderr_path, Some(is_binary))), + ), ) } else { None @@ -68,7 +70,7 @@ impl TryCmd { } else if ext == std::ffi::OsStr::new("trycmd") || ext == std::ffi::OsStr::new("md") { let raw = std::fs::read_to_string(path) .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; - let normalized = snapbox::utils::normalize_lines(&raw); + let normalized = snapbox::filter::normalize_lines(&raw); Self::parse_trycmd(&normalized)? } else { return Err(format!("Unsupported extension: {}", ext.to_string_lossy()).into()); @@ -107,13 +109,13 @@ impl TryCmd { .fs .base .take() - .map(|p| snapbox::path::resolve_dir(p).map_err(|e| e.to_string())) + .map(|p| snapbox::dir::resolve_dir(p).map_err(|e| e.to_string())) .transpose()?; sequence.fs.cwd = sequence .fs .cwd .take() - .map(|p| snapbox::path::resolve_dir(p).map_err(|e| e.to_string())) + .map(|p| snapbox::dir::resolve_dir(p).map_err(|e| e.to_string())) .transpose()?; Ok(sequence) @@ -159,7 +161,7 @@ impl TryCmd { let raw = std::fs::read_to_string(path) .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; - let mut normalized = snapbox::utils::normalize_lines(&raw); + let mut normalized = snapbox::filter::normalize_lines(&raw); overwrite_trycmd_status(exit, step, &mut line_nums, &mut normalized)?; @@ -444,7 +446,7 @@ fn overwrite_trycmd_status( step: &Step, stdout_line_nums: &mut std::ops::Range, normalized: &mut String, -) -> Result<(), snapbox::Error> { +) -> Result<(), crate::Error> { let status = match exit { Some(status) => status, _ => {