diff --git a/crates/snapbox/src/assert/mod.rs b/crates/snapbox/src/assert/mod.rs index d4bc71d5..ce04d95f 100644 --- a/crates/snapbox/src/assert/mod.rs +++ b/crates/snapbox/src/assert/mod.rs @@ -8,7 +8,7 @@ use anstream::stderr; #[cfg(not(feature = "color"))] use std::io::stderr; -use crate::filter::{Filter as _, FilterNewlines, FilterPaths, FilterRedactions}; +use crate::filter::{Filter as _, FilterNewlines, FilterPaths, NormalizeToExpected}; use crate::IntoData; pub use action::Action; @@ -127,9 +127,15 @@ impl Assert { if expected.filters.is_newlines_set() { actual = FilterNewlines.filter(actual); } + + let mut normalize = NormalizeToExpected::new(); if expected.filters.is_redaction_set() { - actual = FilterRedactions::new(&self.substitutions, &expected).filter(actual); + normalize = normalize.redact_with(&self.substitutions); + } + if expected.filters.is_unordered_set() { + normalize = normalize.unordered(); } + actual = normalize.normalize(actual, &expected); (actual, expected) } diff --git a/crates/snapbox/src/data/filters.rs b/crates/snapbox/src/data/filters.rs index 8b1e4ce1..5b0103b3 100644 --- a/crates/snapbox/src/data/filters.rs +++ b/crates/snapbox/src/data/filters.rs @@ -27,6 +27,11 @@ impl FilterSet { self } + pub(crate) fn unordered(mut self) -> Self { + self.set(Self::UNORDERED); + self + } + pub(crate) const fn is_redaction_set(&self) -> bool { self.is_set(Self::REDACTIONS) } @@ -38,12 +43,17 @@ impl FilterSet { pub(crate) const fn is_paths_set(&self) -> bool { self.is_set(Self::PATHS) } + + pub(crate) const fn is_unordered_set(&self) -> bool { + self.is_set(Self::UNORDERED) + } } impl FilterSet { const REDACTIONS: usize = 1 << 0; const NEWLINES: usize = 1 << 1; const PATHS: usize = 1 << 2; + const UNORDERED: usize = 1 << 3; fn set(&mut self, flag: usize) -> &mut Self { self.flags |= flag; diff --git a/crates/snapbox/src/data/mod.rs b/crates/snapbox/src/data/mod.rs index db8e5026..180c2c56 100644 --- a/crates/snapbox/src/data/mod.rs +++ b/crates/snapbox/src/data/mod.rs @@ -80,6 +80,29 @@ pub trait IntoData: Sized { self.into_data().raw() } + /// Treat lines and json arrays as unordered + /// + /// # Examples + /// + /// ```rust + /// # #[cfg(feature = "json")] { + /// use snapbox::prelude::*; + /// use snapbox::str; + /// use snapbox::assert_data_eq; + /// + /// let actual = str![[r#"["world", "hello"]"#]] + /// .is(snapbox::data::DataFormat::Json) + /// .unordered(); + /// let expected = str![[r#"["hello", "world"]"#]] + /// .is(snapbox::data::DataFormat::Json) + /// .unordered(); + /// assert_data_eq!(actual, expected); + /// # } + /// ``` + fn unordered(self) -> Data { + self.into_data().unordered() + } + /// Initialize as [`format`][DataFormat] or [`Error`][DataFormat::Error] /// /// This is generally used for `expected` data @@ -301,6 +324,12 @@ impl Data { self.filters = FilterSet::empty().newlines(); self } + + /// Treat lines and json arrays as unordered + pub fn unordered(mut self) -> Self { + self.filters = FilterSet::empty().unordered(); + self + } } /// # Assertion frameworks operations diff --git a/crates/snapbox/src/dir/diff.rs b/crates/snapbox/src/dir/diff.rs index dafc7860..207e43f9 100644 --- a/crates/snapbox/src/dir/diff.rs +++ b/crates/snapbox/src/dir/diff.rs @@ -1,5 +1,5 @@ #[cfg(feature = "dir")] -use crate::filter::{Filter as _, FilterNewlines, FilterPaths, FilterRedactions}; +use crate::filter::{Filter as _, FilterNewlines, FilterPaths, NormalizeToExpected}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum PathDiff { @@ -160,8 +160,9 @@ impl PathDiff { if normalize_paths { actual = FilterPaths.filter(actual); } - actual = FilterRedactions::new(substitutions, &expected) - .filter(FilterNewlines.filter(actual)); + actual = NormalizeToExpected::new() + .redact_with(substitutions) + .normalize(FilterNewlines.filter(actual), &expected); if expected != actual { return Err(Self::ContentMismatch { diff --git a/crates/snapbox/src/filter/mod.rs b/crates/snapbox/src/filter/mod.rs index 570a47e5..0d4f91d3 100644 --- a/crates/snapbox/src/filter/mod.rs +++ b/crates/snapbox/src/filter/mod.rs @@ -4,13 +4,19 @@ //! - Making snapshots consistent across platforms or conditional compilation //! - Focusing snapshots on the characteristics of the data being tested +mod pattern; mod redactions; #[cfg(test)] mod test; +#[cfg(test)] +mod test_redactions; +#[cfg(test)] +mod test_unordered_redactions; use crate::data::DataInner; use crate::Data; +pub use pattern::NormalizeToExpected; pub use redactions::RedactedValue; pub use redactions::Redactions; @@ -33,13 +39,13 @@ impl Filter for FilterNewlines { #[cfg(feature = "json")] DataInner::Json(value) => { let mut value = value; - normalize_json_string(&mut value, normalize_lines); + normalize_json_string(&mut value, &normalize_lines); DataInner::Json(value) } #[cfg(feature = "json")] DataInner::JsonLines(value) => { let mut value = value; - normalize_json_string(&mut value, normalize_lines); + normalize_json_string(&mut value, &normalize_lines); DataInner::JsonLines(value) } #[cfg(feature = "term-svg")] @@ -80,13 +86,13 @@ impl Filter for FilterPaths { #[cfg(feature = "json")] DataInner::Json(value) => { let mut value = value; - normalize_json_string(&mut value, normalize_paths); + normalize_json_string(&mut value, &normalize_paths); DataInner::Json(value) } #[cfg(feature = "json")] DataInner::JsonLines(value) => { let mut value = value; - normalize_json_string(&mut value, normalize_paths); + normalize_json_string(&mut value, &normalize_paths); DataInner::JsonLines(value) } #[cfg(feature = "term-svg")] @@ -117,21 +123,10 @@ fn normalize_paths_chars(data: impl Iterator) -> impl Iterator { - 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, - } - } +struct NormalizeRedactions<'r> { + redactions: &'r Redactions, } - -impl Filter for FilterRedactions<'_> { +impl Filter for NormalizeRedactions<'_> { fn filter(&self, data: Data) -> Data { let source = data.source; let filters = data.filters; @@ -139,37 +134,25 @@ impl Filter for FilterRedactions<'_> { 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) - } + let lines = self.redactions.redact(&text); + DataInner::Text(lines) } #[cfg(feature = "json")] DataInner::Json(value) => { let mut value = value; - if let DataInner::Json(exp) = &self.pattern.inner { - normalize_value_matches(&mut value, exp, self.substitutions); - } + normalize_json_string(&mut value, &|s| self.redactions.redact(s)); DataInner::Json(value) } #[cfg(feature = "json")] DataInner::JsonLines(value) => { let mut value = value; - if let DataInner::Json(exp) = &self.pattern.inner { - normalize_value_matches(&mut value, exp, self.substitutions); - } + normalize_json_string(&mut value, &|s| self.redactions.redact(s)); DataInner::JsonLines(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) - } + let lines = normalize_lines(&text); + DataInner::TermSvg(lines) } }; Data { @@ -181,7 +164,7 @@ impl Filter for FilterRedactions<'_> { } #[cfg(feature = "structured-data")] -fn normalize_json_string(value: &mut serde_json::Value, op: fn(&str) -> String) { +fn normalize_json_string(value: &mut serde_json::Value, op: &dyn Fn(&str) -> String) { match value { serde_json::Value::String(str) => { *str = op(str); @@ -201,75 +184,3 @@ fn normalize_json_string(value: &mut serde_json::Value, op: fn(&str) -> String) _ => {} } } - -#[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 KEY_WILDCARD: &str = "..."; - 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)) => { - let has_key_wildcard = - exp.get(KEY_WILDCARD).and_then(|v| v.as_str()) == Some(VALUE_WILDCARD); - for (actual_key, mut actual_value) in std::mem::replace(act, serde_json::Map::new()) { - let actual_key = substitutions.redact(&actual_key); - if let Some(expected_value) = exp.get(&actual_key) { - normalize_value_matches(&mut actual_value, expected_value, substitutions) - } else if has_key_wildcard { - continue; - } - act.insert(actual_key, actual_value); - } - if has_key_wildcard { - act.insert(KEY_WILDCARD.to_owned(), String(VALUE_WILDCARD.to_owned())); - } - } - (_, _) => {} - } -} diff --git a/crates/snapbox/src/filter/pattern.rs b/crates/snapbox/src/filter/pattern.rs new file mode 100644 index 00000000..17698336 --- /dev/null +++ b/crates/snapbox/src/filter/pattern.rs @@ -0,0 +1,612 @@ +use super::{Filter, NormalizeRedactions, Redactions}; +use crate::data::DataInner; +use crate::Data; + +/// Adjust `actual` based on `expected` +pub struct NormalizeToExpected<'a> { + substitutions: Option<&'a crate::Redactions>, + unordered: bool, +} + +impl<'a> NormalizeToExpected<'a> { + pub fn new() -> Self { + Self { + substitutions: None, + unordered: false, + } + } + + /// Make unordered content comparable + /// + /// This is done by re-ordering `actual` according to `expected`. + pub fn unordered(mut self) -> Self { + self.unordered = true; + self + } + + /// Apply built-in redactions. + /// + /// Built-in redactions: + /// - `...` on a line of its own: match multiple complete lines + /// - `[..]`: match multiple characters within a line + /// + /// Built-ins cannot automatically be applied to `actual` but are inferred from `expected` + pub fn redact(mut self) -> Self { + static REDACTIONS: Redactions = Redactions::new(); + self.substitutions = Some(&REDACTIONS); + self + } + + /// Apply built-in and user [`Redactions`] + /// + /// Built-in redactions: + /// - `...` on a line of its own: match multiple complete lines + /// - `[..]`: match multiple characters within a line + /// + /// Built-ins cannot automatically be applied to `actual` but are inferred from `expected` + pub fn redact_with(mut self, redactions: &'a crate::Redactions) -> Self { + self.substitutions = Some(redactions); + self + } + + pub fn normalize(&self, actual: Data, expected: &Data) -> Data { + let actual = if let Some(substitutions) = self.substitutions { + NormalizeRedactions { + redactions: substitutions, + } + .filter(actual) + } else { + actual + }; + match (self.substitutions, self.unordered) { + (None, false) => actual, + (Some(substitutions), false) => { + normalize_data_to_redactions(actual, expected, substitutions) + } + (None, true) => normalize_data_to_unordered(actual, expected), + (Some(substitutions), true) => { + normalize_data_to_unordered_redactions(actual, expected, substitutions) + } + } + } +} + +impl Default for NormalizeToExpected<'_> { + fn default() -> Self { + Self::new() + } +} + +fn normalize_data_to_unordered(actual: Data, expected: &Data) -> Data { + let source = actual.source; + let filters = actual.filters; + let inner = match actual.inner { + DataInner::Error(err) => DataInner::Error(err), + DataInner::Binary(bin) => DataInner::Binary(bin), + DataInner::Text(text) => { + if let Some(pattern) = expected.render() { + let lines = normalize_str_to_unordered(&text, &pattern); + DataInner::Text(lines) + } else { + DataInner::Text(text) + } + } + #[cfg(feature = "json")] + DataInner::Json(value) => { + let mut value = value; + if let DataInner::Json(exp) = &expected.inner { + normalize_value_to_unordered(&mut value, exp); + } + DataInner::Json(value) + } + #[cfg(feature = "json")] + DataInner::JsonLines(value) => { + let mut value = value; + if let DataInner::Json(exp) = &expected.inner { + normalize_value_to_unordered(&mut value, exp); + } + DataInner::JsonLines(value) + } + #[cfg(feature = "term-svg")] + DataInner::TermSvg(text) => { + if let Some(pattern) = expected.render() { + let lines = normalize_str_to_unordered(&text, &pattern); + DataInner::TermSvg(lines) + } else { + DataInner::TermSvg(text) + } + } + }; + Data { + inner, + source, + filters, + } +} + +#[cfg(feature = "structured-data")] +fn normalize_value_to_unordered(actual: &mut serde_json::Value, expected: &serde_json::Value) { + use serde_json::Value::*; + + match (actual, expected) { + (String(act), String(exp)) => { + *act = normalize_str_to_unordered(act, exp); + } + (Array(act), Array(exp)) => { + let mut actual_values = std::mem::take(act); + let mut expected_values = exp.clone(); + expected_values.retain(|expected_value| { + let mut matched = false; + actual_values.retain(|actual_value| { + if !matched && actual_value == expected_value { + matched = true; + false + } else { + true + } + }); + if matched { + act.push(expected_value.clone()); + } + !matched + }); + for actual_value in actual_values { + act.push(actual_value); + } + } + (Object(act), Object(exp)) => { + for (actual_key, mut actual_value) in std::mem::replace(act, serde_json::Map::new()) { + if let Some(expected_value) = exp.get(&actual_key) { + normalize_value_to_unordered(&mut actual_value, expected_value) + } + act.insert(actual_key, actual_value); + } + } + (_, _) => {} + } +} + +fn normalize_str_to_unordered(actual: &str, expected: &str) -> String { + if actual == expected { + return actual.to_owned(); + } + + let mut normalized: Vec<&str> = Vec::new(); + let mut actual_lines: Vec<_> = crate::utils::LinesWithTerminator::new(actual).collect(); + let mut expected_lines: Vec<_> = crate::utils::LinesWithTerminator::new(expected).collect(); + expected_lines.retain(|expected_line| { + let mut matched = false; + actual_lines.retain(|actual_line| { + if !matched && actual_line == expected_line { + matched = true; + false + } else { + true + } + }); + if matched { + normalized.push(expected_line); + } + !matched + }); + for actual_line in &actual_lines { + normalized.push(actual_line); + } + + normalized.join("") +} + +#[cfg(feature = "structured-data")] +const KEY_WILDCARD: &str = "..."; +#[cfg(feature = "structured-data")] +const VALUE_WILDCARD: &str = "{...}"; + +fn normalize_data_to_unordered_redactions( + actual: Data, + expected: &Data, + substitutions: &crate::Redactions, +) -> Data { + let source = actual.source; + let filters = actual.filters; + let inner = match actual.inner { + DataInner::Error(err) => DataInner::Error(err), + DataInner::Binary(bin) => DataInner::Binary(bin), + DataInner::Text(text) => { + if let Some(pattern) = expected.render() { + let lines = normalize_str_to_unordered_redactions(&text, &pattern, substitutions); + DataInner::Text(lines) + } else { + DataInner::Text(text) + } + } + #[cfg(feature = "json")] + DataInner::Json(value) => { + let mut value = value; + if let DataInner::Json(exp) = &expected.inner { + normalize_value_to_unordered_redactions(&mut value, exp, substitutions); + } + DataInner::Json(value) + } + #[cfg(feature = "json")] + DataInner::JsonLines(value) => { + let mut value = value; + if let DataInner::Json(exp) = &expected.inner { + normalize_value_to_unordered_redactions(&mut value, exp, substitutions); + } + DataInner::JsonLines(value) + } + #[cfg(feature = "term-svg")] + DataInner::TermSvg(text) => { + if let Some(pattern) = expected.render() { + let lines = normalize_str_to_unordered_redactions(&text, &pattern, substitutions); + DataInner::TermSvg(lines) + } else { + DataInner::TermSvg(text) + } + } + }; + Data { + inner, + source, + filters, + } +} + +#[cfg(feature = "structured-data")] +fn normalize_value_to_unordered_redactions( + actual: &mut serde_json::Value, + expected: &serde_json::Value, + substitutions: &crate::Redactions, +) { + use serde_json::Value::*; + + match (actual, expected) { + (act, String(exp)) if exp == VALUE_WILDCARD => { + *act = serde_json::json!(VALUE_WILDCARD); + } + (String(act), String(exp)) => { + *act = normalize_str_to_unordered_redactions(act, exp, substitutions); + } + (Array(act), Array(exp)) => { + let mut actual_values = std::mem::take(act); + let mut expected_values = exp.clone(); + let mut elided = false; + expected_values.retain(|expected_value| { + let mut matched = false; + if expected_value == VALUE_WILDCARD { + matched = true; + elided = true; + } else { + actual_values.retain(|actual_value| { + if !matched && actual_value == expected_value { + matched = true; + false + } else { + true + } + }); + } + if matched { + act.push(expected_value.clone()); + } + !matched + }); + if !elided { + for actual_value in actual_values { + act.push(actual_value); + } + } + } + (Object(act), Object(exp)) => { + let has_key_wildcard = + exp.get(KEY_WILDCARD).and_then(|v| v.as_str()) == Some(VALUE_WILDCARD); + for (actual_key, mut actual_value) in std::mem::replace(act, serde_json::Map::new()) { + if let Some(expected_value) = exp.get(&actual_key) { + normalize_value_to_unordered_redactions( + &mut actual_value, + expected_value, + substitutions, + ) + } else if has_key_wildcard { + continue; + } + act.insert(actual_key, actual_value); + } + if has_key_wildcard { + act.insert(KEY_WILDCARD.to_owned(), String(VALUE_WILDCARD.to_owned())); + } + } + (_, _) => {} + } +} + +fn normalize_str_to_unordered_redactions( + actual: &str, + expected: &str, + substitutions: &crate::Redactions, +) -> String { + if actual == expected { + return actual.to_owned(); + } + + let mut normalized: Vec<&str> = Vec::new(); + let mut actual_lines: Vec<_> = crate::utils::LinesWithTerminator::new(actual).collect(); + let mut expected_lines: Vec<_> = crate::utils::LinesWithTerminator::new(expected).collect(); + let mut elided = false; + expected_lines.retain(|expected_line| { + let mut matched = false; + if is_line_elide(expected_line) { + matched = true; + elided = true; + } else { + actual_lines.retain(|actual_line| { + if !matched && line_matches(actual_line, expected_line, substitutions) { + matched = true; + false + } else { + true + } + }); + } + if matched { + normalized.push(expected_line); + } + !matched + }); + if !elided { + for actual_line in &actual_lines { + normalized.push(actual_line); + } + } + + normalized.join("") +} + +fn normalize_data_to_redactions( + actual: Data, + expected: &Data, + substitutions: &crate::Redactions, +) -> Data { + let source = actual.source; + let filters = actual.filters; + let inner = match actual.inner { + DataInner::Error(err) => DataInner::Error(err), + DataInner::Binary(bin) => DataInner::Binary(bin), + DataInner::Text(text) => { + if let Some(pattern) = expected.render() { + let lines = normalize_str_to_redactions(&text, &pattern, substitutions); + DataInner::Text(lines) + } else { + DataInner::Text(text) + } + } + #[cfg(feature = "json")] + DataInner::Json(value) => { + let mut value = value; + if let DataInner::Json(exp) = &expected.inner { + normalize_value_to_redactions(&mut value, exp, substitutions); + } + DataInner::Json(value) + } + #[cfg(feature = "json")] + DataInner::JsonLines(value) => { + let mut value = value; + if let DataInner::Json(exp) = &expected.inner { + normalize_value_to_redactions(&mut value, exp, substitutions); + } + DataInner::JsonLines(value) + } + #[cfg(feature = "term-svg")] + DataInner::TermSvg(text) => { + if let Some(pattern) = expected.render() { + let lines = normalize_str_to_redactions(&text, &pattern, substitutions); + DataInner::TermSvg(lines) + } else { + DataInner::TermSvg(text) + } + } + }; + Data { + inner, + source, + filters, + } +} + +#[cfg(feature = "structured-data")] +fn normalize_value_to_redactions( + actual: &mut serde_json::Value, + expected: &serde_json::Value, + substitutions: &crate::Redactions, +) { + use serde_json::Value::*; + + match (actual, expected) { + (act, String(exp)) if exp == VALUE_WILDCARD => { + *act = serde_json::json!(VALUE_WILDCARD); + } + (String(act), String(exp)) => { + *act = normalize_str_to_redactions(act, exp, substitutions); + } + (Array(act), Array(exp)) => { + 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_to_redactions(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)) => { + let has_key_wildcard = + exp.get(KEY_WILDCARD).and_then(|v| v.as_str()) == Some(VALUE_WILDCARD); + for (actual_key, mut actual_value) in std::mem::replace(act, serde_json::Map::new()) { + if let Some(expected_value) = exp.get(&actual_key) { + normalize_value_to_redactions(&mut actual_value, expected_value, substitutions) + } else if has_key_wildcard { + continue; + } + act.insert(actual_key, actual_value); + } + if has_key_wildcard { + act.insert(KEY_WILDCARD.to_owned(), String(VALUE_WILDCARD.to_owned())); + } + } + (_, _) => {} + } +} + +fn normalize_str_to_redactions(input: &str, pattern: &str, redactions: &Redactions) -> String { + if input == pattern { + return input.to_owned(); + } + + let mut normalized: Vec<&str> = Vec::new(); + let mut input_index = 0; + let input_lines: Vec<_> = crate::utils::LinesWithTerminator::new(input).collect(); + let mut pattern_lines = crate::utils::LinesWithTerminator::new(pattern).peekable(); + 'outer: while let Some(pattern_line) = pattern_lines.next() { + if is_line_elide(pattern_line) { + if let Some(next_pattern_line) = pattern_lines.peek() { + for (index_offset, next_input_line) in + input_lines[input_index..].iter().copied().enumerate() + { + if line_matches(next_input_line, next_pattern_line, redactions) { + normalized.push(pattern_line); + input_index += index_offset; + continue 'outer; + } + } + // Give up doing further normalization + break; + } else { + // Give up doing further normalization + normalized.push(pattern_line); + // captured rest so don't copy remaining lines over + input_index = input_lines.len(); + break; + } + } else { + let Some(input_line) = input_lines.get(input_index) else { + // Give up doing further normalization + break; + }; + + if line_matches(input_line, pattern_line, redactions) { + input_index += 1; + normalized.push(pattern_line); + } else { + // Give up doing further normalization + break; + } + } + } + + normalized.extend(input_lines[input_index..].iter().copied()); + normalized.join("") +} + +fn is_line_elide(line: &str) -> bool { + line == "...\n" || line == "..." +} + +fn line_matches(mut input: &str, pattern: &str, redactions: &Redactions) -> bool { + if input == pattern { + return true; + } + + let pattern = redactions.clear(pattern); + let mut sections = pattern.split("[..]").peekable(); + while let Some(section) = sections.next() { + if let Some(remainder) = input.strip_prefix(section) { + if let Some(next_section) = sections.peek() { + if next_section.is_empty() { + input = ""; + } else if let Some(restart_index) = remainder.find(next_section) { + input = &remainder[restart_index..]; + } + } else { + return remainder.is_empty(); + } + } else { + return false; + } + } + + false +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn str_normalize_redactions_line_matches_cases() { + let cases = [ + ("", "", true), + ("", "[..]", true), + ("hello", "hello", true), + ("hello", "goodbye", false), + ("hello", "[..]", true), + ("hello", "he[..]", true), + ("hello", "go[..]", false), + ("hello", "[..]o", true), + ("hello", "[..]e", false), + ("hello", "he[..]o", true), + ("hello", "he[..]e", false), + ("hello", "go[..]o", false), + ("hello", "go[..]e", false), + ( + "hello world, goodbye moon", + "hello [..], goodbye [..]", + true, + ), + ( + "hello world, goodbye moon", + "goodbye [..], goodbye [..]", + false, + ), + ( + "hello world, goodbye moon", + "goodbye [..], hello [..]", + false, + ), + ("hello world, goodbye moon", "hello [..], [..] moon", true), + ( + "hello world, goodbye moon", + "goodbye [..], [..] moon", + false, + ), + ("hello world, goodbye moon", "hello [..], [..] world", false), + ]; + for (line, pattern, expected) in cases { + let actual = line_matches(line, pattern, &Redactions::new()); + assert_eq!(expected, actual, "line={:?} pattern={:?}", line, pattern); + } + } +} diff --git a/crates/snapbox/src/filter/redactions.rs b/crates/snapbox/src/filter/redactions.rs index 8914900f..03f35793 100644 --- a/crates/snapbox/src/filter/redactions.rs +++ b/crates/snapbox/src/filter/redactions.rs @@ -4,18 +4,32 @@ use std::path::PathBuf; /// Replace data with placeholders /// -/// Built-in placeholders: -/// - `...` on a line of its own: match multiple complete lines -/// - `[..]`: match multiple characters within a line +/// This can be used for: +/// - Handling test-run dependent data like temp directories or elapsed time +/// - Making special characters more obvious (e.g. redacting a tab a `[TAB]`) +/// - Normalizing platform-specific data like [`std::env::consts::EXE_SUFFIX`] +/// +/// # Examples +/// +/// ```rust +/// let mut subst = snapbox::Redactions::new(); +/// subst.insert("[LOCATION]", "World"); +/// assert_eq!(subst.redact("Hello World!"), "Hello [LOCATION]!"); +/// ``` #[derive(Default, Clone, Debug, PartialEq, Eq)] pub struct Redactions { - vars: std::collections::BTreeMap>, - unused: std::collections::BTreeSet, + vars: Option< + std::collections::BTreeMap>, + >, + unused: Option>, } impl Redactions { - pub fn new() -> Self { - Default::default() + pub const fn new() -> Self { + Self { + vars: None, + unused: None, + } } pub(crate) fn with_exe() -> Self { @@ -43,6 +57,9 @@ impl Redactions { /// # #[cfg(feature = "regex")] { /// let mut subst = snapbox::Redactions::new(); /// subst.insert("[OBJECT]", regex::Regex::new("(?(world|moon))").unwrap()); + /// assert_eq!(subst.redact("Hello world!"), "Hello [OBJECT]!"); + /// assert_eq!(subst.redact("Hello moon!"), "Hello [OBJECT]!"); + /// assert_eq!(subst.redact("Hello other!"), "Hello other!"); /// # } /// ``` pub fn insert( @@ -53,9 +70,15 @@ impl Redactions { let placeholder = validate_placeholder(placeholder)?; let value = value.into(); if let Some(value) = value.inner { - self.vars.entry(value).or_default().insert(placeholder); + self.vars + .get_or_insert(std::collections::BTreeMap::new()) + .entry(value) + .or_default() + .insert(placeholder); } else { - self.unused.insert(RedactedValueInner::Str(placeholder)); + self.unused + .get_or_insert(std::collections::BTreeSet::new()) + .insert(RedactedValueInner::Str(placeholder)); } Ok(()) } @@ -75,46 +98,48 @@ impl Redactions { pub fn remove(&mut self, placeholder: &'static str) -> crate::assert::Result<()> { let placeholder = validate_placeholder(placeholder)?; - self.vars.retain(|_value, placeholders| { - placeholders.retain(|p| *p != placeholder); - !placeholders.is_empty() - }); + self.vars + .get_or_insert(std::collections::BTreeMap::new()) + .retain(|_value, placeholders| { + placeholders.retain(|p| *p != placeholder); + !placeholders.is_empty() + }); Ok(()) } - /// Apply match pattern to `input` - /// - /// If `pattern` matches `input`, then `pattern` is returned. + /// Apply redaction only, no pattern-dependent globs /// - /// Otherwise, `input`, with as many patterns replaced as possible, will be returned. + /// # Examples /// /// ```rust - /// let subst = snapbox::Redactions::new(); - /// let output = subst.normalize("Hello World!", "Hello [..]!"); - /// assert_eq!(output, "Hello [..]!"); + /// let mut subst = snapbox::Redactions::new(); + /// subst.insert("[LOCATION]", "World"); + /// let output = subst.redact("Hello World!"); + /// assert_eq!(output, "Hello [LOCATION]!"); /// ``` - pub fn normalize(&self, input: &str, pattern: &str) -> String { - normalize(input, pattern, self) - } - - /// Apply redaction only, no pattern-dependent globs pub fn redact(&self, input: &str) -> String { let mut input = input.to_owned(); replace_many( &mut input, - self.vars.iter().flat_map(|(value, placeholders)| { - placeholders - .iter() - .map(move |placeholder| (value, *placeholder)) - }), + self.vars + .iter() + .flatten() + .flat_map(|(value, placeholders)| { + placeholders + .iter() + .map(move |placeholder| (value, *placeholder)) + }), ); input } - fn clear<'v>(&self, pattern: &'v str) -> Cow<'v, str> { - if !self.unused.is_empty() && pattern.contains('[') { + pub(crate) fn clear<'v>(&self, pattern: &'v str) -> Cow<'v, str> { + if !self.unused.as_ref().map(|s| s.is_empty()).unwrap_or(false) && pattern.contains('[') { let mut pattern = pattern.to_owned(); - replace_many(&mut pattern, self.unused.iter().map(|var| (var, ""))); + replace_many( + &mut pattern, + self.unused.iter().flatten().map(|var| (var, "")), + ); Cow::Owned(pattern) } else { Cow::Borrowed(pattern) @@ -315,254 +340,10 @@ fn validate_placeholder(placeholder: &'static str) -> crate::assert::Result<&'st Ok(placeholder) } -fn normalize(input: &str, pattern: &str, redactions: &Redactions) -> String { - if input == pattern { - return input.to_owned(); - } - - let input = redactions.redact(input); - - let mut normalized: Vec<&str> = Vec::new(); - let mut input_index = 0; - let input_lines: Vec<_> = crate::utils::LinesWithTerminator::new(&input).collect(); - let mut pattern_lines = crate::utils::LinesWithTerminator::new(pattern).peekable(); - 'outer: while let Some(pattern_line) = pattern_lines.next() { - if is_line_elide(pattern_line) { - if let Some(next_pattern_line) = pattern_lines.peek() { - for (index_offset, next_input_line) in - input_lines[input_index..].iter().copied().enumerate() - { - if line_matches(next_input_line, next_pattern_line, redactions) { - normalized.push(pattern_line); - input_index += index_offset; - continue 'outer; - } - } - // Give up doing further normalization - break; - } else { - // Give up doing further normalization - normalized.push(pattern_line); - // captured rest so don't copy remaining lines over - input_index = input_lines.len(); - break; - } - } else { - let Some(input_line) = input_lines.get(input_index) else { - // Give up doing further normalization - break; - }; - - if line_matches(input_line, pattern_line, redactions) { - input_index += 1; - normalized.push(pattern_line); - } else { - // Give up doing further normalization - break; - } - } - } - - normalized.extend(input_lines[input_index..].iter().copied()); - normalized.join("") -} - -fn is_line_elide(line: &str) -> bool { - line == "...\n" || line == "..." -} - -fn line_matches(mut input: &str, pattern: &str, redactions: &Redactions) -> bool { - if input == pattern { - return true; - } - - let pattern = redactions.clear(pattern); - let mut sections = pattern.split("[..]").peekable(); - while let Some(section) = sections.next() { - if let Some(remainder) = input.strip_prefix(section) { - if let Some(next_section) = sections.peek() { - if next_section.is_empty() { - input = ""; - } else if let Some(restart_index) = remainder.find(next_section) { - input = &remainder[restart_index..]; - } - } else { - return remainder.is_empty(); - } - } else { - return false; - } - } - - false -} - #[cfg(test)] mod test { use super::*; - #[test] - fn empty() { - let input = ""; - let pattern = ""; - let expected = ""; - let actual = normalize(input, pattern, &Redactions::new()); - assert_eq!(expected, actual); - } - - #[test] - fn literals_match() { - let input = "Hello\nWorld"; - let pattern = "Hello\nWorld"; - let expected = "Hello\nWorld"; - let actual = normalize(input, pattern, &Redactions::new()); - assert_eq!(expected, actual); - } - - #[test] - fn pattern_shorter() { - let input = "Hello\nWorld"; - let pattern = "Hello\n"; - let expected = "Hello\nWorld"; - let actual = normalize(input, pattern, &Redactions::new()); - assert_eq!(expected, actual); - } - - #[test] - fn input_shorter() { - let input = "Hello\n"; - let pattern = "Hello\nWorld"; - let expected = "Hello\n"; - let actual = normalize(input, pattern, &Redactions::new()); - assert_eq!(expected, actual); - } - - #[test] - fn all_different() { - let input = "Hello\nWorld"; - let pattern = "Goodbye\nMoon"; - let expected = "Hello\nWorld"; - let actual = normalize(input, pattern, &Redactions::new()); - assert_eq!(expected, actual); - } - - #[test] - fn middles_diverge() { - let input = "Hello\nWorld\nGoodbye"; - let pattern = "Hello\nMoon\nGoodbye"; - let expected = "Hello\nWorld\nGoodbye"; - let actual = normalize(input, pattern, &Redactions::new()); - assert_eq!(expected, actual); - } - - #[test] - fn elide_delimited_with_sub() { - let input = "Hello World\nHow are you?\nGoodbye World"; - let pattern = "Hello [..]\n...\nGoodbye [..]"; - let expected = "Hello [..]\n...\nGoodbye [..]"; - let actual = normalize(input, pattern, &Redactions::new()); - assert_eq!(expected, actual); - } - - #[test] - fn leading_elide() { - let input = "Hello\nWorld\nGoodbye"; - let pattern = "...\nGoodbye"; - let expected = "...\nGoodbye"; - let actual = normalize(input, pattern, &Redactions::new()); - assert_eq!(expected, actual); - } - - #[test] - fn trailing_elide() { - let input = "Hello\nWorld\nGoodbye"; - let pattern = "Hello\n..."; - let expected = "Hello\n..."; - let actual = normalize(input, pattern, &Redactions::new()); - assert_eq!(expected, actual); - } - - #[test] - fn middle_elide() { - let input = "Hello\nWorld\nGoodbye"; - let pattern = "Hello\n...\nGoodbye"; - let expected = "Hello\n...\nGoodbye"; - let actual = normalize(input, pattern, &Redactions::new()); - assert_eq!(expected, actual); - } - - #[test] - fn post_elide_diverge() { - let input = "Hello\nSun\nAnd\nWorld"; - let pattern = "Hello\n...\nMoon"; - let expected = "Hello\nSun\nAnd\nWorld"; - let actual = normalize(input, pattern, &Redactions::new()); - assert_eq!(expected, actual); - } - - #[test] - fn post_diverge_elide() { - let input = "Hello\nWorld\nGoodbye\nSir"; - let pattern = "Hello\nMoon\nGoodbye\n..."; - let expected = "Hello\nWorld\nGoodbye\nSir"; - let actual = normalize(input, pattern, &Redactions::new()); - assert_eq!(expected, actual); - } - - #[test] - fn inline_elide() { - let input = "Hello\nWorld\nGoodbye\nSir"; - let pattern = "Hello\nW[..]d\nGoodbye\nSir"; - let expected = "Hello\nW[..]d\nGoodbye\nSir"; - let actual = normalize(input, pattern, &Redactions::new()); - assert_eq!(expected, actual); - } - - #[test] - fn line_matches_cases() { - let cases = [ - ("", "", true), - ("", "[..]", true), - ("hello", "hello", true), - ("hello", "goodbye", false), - ("hello", "[..]", true), - ("hello", "he[..]", true), - ("hello", "go[..]", false), - ("hello", "[..]o", true), - ("hello", "[..]e", false), - ("hello", "he[..]o", true), - ("hello", "he[..]e", false), - ("hello", "go[..]o", false), - ("hello", "go[..]e", false), - ( - "hello world, goodbye moon", - "hello [..], goodbye [..]", - true, - ), - ( - "hello world, goodbye moon", - "goodbye [..], goodbye [..]", - false, - ), - ( - "hello world, goodbye moon", - "goodbye [..], hello [..]", - false, - ), - ("hello world, goodbye moon", "hello [..], [..] moon", true), - ( - "hello world, goodbye moon", - "goodbye [..], [..] moon", - false, - ), - ("hello world, goodbye moon", "hello [..], [..] world", false), - ]; - for (line, pattern, expected) in cases { - let actual = line_matches(line, pattern, &Redactions::new()); - assert_eq!(expected, actual, "line={:?} pattern={:?}", line, pattern); - } - } - #[test] fn test_validate_placeholder() { let cases = [ @@ -577,84 +358,4 @@ mod test { assert_eq!(expected, actual, "placeholder={:?}", placeholder); } } - - #[test] - fn substitute_literal() { - let input = "Hello world!"; - let pattern = "Hello [OBJECT]!"; - let mut sub = Redactions::new(); - sub.insert("[OBJECT]", "world").unwrap(); - let actual = normalize(input, pattern, &sub); - assert_eq!(actual, pattern); - } - - #[test] - fn substitute_path() { - let input = "input: /home/epage"; - let pattern = "input: [HOME]"; - let mut sub = Redactions::new(); - let sep = std::path::MAIN_SEPARATOR.to_string(); - let redacted = PathBuf::from(sep).join("home").join("epage"); - sub.insert("[HOME]", redacted).unwrap(); - let actual = normalize(input, pattern, &sub); - assert_eq!(actual, pattern); - } - - #[test] - fn substitute_overlapping_path() { - let input = "\ -a: /home/epage -b: /home/epage/snapbox"; - let pattern = "\ -a: [A] -b: [B]"; - let mut sub = Redactions::new(); - let sep = std::path::MAIN_SEPARATOR.to_string(); - let redacted = PathBuf::from(&sep).join("home").join("epage"); - sub.insert("[A]", redacted).unwrap(); - let redacted = PathBuf::from(sep) - .join("home") - .join("epage") - .join("snapbox"); - sub.insert("[B]", redacted).unwrap(); - let actual = normalize(input, pattern, &sub); - assert_eq!(actual, pattern); - } - - #[test] - fn substitute_disabled() { - let input = "cargo"; - let pattern = "cargo[EXE]"; - let mut sub = Redactions::new(); - sub.insert("[EXE]", "").unwrap(); - let actual = normalize(input, pattern, &sub); - assert_eq!(actual, pattern); - } - - #[test] - #[cfg(feature = "regex")] - fn substitute_regex_unnamed() { - let input = "Hello world!"; - let pattern = "Hello [OBJECT]!"; - let mut sub = Redactions::new(); - sub.insert("[OBJECT]", regex::Regex::new("world").unwrap()) - .unwrap(); - let actual = normalize(input, pattern, &sub); - assert_eq!(actual, pattern); - } - - #[test] - #[cfg(feature = "regex")] - fn substitute_regex_named() { - let input = "Hello world!"; - let pattern = "Hello [OBJECT]!"; - let mut sub = Redactions::new(); - sub.insert( - "[OBJECT]", - regex::Regex::new("(?world)!").unwrap(), - ) - .unwrap(); - let actual = normalize(input, pattern, &sub); - assert_eq!(actual, pattern); - } } diff --git a/crates/snapbox/src/filter/test.rs b/crates/snapbox/src/filter/test.rs index ed83bbe5..46a287b3 100644 --- a/crates/snapbox/src/filter/test.rs +++ b/crates/snapbox/src/filter/test.rs @@ -7,7 +7,7 @@ use super::*; // Tests for normalization on json #[test] #[cfg(feature = "json")] -fn json_normalize_paths_and_lines() { +fn json_normalize_paths_and_lines_string() { let json = json!({"name": "John\\Doe\r\n"}); let data = Data::json(json); let data = FilterPaths.filter(data); @@ -18,7 +18,7 @@ fn json_normalize_paths_and_lines() { #[test] #[cfg(feature = "json")] -fn json_normalize_obj_value_paths_and_lines() { +fn json_normalize_paths_and_lines_nested_string() { let json = json!({ "person": { "name": "John\\Doe\r\n", @@ -46,7 +46,7 @@ fn json_normalize_obj_value_paths_and_lines() { #[test] #[cfg(feature = "json")] -fn json_normalize_obj_key_paths_and_lines() { +fn json_normalize_paths_and_lines_obj_key() { let json = json!({ "person": { "John\\Doe\r\n": "name", @@ -74,7 +74,7 @@ fn json_normalize_obj_key_paths_and_lines() { #[test] #[cfg(feature = "json")] -fn json_normalize_array_paths_and_lines() { +fn json_normalize_paths_and_lines_array() { let json = json!({"people": ["John\\Doe\r\n", "Jo\\hn\r\n"]}); let data = Data::json(json); let data = FilterPaths.filter(data); @@ -87,7 +87,7 @@ fn json_normalize_array_paths_and_lines() { #[test] #[cfg(feature = "json")] -fn json_normalize_array_obj_paths_and_lines() { +fn json_normalize_paths_and_lines_array_obj() { let json = json!({ "people": [ { @@ -118,338 +118,3 @@ fn json_normalize_array_obj_paths_and_lines() { }); 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_obj_redact_keys() { - let expected = json!({ - "[A]": "value-a", - "[B]": "value-b", - "[C]": "value-c", - }); - let expected = Data::json(expected); - let actual = json!({ - "key-a": "value-a", - "key-b": "value-b", - "key-c": "value-c", - }); - let actual = Data::json(actual); - let mut sub = Redactions::new(); - sub.insert("[A]", "key-a").unwrap(); - sub.insert("[B]", "key-b").unwrap(); - sub.insert("[C]", "key-c").unwrap(); - let actual = FilterRedactions::new(&sub, &expected).filter(actual); - - let expected_actual = json!({ - "[A]": "value-a", - "[B]": "value-b", - "[C]": "value-c", - }); - let expected_actual = Data::json(expected_actual); - assert_eq!(actual, expected_actual); -} - -#[test] -#[cfg(feature = "json")] -fn json_obj_redact_with_disparate_keys() { - let expected = json!({ - "a": "[A]", - "b": "[B]", - "c": "[C]", - }); - let expected = Data::json(expected); - let actual = json!({ - "a": "value-a", - "c": "value-c", - }); - let actual = Data::json(actual); - let mut sub = Redactions::new(); - sub.insert("[A]", "value-a").unwrap(); - sub.insert("[B]", "value-b").unwrap(); - sub.insert("[C]", "value-c").unwrap(); - let actual = FilterRedactions::new(&sub, &expected).filter(actual); - - let expected_actual = json!({ - "a": "[A]", - "c": "[C]", - }); - let expected_actual = Data::json(expected_actual); - assert_eq!(actual, expected_actual); -} - -#[test] -#[cfg(feature = "json")] -fn json_normalize_wildcard_key() { - let expected = json!({ - "a": "value-a", - "c": "value-c", - "...": "{...}", - }); - let expected = Data::json(expected); - let actual = json!({ - "a": "value-a", - "b": "value-b", - "c": "value-c", - }); - let actual = Data::json(actual); - let actual = FilterRedactions::new(&Default::default(), &expected).filter(actual); - - let expected_actual = json!({ - "a": "value-a", - "c": "value-c", - "...": "{...}", - }); - let expected_actual = Data::json(expected_actual); - assert_eq!(actual, expected_actual); -} - -#[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/filter/test_redactions.rs b/crates/snapbox/src/filter/test_redactions.rs new file mode 100644 index 00000000..2f75beca --- /dev/null +++ b/crates/snapbox/src/filter/test_redactions.rs @@ -0,0 +1,567 @@ +use std::path::PathBuf; + +#[cfg(feature = "json")] +use serde_json::json; + +use super::*; +use crate::prelude::*; + +#[test] +fn str_normalize_empty() { + let input = ""; + let pattern = ""; + let expected = ""; + let actual = NormalizeToExpected::new() + .redact() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_literals_match() { + let input = "Hello\nWorld"; + let pattern = "Hello\nWorld"; + let expected = "Hello\nWorld"; + let actual = NormalizeToExpected::new() + .redact() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_pattern_shorter() { + let input = "Hello\nWorld"; + let pattern = "Hello\n"; + let expected = "Hello\nWorld"; + let actual = NormalizeToExpected::new() + .redact() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_input_shorter() { + let input = "Hello\n"; + let pattern = "Hello\nWorld"; + let expected = "Hello\n"; + let actual = NormalizeToExpected::new() + .redact() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_all_different() { + let input = "Hello\nWorld"; + let pattern = "Goodbye\nMoon"; + let expected = "Hello\nWorld"; + let actual = NormalizeToExpected::new() + .redact() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_middles_diverge() { + let input = "Hello\nWorld\nGoodbye"; + let pattern = "Hello\nMoon\nGoodbye"; + let expected = "Hello\nWorld\nGoodbye"; + let actual = NormalizeToExpected::new() + .redact() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_elide_delimited_with_sub() { + let input = "Hello World\nHow are you?\nGoodbye World"; + let pattern = "Hello [..]\n...\nGoodbye [..]"; + let expected = "Hello [..]\n...\nGoodbye [..]"; + let actual = NormalizeToExpected::new() + .redact() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_leading_elide() { + let input = "Hello\nWorld\nGoodbye"; + let pattern = "...\nGoodbye"; + let expected = "...\nGoodbye"; + let actual = NormalizeToExpected::new() + .redact() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_trailing_elide() { + let input = "Hello\nWorld\nGoodbye"; + let pattern = "Hello\n..."; + let expected = "Hello\n..."; + let actual = NormalizeToExpected::new() + .redact() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_middle_elide() { + let input = "Hello\nWorld\nGoodbye"; + let pattern = "Hello\n...\nGoodbye"; + let expected = "Hello\n...\nGoodbye"; + let actual = NormalizeToExpected::new() + .redact() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_post_elide_diverge() { + let input = "Hello\nSun\nAnd\nWorld"; + let pattern = "Hello\n...\nMoon"; + let expected = "Hello\nSun\nAnd\nWorld"; + let actual = NormalizeToExpected::new() + .redact() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_post_diverge_elide() { + let input = "Hello\nWorld\nGoodbye\nSir"; + let pattern = "Hello\nMoon\nGoodbye\n..."; + let expected = "Hello\nWorld\nGoodbye\nSir"; + let actual = NormalizeToExpected::new() + .redact() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_inline_elide() { + let input = "Hello\nWorld\nGoodbye\nSir"; + let pattern = "Hello\nW[..]d\nGoodbye\nSir"; + let expected = "Hello\nW[..]d\nGoodbye\nSir"; + let actual = NormalizeToExpected::new() + .redact() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_user_literal() { + let input = "Hello world!"; + let pattern = "Hello [OBJECT]!"; + let mut sub = Redactions::new(); + sub.insert("[OBJECT]", "world").unwrap(); + let actual = NormalizeToExpected::new() + .redact_with(&sub) + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, pattern.into_data()); +} + +#[test] +fn str_normalize_user_path() { + let input = "input: /home/epage"; + let pattern = "input: [HOME]"; + let mut sub = Redactions::new(); + let sep = std::path::MAIN_SEPARATOR.to_string(); + let redacted = PathBuf::from(sep).join("home").join("epage"); + sub.insert("[HOME]", redacted).unwrap(); + let actual = NormalizeToExpected::new() + .redact_with(&sub) + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, pattern.into_data()); +} + +#[test] +fn str_normalize_user_overlapping_path() { + let input = "\ +a: /home/epage +b: /home/epage/snapbox"; + let pattern = "\ +a: [A] +b: [B]"; + let mut sub = Redactions::new(); + let sep = std::path::MAIN_SEPARATOR.to_string(); + let redacted = PathBuf::from(&sep).join("home").join("epage"); + sub.insert("[A]", redacted).unwrap(); + let redacted = PathBuf::from(sep) + .join("home") + .join("epage") + .join("snapbox"); + sub.insert("[B]", redacted).unwrap(); + let actual = NormalizeToExpected::new() + .redact_with(&sub) + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, pattern.into_data()); +} + +#[test] +fn str_normalize_user_disabled() { + let input = "cargo"; + let pattern = "cargo[EXE]"; + let mut sub = Redactions::new(); + sub.insert("[EXE]", "").unwrap(); + let actual = NormalizeToExpected::new() + .redact_with(&sub) + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, pattern.into_data()); +} + +#[test] +#[cfg(feature = "regex")] +fn str_normalize_user_regex_unnamed() { + let input = "Hello world!"; + let pattern = "Hello [OBJECT]!"; + let mut sub = Redactions::new(); + sub.insert("[OBJECT]", regex::Regex::new("world").unwrap()) + .unwrap(); + let actual = NormalizeToExpected::new() + .redact_with(&sub) + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, pattern.into_data()); +} + +#[test] +#[cfg(feature = "regex")] +fn str_normalize_user_regex_named() { + let input = "Hello world!"; + let pattern = "Hello [OBJECT]!"; + let mut sub = Redactions::new(); + sub.insert( + "[OBJECT]", + regex::Regex::new("(?world)!").unwrap(), + ) + .unwrap(); + let actual = NormalizeToExpected::new() + .redact_with(&sub) + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, pattern.into_data()); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_glob_for_string() { + let exp = json!({"name": "{...}"}); + let expected = Data::json(exp); + let actual = json!({"name": "JohnDoe"}); + let actual = NormalizeToExpected::new() + .redact() + .normalize(Data::json(actual), &expected); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_glob_for_array() { + let exp = json!({"people": "{...}"}); + let expected = Data::json(exp); + let actual = json!({ + "people": [ + { + "name": "JohnDoe", + "nickname": "John", + } + ] + }); + let actual = NormalizeToExpected::new() + .redact() + .normalize(Data::json(actual), &expected); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_glob_for_obj() { + let exp = json!({"people": "{...}"}); + let expected = Data::json(exp); + let actual = json!({ + "people": { + "name": "JohnDoe", + "nickname": "John", + } + }); + let actual = NormalizeToExpected::new() + .redact() + .normalize(Data::json(actual), &expected); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_glob_array_start() { + let exp = json!({ + "people": [ + "{...}", + { + "name": "three", + "nickname": "3", + } + ] + }); + let expected = Data::json(exp); + let actual = json!({ + "people": [ + { + "name": "one", + "nickname": "1", + }, + { + "name": "two", + "nickname": "2", + }, + { + "name": "three", + "nickname": "3", + } + ] + }); + let actual = NormalizeToExpected::new() + .redact() + .normalize(Data::json(actual), &expected); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_glob_for_array_start_end() { + let exp = json!([ + "{...}", + { + "name": "two", + "nickname": "2", + }, + "{...}" + ]); + let expected = Data::json(exp); + let actual = json!([ + { + "name": "one", + "nickname": "1", + }, + { + "name": "two", + "nickname": "2", + }, + { + "name": "three", + "nickname": "3", + }, + { + "name": "four", + "nickname": "4", + } + ]); + let actual = NormalizeToExpected::new() + .redact() + .normalize(Data::json(actual), &expected); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_glob_for_array_middle_end() { + let exp = json!([ + { + "name": "one", + "nickname": "1", + }, + "{...}", + { + "name": "three", + "nickname": "3", + }, + "{...}" + ]); + let expected = Data::json(exp); + let actual = json!([ + { + "name": "one", + "nickname": "1", + }, + { + "name": "two", + "nickname": "2", + }, + { + "name": "three", + "nickname": "3", + }, + { + "name": "four", + "nickname": "4", + }, + { + "name": "five", + "nickname": "5", + } + ]); + let actual = NormalizeToExpected::new() + .redact() + .normalize(Data::json(actual), &expected); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_glob_for_array_mismatch() { + let exp = json!([ + { + "name": "one", + "nickname": "1", + }, + "{...}", + { + "name": "three", + "nickname": "3", + }, + "{...}" + ]); + let expected = Data::json(exp); + let actual = json!([ + { + "name": "one", + "nickname": "1", + }, + { + "name": "two", + "nickname": "2", + }, + { + "name": "four", + "nickname": "4", + }, + { + "name": "five", + "nickname": "5", + } + ]); + let actual_normalized = NormalizeToExpected::new() + .redact() + .normalize(Data::json(actual.clone()), &expected); + if let DataInner::Json(act) = actual_normalized.inner { + assert_eq!(act, actual); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_bad_order() { + let exp = json!({ + "people": ["John", "Jane"] + }); + let expected = Data::json(exp); + let actual = json!({ + "people": ["Jane", "John"] + }); + let actual = NormalizeToExpected::new() + .redact() + .normalize(Data::json(actual), &expected); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_ne!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_obj_key() { + let expected = json!({ + "[A]": "value-a", + "[B]": "value-b", + "[C]": "value-c", + }); + let expected = Data::json(expected); + let actual = json!({ + "key-a": "value-a", + "key-b": "value-b", + "key-c": "value-c", + }); + let actual = Data::json(actual); + let mut sub = Redactions::new(); + sub.insert("[A]", "key-a").unwrap(); + sub.insert("[B]", "key-b").unwrap(); + sub.insert("[C]", "key-c").unwrap(); + let actual = NormalizeToExpected::new() + .redact_with(&sub) + .normalize(actual, &expected); + + let expected_actual = json!({ + "[A]": "value-a", + "[B]": "value-b", + "[C]": "value-c", + }); + let expected_actual = Data::json(expected_actual); + assert_eq!(actual, expected_actual); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_with_missing_obj_key() { + let expected = json!({ + "a": "[A]", + "b": "[B]", + "c": "[C]", + }); + let expected = Data::json(expected); + let actual = json!({ + "a": "value-a", + "c": "value-c", + }); + let actual = Data::json(actual); + let mut sub = Redactions::new(); + sub.insert("[A]", "value-a").unwrap(); + sub.insert("[B]", "value-b").unwrap(); + sub.insert("[C]", "value-c").unwrap(); + let actual = NormalizeToExpected::new() + .redact_with(&sub) + .normalize(actual, &expected); + + let expected_actual = json!({ + "a": "[A]", + "c": "[C]", + }); + let expected_actual = Data::json(expected_actual); + assert_eq!(actual, expected_actual); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_glob_obj_key() { + let expected = json!({ + "a": "value-a", + "c": "value-c", + "...": "{...}", + }); + let expected = Data::json(expected); + let actual = json!({ + "a": "value-a", + "b": "value-b", + "c": "value-c", + }); + let actual = Data::json(actual); + let actual = NormalizeToExpected::new() + .redact() + .normalize(actual, &expected); + + let expected_actual = json!({ + "a": "value-a", + "c": "value-c", + "...": "{...}", + }); + let expected_actual = Data::json(expected_actual); + assert_eq!(actual, expected_actual); +} diff --git a/crates/snapbox/src/filter/test_unordered.rs b/crates/snapbox/src/filter/test_unordered.rs new file mode 100644 index 00000000..6ecf2894 --- /dev/null +++ b/crates/snapbox/src/filter/test_unordered.rs @@ -0,0 +1,222 @@ +use std::path::PathBuf; + +#[cfg(feature = "json")] +use serde_json::json; + +use super::*; +use crate::prelude::*; + +#[test] +fn str_normalize_empty() { + let input = ""; + let pattern = ""; + let expected = ""; + let actual = NormalizeToExpected::new() + .unordered() + .normalize(input.into_data(), &pattern.into_data()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_same_order() { + let input = "1 +2 +3 +"; + let pattern = "1 +2 +3 +"; + let expected = "1 +2 +3 +"; + let actual = NormalizeToExpected::new() + .unordered() + .normalize(input.into_data(), &pattern.into_data()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_reverse_order() { + let input = "1 +2 +3 +"; + let pattern = "3 +2 +1 +"; + let expected = "3 +2 +1 +"; + let actual = NormalizeToExpected::new() + .unordered() + .normalize(input.into_data(), &pattern.into_data()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_actual_missing() { + let input = "1 +3 +"; + let pattern = "1 +2 +3 +"; + let expected = "1 +3 +"; + let actual = NormalizeToExpected::new() + .unordered() + .normalize(input.into_data(), &pattern.into_data()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_expected_missing() { + let input = "1 +2 +3 +"; + let pattern = "1 +3 +"; + let expected = "1 +3 +2 +"; + let actual = NormalizeToExpected::new() + .unordered() + .normalize(input.into_data(), &pattern.into_data()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_actual_duplicated() { + let input = "1 +2 +2 +3 +"; + let pattern = "1 +2 +3 +"; + let expected = "1 +2 +3 +2 +"; + let actual = NormalizeToExpected::new() + .unordered() + .normalize(input.into_data(), &pattern.into_data()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_expected_duplicated() { + let input = "1 +2 +3 +"; + let pattern = "1 +2 +2 +3 +"; + let expected = "1 +2 +3 +"; + let actual = NormalizeToExpected::new() + .unordered() + .normalize(input.into_data(), &pattern.into_data()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_empty() { + let input = json!([]); + let pattern = json!([]); + let expected = json!([]); + let actual = NormalizeToExpected::new() + .unordered() + .normalize(Data::json(input), &Data::json(pattern)); + assert_eq!(actual, Data::json(expected)); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_same_order() { + let input = json!([1, 2, 3]); + let pattern = json!([1, 2, 3]); + let expected = json!([1, 2, 3]); + let actual = NormalizeToExpected::new() + .unordered() + .normalize(Data::json(input), &Data::json(pattern)); + assert_eq!(actual, Data::json(expected)); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_reverse_order() { + let input = json!([1, 2, 3]); + let pattern = json!([3, 2, 1]); + let expected = json!([3, 2, 1]); + let actual = NormalizeToExpected::new() + .unordered() + .normalize(Data::json(input), &Data::json(pattern)); + assert_eq!(actual, Data::json(expected)); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_actual_missing() { + let input = json!([1, 3]); + let pattern = json!([1, 2, 3]); + let expected = json!([1, 3]); + let actual = NormalizeToExpected::new() + .unordered() + .normalize(Data::json(input), &Data::json(pattern)); + assert_eq!(actual, Data::json(expected)); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_expected_missing() { + let input = json!([1, 2, 3]); + let pattern = json!([1, 3]); + let expected = json!([1, 3, 2]); + let actual = NormalizeToExpected::new() + .unordered() + .normalize(Data::json(input), &Data::json(pattern)); + assert_eq!(actual, Data::json(expected)); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_actual_duplicated() { + let input = json!([1, 2, 2, 3]); + let pattern = json!([1, 2, 3]); + let expected = json!([1, 2, 3, 2]); + let actual = NormalizeToExpected::new() + .unordered() + .normalize(Data::json(input), &Data::json(pattern)); + assert_eq!(actual, Data::json(expected)); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_expected_duplicated() { + let input = json!([1, 2, 3]); + let pattern = json!([1, 2, 2, 3]); + let expected = json!([1, 2, 3]); + let actual = NormalizeToExpected::new() + .unordered() + .normalize(Data::json(input), &Data::json(pattern)); + assert_eq!(actual, Data::json(expected)); +} diff --git a/crates/snapbox/src/filter/test_unordered_redactions.rs b/crates/snapbox/src/filter/test_unordered_redactions.rs new file mode 100644 index 00000000..82b8a451 --- /dev/null +++ b/crates/snapbox/src/filter/test_unordered_redactions.rs @@ -0,0 +1,622 @@ +use std::path::PathBuf; + +#[cfg(feature = "json")] +use serde_json::json; + +use super::*; +use crate::prelude::*; + +#[test] +fn str_normalize_empty() { + let input = ""; + let pattern = ""; + let expected = ""; + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(input.into_data(), &pattern.into_data()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_same_order() { + let input = "1 +2 +3 +"; + let pattern = "1 +2 +3 +"; + let expected = "1 +2 +3 +"; + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(input.into_data(), &pattern.into_data()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_reverse_order() { + let input = "1 +2 +3 +"; + let pattern = "3 +2 +1 +"; + let expected = "3 +2 +1 +"; + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(input.into_data(), &pattern.into_data()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_actual_missing() { + let input = "1 +3 +"; + let pattern = "1 +2 +3 +"; + let expected = "1 +3 +"; + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(input.into_data(), &pattern.into_data()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_expected_missing() { + let input = "1 +2 +3 +"; + let pattern = "1 +3 +"; + let expected = "1 +3 +2 +"; + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(input.into_data(), &pattern.into_data()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_actual_duplicated() { + let input = "1 +2 +2 +3 +"; + let pattern = "1 +2 +3 +"; + let expected = "1 +2 +3 +2 +"; + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(input.into_data(), &pattern.into_data()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_expected_duplicated() { + let input = "1 +2 +3 +"; + let pattern = "1 +2 +2 +3 +"; + let expected = "1 +2 +3 +"; + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(input.into_data(), &pattern.into_data()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_elide_delimited_with_sub() { + let input = "Hello World\nHow are you?\nGoodbye World"; + let pattern = "Hello [..]\n...\nGoodbye [..]"; + let expected = "Hello [..]\n...\nGoodbye [..]"; + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_leading_elide() { + let input = "Hello\nWorld\nGoodbye"; + let pattern = "...\nGoodbye"; + let expected = "...\nGoodbye"; + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_trailing_elide() { + let input = "Hello\nWorld\nGoodbye"; + let pattern = "Hello\n..."; + let expected = "Hello\n..."; + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_middle_elide() { + let input = "Hello\nWorld\nGoodbye"; + let pattern = "Hello\n...\nGoodbye"; + let expected = "Hello\n...\nGoodbye"; + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_post_elide_diverge() { + let input = "Hello\nSun\nAnd\nWorld"; + let pattern = "Hello\n...\nMoon"; + let expected = "Hello\n...\n"; + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_post_diverge_elide() { + let input = "Hello\nWorld\nGoodbye\nSir"; + let pattern = "Hello\nMoon\nGoodbye\n..."; + let expected = "Hello\nGoodbye\n..."; + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_inline_elide() { + let input = "Hello\nWorld\nGoodbye\nSir"; + let pattern = "Hello\nW[..]d\nGoodbye\nSir"; + let expected = "Hello\nW[..]d\nGoodbye\nSir"; + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, expected.into_data()); +} + +#[test] +fn str_normalize_user_literal() { + let input = "Hello world!"; + let pattern = "Hello [OBJECT]!"; + let mut sub = Redactions::new(); + sub.insert("[OBJECT]", "world").unwrap(); + let actual = NormalizeToExpected::new() + .redact_with(&sub) + .unordered() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, pattern.into_data()); +} + +#[test] +fn str_normalize_user_path() { + let input = "input: /home/epage"; + let pattern = "input: [HOME]"; + let mut sub = Redactions::new(); + let sep = std::path::MAIN_SEPARATOR.to_string(); + let redacted = PathBuf::from(sep).join("home").join("epage"); + sub.insert("[HOME]", redacted).unwrap(); + let actual = NormalizeToExpected::new() + .redact_with(&sub) + .unordered() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, pattern.into_data()); +} + +#[test] +fn str_normalize_user_overlapping_path() { + let input = "\ +a: /home/epage +b: /home/epage/snapbox"; + let pattern = "\ +a: [A] +b: [B]"; + let mut sub = Redactions::new(); + let sep = std::path::MAIN_SEPARATOR.to_string(); + let redacted = PathBuf::from(&sep).join("home").join("epage"); + sub.insert("[A]", redacted).unwrap(); + let redacted = PathBuf::from(sep) + .join("home") + .join("epage") + .join("snapbox"); + sub.insert("[B]", redacted).unwrap(); + let actual = NormalizeToExpected::new() + .redact_with(&sub) + .unordered() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, pattern.into_data()); +} + +#[test] +fn str_normalize_user_disabled() { + let input = "cargo"; + let pattern = "cargo[EXE]"; + let mut sub = Redactions::new(); + sub.insert("[EXE]", "").unwrap(); + let actual = NormalizeToExpected::new() + .redact_with(&sub) + .unordered() + .normalize(input.into(), &pattern.into()); + assert_eq!(actual, pattern.into_data()); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_empty() { + let input = json!([]); + let pattern = json!([]); + let expected = json!([]); + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(Data::json(input), &Data::json(pattern)); + assert_eq!(actual, Data::json(expected)); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_same_order() { + let input = json!([1, 2, 3]); + let pattern = json!([1, 2, 3]); + let expected = json!([1, 2, 3]); + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(Data::json(input), &Data::json(pattern)); + assert_eq!(actual, Data::json(expected)); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_reverse_order() { + let input = json!([1, 2, 3]); + let pattern = json!([3, 2, 1]); + let expected = json!([3, 2, 1]); + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(Data::json(input), &Data::json(pattern)); + assert_eq!(actual, Data::json(expected)); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_actual_missing() { + let input = json!([1, 3]); + let pattern = json!([1, 2, 3]); + let expected = json!([1, 3]); + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(Data::json(input), &Data::json(pattern)); + assert_eq!(actual, Data::json(expected)); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_expected_missing() { + let input = json!([1, 2, 3]); + let pattern = json!([1, 3]); + let expected = json!([1, 3, 2]); + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(Data::json(input), &Data::json(pattern)); + assert_eq!(actual, Data::json(expected)); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_actual_duplicated() { + let input = json!([1, 2, 2, 3]); + let pattern = json!([1, 2, 3]); + let expected = json!([1, 2, 3, 2]); + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(Data::json(input), &Data::json(pattern)); + assert_eq!(actual, Data::json(expected)); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_expected_duplicated() { + let input = json!([1, 2, 3]); + let pattern = json!([1, 2, 2, 3]); + let expected = json!([1, 2, 3]); + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(Data::json(input), &Data::json(pattern)); + assert_eq!(actual, Data::json(expected)); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_glob_for_string() { + let exp = json!({"name": "{...}"}); + let expected = Data::json(exp); + let actual = json!({"name": "JohnDoe"}); + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(Data::json(actual), &expected); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_glob_for_array() { + let exp = json!({"people": "{...}"}); + let expected = Data::json(exp); + let actual = json!({ + "people": [ + { + "name": "JohnDoe", + "nickname": "John", + } + ] + }); + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(Data::json(actual), &expected); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_glob_for_obj() { + let exp = json!({"people": "{...}"}); + let expected = Data::json(exp); + let actual = json!({ + "people": { + "name": "JohnDoe", + "nickname": "John", + } + }); + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(Data::json(actual), &expected); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_glob_array_start() { + let exp = json!({ + "people": [ + "{...}", + { + "name": "three", + "nickname": "3", + } + ] + }); + let expected = Data::json(exp); + let actual = json!({ + "people": [ + { + "name": "one", + "nickname": "1", + }, + { + "name": "two", + "nickname": "2", + }, + { + "name": "three", + "nickname": "3", + } + ] + }); + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(Data::json(actual), &expected); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_glob_for_array_mismatch() { + let exp = json!([ + { + "name": "one", + "nickname": "1", + }, + { + "name": "three", + "nickname": "3", + }, + "{...}" + ]); + let expected = Data::json(exp); + let actual = json!([ + { + "name": "one", + "nickname": "1", + }, + { + "name": "two", + "nickname": "2", + }, + { + "name": "four", + "nickname": "4", + }, + { + "name": "five", + "nickname": "5", + } + ]); + let expected_actual = json!([ + { + "name": "one", + "nickname": "1", + }, + "{...}" + ]); + let actual_normalized = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(Data::json(actual.clone()), &expected); + if let DataInner::Json(act) = actual_normalized.inner { + assert_eq!(act, expected_actual); + } +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_obj_key() { + let expected = json!({ + "[A]": "value-a", + "[B]": "value-b", + "[C]": "value-c", + }); + let expected = Data::json(expected); + let actual = json!({ + "key-a": "value-a", + "key-b": "value-b", + "key-c": "value-c", + }); + let actual = Data::json(actual); + let mut sub = Redactions::new(); + sub.insert("[A]", "key-a").unwrap(); + sub.insert("[B]", "key-b").unwrap(); + sub.insert("[C]", "key-c").unwrap(); + let actual = NormalizeToExpected::new() + .redact_with(&sub) + .unordered() + .normalize(actual, &expected); + + let expected_actual = json!({ + "[A]": "value-a", + "[B]": "value-b", + "[C]": "value-c", + }); + let expected_actual = Data::json(expected_actual); + assert_eq!(actual, expected_actual); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_with_missing_obj_key() { + let expected = json!({ + "a": "[A]", + "b": "[B]", + "c": "[C]", + }); + let expected = Data::json(expected); + let actual = json!({ + "a": "value-a", + "c": "value-c", + }); + let actual = Data::json(actual); + let mut sub = Redactions::new(); + sub.insert("[A]", "value-a").unwrap(); + sub.insert("[B]", "value-b").unwrap(); + sub.insert("[C]", "value-c").unwrap(); + let actual = NormalizeToExpected::new() + .redact_with(&sub) + .unordered() + .normalize(actual, &expected); + + let expected_actual = json!({ + "a": "[A]", + "c": "[C]", + }); + let expected_actual = Data::json(expected_actual); + assert_eq!(actual, expected_actual); +} + +#[test] +#[cfg(feature = "json")] +fn json_normalize_glob_obj_key() { + let expected = json!({ + "a": "value-a", + "c": "value-c", + "...": "{...}", + }); + let expected = Data::json(expected); + let actual = json!({ + "a": "value-a", + "b": "value-b", + "c": "value-c", + }); + let actual = Data::json(actual); + let actual = NormalizeToExpected::new() + .redact() + .unordered() + .normalize(actual, &expected); + + let expected_actual = json!({ + "a": "value-a", + "c": "value-c", + "...": "{...}", + }); + let expected_actual = Data::json(expected_actual); + assert_eq!(actual, expected_actual); +} diff --git a/crates/trycmd/src/runner.rs b/crates/trycmd/src/runner.rs index de2e6264..9033676a 100644 --- a/crates/trycmd/src/runner.rs +++ b/crates/trycmd/src/runner.rs @@ -14,7 +14,7 @@ use std::io::stderr; use rayon::prelude::*; use snapbox::data::DataFormat; use snapbox::dir::FileType; -use snapbox::filter::{Filter as _, FilterNewlines, FilterPaths, FilterRedactions}; +use snapbox::filter::{Filter as _, FilterNewlines, FilterPaths, NormalizeToExpected}; use snapbox::IntoData; #[derive(Debug)] @@ -439,8 +439,9 @@ impl Case { } if let Some(expected_content) = expected_content { - stream.content = - FilterRedactions::new(substitutions, expected_content).filter(stream.content); + stream.content = NormalizeToExpected::new() + .redact_with(substitutions) + .normalize(stream.content, expected_content); if stream.content != *expected_content { stream.status = StreamStatus::Expected(expected_content.clone());