diff --git a/Cargo.lock b/Cargo.lock index 8ff13bcf..ef117b90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -679,9 +679,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.82" +version = "1.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82c2c1fdcd807d1098552c5b9a36e425e42e9fbd7c6a37a8425f390f781f7fa7" +checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44" dependencies = [ "itoa", "ryu", @@ -711,9 +711,12 @@ dependencies = [ "dunce", "filetime", "ignore", + "indexmap", "libtest-mimic", "normalize-line-endings", "os_pipe", + "serde", + "serde_json", "similar", "snapbox-macros", "tempfile", diff --git a/crates/snapbox/Cargo.toml b/crates/snapbox/Cargo.toml index 4ece5c69..1b8fceeb 100644 --- a/crates/snapbox/Cargo.toml +++ b/crates/snapbox/Cargo.toml @@ -49,6 +49,11 @@ path = ["tempfile", "walkdir", "dunce", "detect-encoding", "filetime"] ## Snapshotting of commands cmd = ["os_pipe", "wait-timeout"] +## Snapshotting of json +json = ["structured-data"] +## Snapshotting of structured data +structured-data = ["serde_json", "indexmap", "serde"] + ## Extra debugging information debug = ["snapbox-macros/debug", "backtrace"] @@ -89,3 +94,9 @@ yansi = { version = "0.5.0", optional = true } concolor = { version = "0.0.8", optional = true } document-features = { version = "0.2.3", optional = true } + +serde = { version = "1.0", features = ["derive"], optional = true } + +serde_json = { version = "1.0.85", optional = true } + +indexmap = { version = "1.9.1", optional = true, features = ["serde-1"] } diff --git a/crates/snapbox/src/data.rs b/crates/snapbox/src/data.rs index 696e047d..9e4f36b7 100644 --- a/crates/snapbox/src/data.rs +++ b/crates/snapbox/src/data.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "structured-data")] +use serde::{Deserialize, Serialize}; /// Test fixture, actual output, or expected result /// /// This provides conveniences for tracking the intended format (binary vs text). @@ -10,12 +12,16 @@ pub struct Data { enum DataInner { Binary(Vec), Text(String), + #[cfg(feature = "structured-data")] + Json(Value), } #[derive(Clone, Debug, PartialEq, Eq, Copy, Hash)] pub enum DataFormat { Binary, Text, + #[cfg(feature = "json")] + Json, } impl Default for DataFormat { @@ -24,6 +30,47 @@ impl Default for DataFormat { } } +#[cfg(feature = "structured-data")] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Value { + Null, + Bool(bool), + Number(serde_json::Number), + String(String), + Array(Vec), + // IndexMap is used as it keeps insertion order + Object(indexmap::IndexMap), +} + +#[cfg(feature = "structured-data")] +impl Value { + fn normalize_paths(self) -> Self { + match self { + Value::Null => Value::Null, + Value::Bool(b) => Value::Bool(b), + Value::Number(num) => Value::Number(num), + Value::String(str) => { + let str = crate::utils::normalize_paths(&str); + Value::String(str) + } + Value::Array(arr) => { + let arr = arr + .into_iter() + .map(|value| value.normalize_paths()) + .collect(); + Value::Array(arr) + } + Value::Object(obj) => { + let obj = indexmap::IndexMap::from_iter( + obj.into_iter() + .map(|(str, value)| (str, value.normalize_paths())), + ); + Value::Object(obj) + } + } + } +} + impl Data { /// Mark the data as binary (no post-processing) pub fn binary(raw: impl Into>) -> Self { @@ -39,6 +86,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 +115,25 @@ 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() { + Some(ext) => match ext.to_str() { + #[cfg(feature = "json")] + Some("json") => data.try_coerce(DataFormat::Json), + _ => data.try_coerce(DataFormat::Text), + }, + None => data.try_coerce(DataFormat::Text), + } } }; Ok(data) @@ -107,6 +175,8 @@ impl Data { Ok(data) } DataInner::Text(data) => Ok(data), + #[cfg(feature = "json")] + DataInner::Json(value) => Ok(serde_json::to_string_pretty(&value).unwrap()), } } @@ -117,6 +187,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()), } } @@ -124,6 +196,8 @@ 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(value).unwrap(), } } @@ -145,6 +219,23 @@ impl Data { } } DataInner::Text(data) => Self::text(data), + #[cfg(feature = "json")] + DataInner::Json(data) => match serde_json::to_string_pretty(&data) { + Ok(text) => Self::text(text), + Err(_) => Self::json(data), + }, + }, + #[cfg(feature = "json")] + DataFormat::Json => match self.inner { + DataInner::Binary(bin) => match serde_json::from_slice::(&bin) { + Ok(json) => Self::json(json), + Err(_) => Self::binary(bin), + }, + DataInner::Text(text) => match serde_json::from_str::(&text) { + Ok(json) => Self::json(json), + Err(_) => Self::text(text), + }, + DataInner::Json(json) => Self::json(json), }, } } @@ -154,6 +245,8 @@ impl Data { match &self.inner { DataInner::Binary(_) => DataFormat::Binary, DataInner::Text(_) => DataFormat::Text, + #[cfg(feature = "json")] + DataInner::Json(_) => DataFormat::Json, } } } @@ -163,6 +256,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) => std::fmt::Debug::fmt(data, f), } } } @@ -222,6 +317,8 @@ impl Normalize for NormalizeNewlines { let lines = crate::utils::normalize_lines(&text); Data::text(lines) } + #[cfg(feature = "json")] + DataInner::Json(json) => Data::json(json), } } } @@ -235,6 +332,8 @@ impl Normalize for NormalizePaths { let lines = crate::utils::normalize_paths(&text); Data::text(lines) } + #[cfg(feature = "json")] + DataInner::Json(value) => Data::json(value.normalize_paths()), } } } @@ -263,6 +362,8 @@ impl Normalize for NormalizeMatches<'_> { .normalize(&text, &self.pattern.render().unwrap()); Data::text(lines) } + #[cfg(feature = "json")] + DataInner::Json(_) => todo!("unsure of how to do matches here"), } } }