From 7a557392e7106e28ac738808977625cb459b293d Mon Sep 17 00:00:00 2001 From: Scott Schafer Date: Thu, 1 Sep 2022 19:24:17 -0500 Subject: [PATCH] feat(snapbox): Add initial JSON support --- Cargo.lock | 1 + crates/snapbox/Cargo.toml | 7 + crates/snapbox/src/data.rs | 472 +++++++++++++++++++++++++++++++++++-- 3 files changed, 465 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 20f92233..ef7ff0c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -713,6 +713,7 @@ dependencies = [ "libtest-mimic", "normalize-line-endings", "os_pipe", + "serde_json", "similar", "snapbox-macros", "tempfile", diff --git a/crates/snapbox/Cargo.toml b/crates/snapbox/Cargo.toml index 75ab4414..d72c9b75 100644 --- a/crates/snapbox/Cargo.toml +++ b/crates/snapbox/Cargo.toml @@ -49,6 +49,11 @@ path = ["dep:tempfile", "dep:walkdir", "dep:dunce", "detect-encoding", "dep:file ## Snapshotting of commands cmd = ["dep:os_pipe", "dep:wait-timeout"] +## Snapshotting of json +json = ["structured-data"] +## Snapshotting of structured data +structured-data = ["dep:serde_json"] + ## Extra debugging information debug = ["snapbox-macros/debug", "dep:backtrace"] @@ -89,3 +94,5 @@ yansi = { version = "0.5.0", optional = true } concolor = { version = "0.0.8", features = ["std"], optional = true } document-features = { version = "0.2.3", optional = true } + +serde_json = { version = "1.0.85", optional = true} diff --git a/crates/snapbox/src/data.rs b/crates/snapbox/src/data.rs index 5add77ef..aa5f9b1e 100644 --- a/crates/snapbox/src/data.rs +++ b/crates/snapbox/src/data.rs @@ -10,12 +10,16 @@ pub struct Data { enum DataInner { Binary(Vec), Text(String), + #[cfg(feature = "structured-data")] + Json(serde_json::Value), } #[derive(Clone, Debug, PartialEq, Eq, Copy, Hash)] pub enum DataFormat { Binary, Text, + #[cfg(feature = "json")] + Json, } impl Default for DataFormat { @@ -39,6 +43,13 @@ impl Data { } } + #[cfg(feature = "json")] + pub fn json(raw: impl Into) -> Self { + Self { + inner: DataInner::Json(raw.into()), + } + } + /// Empty test data pub fn new() -> Self { Self::text("") @@ -61,11 +72,26 @@ impl Data { .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; Self::text(data) } + #[cfg(feature = "json")] + DataFormat::Json => { + let data = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + Self::json(serde_json::from_str::(&data).unwrap()) + } }, None => { let data = std::fs::read(&path) .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; - Self::binary(data).try_coerce(DataFormat::Text) + let data = Self::binary(data); + match path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default() + { + #[cfg(feature = "json")] + "json" => data.try_coerce(DataFormat::Json), + _ => data.try_coerce(DataFormat::Text), + } } }; Ok(data) @@ -96,6 +122,8 @@ impl Data { match &self.inner { DataInner::Binary(_) => None, DataInner::Text(data) => Some(data.to_owned()), + #[cfg(feature = "json")] + DataInner::Json(value) => Some(serde_json::to_string_pretty(value).unwrap()), } } @@ -103,28 +131,57 @@ impl Data { match &self.inner { DataInner::Binary(data) => data.clone(), DataInner::Text(data) => data.clone().into_bytes(), + #[cfg(feature = "json")] + DataInner::Json(value) => serde_json::to_vec_pretty(value).unwrap(), } } pub fn try_coerce(self, format: DataFormat) -> Self { - match format { - DataFormat::Binary => Self::binary(self.to_bytes()), - DataFormat::Text => match self.inner { - DataInner::Binary(data) => { - if is_binary(&data) { - Self::binary(data) - } else { - match String::from_utf8(data) { - Ok(data) => Self::text(data), - Err(err) => { - let data = err.into_bytes(); - Self::binary(data) + match (self.inner, format) { + (DataInner::Binary(inner), DataFormat::Binary) => Self::binary(inner), + (DataInner::Text(inner), DataFormat::Text) => Self::text(inner), + #[cfg(feature = "json")] + (DataInner::Json(inner), DataFormat::Json) => Self::json(inner), + (DataInner::Binary(inner), _) => { + if is_binary(&inner) { + Self::binary(inner) + } else { + match String::from_utf8(inner) { + Ok(str) => { + let coerced = Self::text(str).try_coerce(format); + // if the Text cannot be coerced into the correct format + // reset it back to Binary + if coerced.format() != format { + coerced.try_coerce(DataFormat::Binary) + } else { + coerced } } + Err(err) => { + let bin = err.into_bytes(); + Self::binary(bin) + } } } - DataInner::Text(data) => Self::text(data), - }, + } + #[cfg(feature = "json")] + (DataInner::Text(inner), DataFormat::Json) => { + match serde_json::from_str::(&inner) { + Ok(json) => Self::json(json), + Err(_) => Self::text(inner), + } + } + (inner, DataFormat::Binary) => Self::binary(Self { inner }.to_bytes()), + // This variant is already covered unless structured data is enabled + #[cfg(feature = "structured-data")] + (inner, DataFormat::Text) => { + let remake = Self { inner }; + if let Some(str) = remake.render() { + Self::text(str) + } else { + remake + } + } } } @@ -133,6 +190,8 @@ impl Data { match &self.inner { DataInner::Binary(_) => DataFormat::Binary, DataInner::Text(_) => DataFormat::Text, + #[cfg(feature = "json")] + DataInner::Json(_) => DataFormat::Json, } } } @@ -142,6 +201,8 @@ impl std::fmt::Display for Data { match &self.inner { DataInner::Binary(data) => String::from_utf8_lossy(data).fmt(f), DataInner::Text(data) => data.fmt(f), + #[cfg(feature = "json")] + DataInner::Json(data) => serde_json::to_string_pretty(data).unwrap().fmt(f), } } } @@ -201,6 +262,12 @@ impl Normalize for NormalizeNewlines { let lines = crate::utils::normalize_lines(&text); Data::text(lines) } + #[cfg(feature = "json")] + DataInner::Json(value) => { + let mut value = value; + normalize_value(&mut value, crate::utils::normalize_lines); + Data::json(value) + } } } } @@ -214,6 +281,12 @@ impl Normalize for NormalizePaths { let lines = crate::utils::normalize_paths(&text); Data::text(lines) } + #[cfg(feature = "json")] + DataInner::Json(value) => { + let mut value = value; + normalize_value(&mut value, crate::utils::normalize_paths); + Data::json(value) + } } } } @@ -242,7 +315,62 @@ impl Normalize for NormalizeMatches<'_> { .normalize(&text, &self.pattern.render().unwrap()); Data::text(lines) } + #[cfg(feature = "json")] + DataInner::Json(value) => { + let mut value = value; + if let DataInner::Json(exp) = &self.pattern.inner { + normalize_value_matches(&mut value, exp, self.substitutions); + } + Data::json(value) + } + } + } +} + +#[cfg(feature = "structured-data")] +fn normalize_value(value: &mut serde_json::Value, op: fn(&str) -> String) { + match value { + serde_json::Value::String(str) => { + *str = op(str); } + serde_json::Value::Array(arr) => { + arr.iter_mut().for_each(|value| normalize_value(value, op)); + } + serde_json::Value::Object(obj) => { + obj.iter_mut() + .for_each(|(_, value)| normalize_value(value, op)); + } + _ => {} + } +} + +#[cfg(feature = "structured-data")] +fn normalize_value_matches( + actual: &mut serde_json::Value, + expected: &serde_json::Value, + substitutions: &crate::Substitutions, +) { + use serde_json::Value::*; + match (actual, expected) { + // "{...}" is a wildcard + (act, String(exp)) if exp == "{...}" => { + *act = serde_json::json!("{...}"); + } + (String(act), String(exp)) => { + *act = substitutions.normalize(act, exp); + } + (Array(act), Array(exp)) => { + act.iter_mut() + .zip(exp) + .for_each(|(a, e)| normalize_value_matches(a, e, substitutions)); + } + (Object(act), Object(exp)) => { + act.iter_mut() + .zip(exp) + .filter(|(a, e)| a.0 == e.0) + .for_each(|(a, e)| normalize_value_matches(a.1, e.1, substitutions)); + } + (_, _) => {} } } @@ -268,3 +396,317 @@ fn is_binary(data: &[u8]) -> bool { fn is_binary(_data: &[u8]) -> bool { false } + +#[cfg(test)] +mod test { + use super::*; + #[cfg(feature = "json")] + use serde_json::json; + + // Tests for checking to_bytes and render produce the same results + #[test] + fn text_to_bytes_render() { + let d = Data::text(String::from("test")); + let bytes = d.to_bytes(); + let bytes = String::from_utf8(bytes).unwrap(); + let rendered = d.render().unwrap(); + assert_eq!(bytes, rendered); + } + + #[test] + #[cfg(feature = "json")] + fn json_to_bytes_render() { + let d = Data::json(json!({"name": "John\\Doe\r\n"})); + let bytes = d.to_bytes(); + let bytes = String::from_utf8(bytes).unwrap(); + let rendered = d.render().unwrap(); + assert_eq!(bytes, rendered); + } + + // Tests for checking all types are coercible to each other and + // for when the coercion should fail + #[test] + fn binary_to_text() { + let binary = String::from("test").into_bytes(); + let d = Data::binary(binary); + let text = d.try_coerce(DataFormat::Text); + assert_eq!(DataFormat::Text, text.format()) + } + + #[test] + fn binary_to_text_not_utf8() { + let binary = b"\xFF\xE0\x00\x10\x4A\x46\x49\x46\x00".to_vec(); + let d = Data::binary(binary); + let d = d.try_coerce(DataFormat::Text); + assert_ne!(DataFormat::Text, d.format()); + assert_eq!(DataFormat::Binary, d.format()); + } + + #[test] + #[cfg(feature = "json")] + fn binary_to_json() { + let value = json!({"name": "John\\Doe\r\n"}); + let binary = serde_json::to_vec_pretty(&value).unwrap(); + let d = Data::binary(binary); + let json = d.try_coerce(DataFormat::Json); + assert_eq!(DataFormat::Json, json.format()); + } + + #[test] + #[cfg(feature = "json")] + fn binary_to_json_not_utf8() { + let binary = b"\xFF\xE0\x00\x10\x4A\x46\x49\x46\x00".to_vec(); + let d = Data::binary(binary); + let d = d.try_coerce(DataFormat::Json); + assert_ne!(DataFormat::Json, d.format()); + assert_eq!(DataFormat::Binary, d.format()); + } + + #[test] + #[cfg(feature = "json")] + fn binary_to_json_not_json() { + let binary = String::from("test").into_bytes(); + let d = Data::binary(binary); + let d = d.try_coerce(DataFormat::Json); + assert_ne!(DataFormat::Json, d.format()); + assert_eq!(DataFormat::Binary, d.format()); + } + + #[test] + fn text_to_binary() { + let text = String::from("test"); + let d = Data::text(text); + let binary = d.try_coerce(DataFormat::Binary); + assert_eq!(DataFormat::Binary, binary.format()); + } + + #[test] + #[cfg(feature = "json")] + fn text_to_json() { + let value = json!({"name": "John\\Doe\r\n"}); + let text = serde_json::to_string_pretty(&value).unwrap(); + let d = Data::text(text); + let json = d.try_coerce(DataFormat::Json); + assert_eq!(DataFormat::Json, json.format()); + } + + #[test] + #[cfg(feature = "json")] + fn text_to_json_not_json() { + let text = String::from("test"); + let d = Data::text(text); + let json = d.try_coerce(DataFormat::Json); + assert_eq!(DataFormat::Text, json.format()); + } + + #[test] + #[cfg(feature = "json")] + fn json_to_binary() { + let value = json!({"name": "John\\Doe\r\n"}); + let d = Data::json(value); + let binary = d.try_coerce(DataFormat::Binary); + assert_eq!(DataFormat::Binary, binary.format()); + } + + #[test] + #[cfg(feature = "json")] + fn json_to_text() { + let value = json!({"name": "John\\Doe\r\n"}); + let d = Data::json(value); + let text = d.try_coerce(DataFormat::Text); + assert_eq!(DataFormat::Text, text.format()); + } + + // Tests for coercible conversions create the same output as to_bytes/render + // + // render does not need to be checked against bin -> text since render + // outputs None for binary + #[test] + fn text_to_bin_coerce_equals_to_bytes() { + let text = String::from("test"); + let d = Data::text(text); + let binary = d.clone().try_coerce(DataFormat::Binary); + assert_eq!(Data::binary(d.to_bytes()), binary); + } + + #[test] + #[cfg(feature = "json")] + fn json_to_bin_coerce_equals_to_bytes() { + let json = json!({"name": "John\\Doe\r\n"}); + let d = Data::json(json); + let binary = d.clone().try_coerce(DataFormat::Binary); + assert_eq!(Data::binary(d.to_bytes()), binary); + } + + #[test] + #[cfg(feature = "json")] + fn json_to_text_coerce_equals_render() { + let json = json!({"name": "John\\Doe\r\n"}); + let d = Data::json(json); + let text = d.clone().try_coerce(DataFormat::Text); + assert_eq!(Data::text(d.render().unwrap()), text); + } + + // Tests for normalization on json + #[test] + #[cfg(feature = "json")] + fn json_normalize_paths_and_lines() { + let json = json!({"name": "John\\Doe\r\n"}); + let data = Data::json(json); + let data = data.normalize(NormalizePaths); + assert_eq!(Data::json(json!({"name": "John/Doe\r\n"})), data); + let data = data.normalize(NormalizeNewlines); + assert_eq!(Data::json(json!({"name": "John/Doe\n"})), data); + } + + #[test] + #[cfg(feature = "json")] + fn json_normalize_obj_paths_and_lines() { + let json = json!({ + "person": { + "name": "John\\Doe\r\n", + "nickname": "Jo\\hn\r\n", + } + }); + let data = Data::json(json); + let data = data.normalize(NormalizePaths); + let assert = json!({ + "person": { + "name": "John/Doe\r\n", + "nickname": "Jo/hn\r\n", + } + }); + assert_eq!(Data::json(assert), data); + let data = data.normalize(NormalizeNewlines); + let assert = json!({ + "person": { + "name": "John/Doe\n", + "nickname": "Jo/hn\n", + } + }); + assert_eq!(Data::json(assert), data); + } + + #[test] + #[cfg(feature = "json")] + fn json_normalize_array_paths_and_lines() { + let json = json!({"people": ["John\\Doe\r\n", "Jo\\hn\r\n"]}); + let data = Data::json(json); + let data = data.normalize(NormalizePaths); + let paths = json!({"people": ["John/Doe\r\n", "Jo/hn\r\n"]}); + assert_eq!(Data::json(paths), data); + let data = data.normalize(NormalizeNewlines); + let new_lines = json!({"people": ["John/Doe\n", "Jo/hn\n"]}); + assert_eq!(Data::json(new_lines), data); + } + + #[test] + #[cfg(feature = "json")] + fn json_normalize_array_obj_paths_and_lines() { + let json = json!({ + "people": [ + { + "name": "John\\Doe\r\n", + "nickname": "Jo\\hn\r\n", + } + ] + }); + let data = Data::json(json); + let data = data.normalize(NormalizePaths); + let paths = json!({ + "people": [ + { + "name": "John/Doe\r\n", + "nickname": "Jo/hn\r\n", + } + ] + }); + assert_eq!(Data::json(paths), data); + let data = data.normalize(NormalizeNewlines); + let new_lines = json!({ + "people": [ + { + "name": "John/Doe\n", + "nickname": "Jo/hn\n", + } + ] + }); + assert_eq!(Data::json(new_lines), data); + } + + #[test] + #[cfg(feature = "json")] + fn json_normalize_matches_string() { + let exp = json!({"name": "{...}"}); + let expected = Data::json(exp); + let actual = json!({"name": "JohnDoe"}); + let actual = Data::json(actual).normalize(NormalizeMatches { + substitutions: &Default::default(), + pattern: &expected, + }); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } + } + + #[test] + #[cfg(feature = "json")] + fn json_normalize_matches_array() { + let exp = json!({"people": "{...}"}); + let expected = Data::json(exp); + let actual = json!({ + "people": [ + { + "name": "JohnDoe", + "nickname": "John", + } + ] + }); + let actual = Data::json(actual).normalize(NormalizeMatches { + substitutions: &Default::default(), + pattern: &expected, + }); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } + } + + #[test] + #[cfg(feature = "json")] + fn json_normalize_matches_obj() { + let exp = json!({"people": "{...}"}); + let expected = Data::json(exp); + let actual = json!({ + "people": { + "name": "JohnDoe", + "nickname": "John", + } + }); + let actual = Data::json(actual).normalize(NormalizeMatches { + substitutions: &Default::default(), + pattern: &expected, + }); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_eq!(exp, act); + } + } + + #[test] + #[cfg(feature = "json")] + fn json_normalize_matches_diff_order_array() { + let exp = json!({ + "people": ["John", "Jane"] + }); + let expected = Data::json(exp); + let actual = json!({ + "people": ["Jane", "John"] + }); + let actual = Data::json(actual).normalize(NormalizeMatches { + substitutions: &Default::default(), + pattern: &expected, + }); + if let (DataInner::Json(exp), DataInner::Json(act)) = (expected.inner, actual.inner) { + assert_ne!(exp, act); + } + } +}