mod filters;
mod format;
mod runtime;
mod source;
#[cfg(test)]
mod tests;
pub use format::DataFormat;
pub use source::DataSource;
pub use source::Inline;
#[doc(hidden)]
pub use source::Position;
use filters::FilterSet;
pub trait ToDebug {
fn to_debug(&self) -> Data;
}
impl<D: std::fmt::Debug> ToDebug for D {
fn to_debug(&self) -> Data {
Data::text(format!("{self:#?}\n"))
}
}
#[cfg(feature = "json")]
pub trait IntoJson {
fn into_json(self) -> Data;
}
#[cfg(feature = "json")]
impl<S: serde::Serialize> IntoJson for S {
fn into_json(self) -> Data {
match serde_json::to_value(self) {
Ok(value) => Data::json(value),
Err(err) => Data::error(err.to_string(), DataFormat::Json),
}
}
}
#[allow(clippy::wrong_self_convention)]
pub trait IntoData: Sized {
fn raw(self) -> Data {
self.into_data().raw()
}
fn unordered(self) -> Data {
self.into_data().unordered()
}
fn is(self, format: DataFormat) -> Data {
self.into_data().is(format)
}
#[cfg(feature = "json")]
fn is_json(self) -> Data {
self.is(DataFormat::Json)
}
#[cfg(feature = "json")]
#[deprecated(since = "0.6.13", note = "Replaced with `IntoData::is_json`")]
fn json(self) -> Data {
self.is_json()
}
#[cfg(feature = "json")]
fn is_jsonlines(self) -> Data {
self.is(DataFormat::JsonLines)
}
#[cfg(feature = "json")]
#[deprecated(since = "0.6.13", note = "Replaced with `IntoData::is_jsonlines`")]
fn json_lines(self) -> Data {
self.is_jsonlines()
}
#[cfg(feature = "term-svg")]
fn is_termsvg(self) -> Data {
self.is(DataFormat::TermSvg)
}
#[cfg(feature = "term-svg")]
#[deprecated(since = "0.6.13", note = "Replaced with `IntoData::is_termsvg`")]
fn term_svg(self) -> Data {
self.is_termsvg()
}
fn against(self, format: DataFormat) -> Data {
self.into_data().against(format)
}
#[cfg(feature = "json")]
fn against_json(self) -> Data {
self.against(DataFormat::Json)
}
#[cfg(feature = "json")]
fn against_jsonlines(self) -> Data {
self.against(DataFormat::JsonLines)
}
fn into_data(self) -> Data;
}
impl IntoData for Data {
fn into_data(self) -> Data {
self
}
}
impl IntoData for &'_ Data {
fn into_data(self) -> Data {
self.clone()
}
}
impl IntoData for Vec<u8> {
fn into_data(self) -> Data {
Data::binary(self)
}
}
impl IntoData for &'_ [u8] {
fn into_data(self) -> Data {
self.to_owned().into_data()
}
}
impl IntoData for String {
fn into_data(self) -> Data {
Data::text(self)
}
}
impl IntoData for &'_ String {
fn into_data(self) -> Data {
self.to_owned().into_data()
}
}
impl IntoData for &'_ str {
fn into_data(self) -> Data {
self.to_owned().into_data()
}
}
impl IntoData for Inline {
fn into_data(self) -> Data {
let trimmed = self.trimmed();
Data::text(trimmed).with_source(self)
}
}
#[macro_export]
macro_rules! file {
[_] => {{
let path = $crate::data::generate_snapshot_path($crate::fn_path!(), None);
$crate::Data::read_from(&path, None)
}};
[_ : $type:ident] => {{
let format = $crate::data::DataFormat:: $type;
let path = $crate::data::generate_snapshot_path($crate::fn_path!(), Some(format));
$crate::Data::read_from(&path, Some($crate::data::DataFormat:: $type))
}};
[$path:literal] => {{
let mut path = $crate::utils::current_dir!();
path.push($path);
$crate::Data::read_from(&path, None)
}};
[$path:literal : $type:ident] => {{
let mut path = $crate::utils::current_dir!();
path.push($path);
$crate::Data::read_from(&path, Some($crate::data::DataFormat:: $type))
}};
}
#[macro_export]
macro_rules! str {
[$data:literal] => { $crate::str![[$data]] };
[[$data:literal]] => {{
let position = $crate::data::Position {
file: $crate::utils::current_rs!(),
line: line!(),
column: column!(),
};
let inline = $crate::data::Inline {
position,
data: $data,
};
inline
}};
[] => { $crate::str![[""]] };
[[]] => { $crate::str![[""]] };
}
#[derive(Clone, Debug)]
pub struct Data {
pub(crate) inner: Box<DataInner>,
}
#[derive(Clone, Debug)]
pub(crate) struct DataInner {
pub(crate) value: DataValue,
pub(crate) source: Option<DataSource>,
pub(crate) filters: FilterSet,
}
#[derive(Clone, Debug)]
pub(crate) enum DataValue {
Error(DataError),
Binary(Vec<u8>),
Text(String),
#[cfg(feature = "json")]
Json(serde_json::Value),
#[cfg(feature = "json")]
JsonLines(serde_json::Value),
#[cfg(feature = "term-svg")]
TermSvg(String),
}
impl Data {
pub fn binary(raw: impl Into<Vec<u8>>) -> Self {
Self::with_value(DataValue::Binary(raw.into()))
}
pub fn text(raw: impl Into<String>) -> Self {
Self::with_value(DataValue::Text(raw.into()))
}
#[cfg(feature = "json")]
pub fn json(raw: impl Into<serde_json::Value>) -> Self {
Self::with_value(DataValue::Json(raw.into()))
}
#[cfg(feature = "json")]
pub fn jsonlines(raw: impl Into<Vec<serde_json::Value>>) -> Self {
Self::with_value(DataValue::JsonLines(serde_json::Value::Array(raw.into())))
}
fn error(raw: impl Into<crate::assert::Error>, intended: DataFormat) -> Self {
Self::with_value(DataValue::Error(DataError {
error: raw.into(),
intended,
}))
}
pub fn new() -> Self {
Self::text("")
}
pub fn read_from(path: &std::path::Path, data_format: Option<DataFormat>) -> Self {
match Self::try_read_from(path, data_format) {
Ok(data) => data,
Err(err) => Self::error(err, data_format.unwrap_or_else(|| DataFormat::from(path)))
.with_path(path),
}
}
pub fn raw(mut self) -> Self {
self.inner.filters = FilterSet::empty().newlines();
self
}
pub fn unordered(mut self) -> Self {
self.inner.filters = self.inner.filters.unordered();
self
}
}
impl Data {
pub(crate) fn with_value(value: DataValue) -> Self {
Self {
inner: Box::new(DataInner {
value,
source: None,
filters: FilterSet::new(),
}),
}
}
fn with_source(mut self, source: impl Into<DataSource>) -> Self {
self.inner.source = Some(source.into());
self
}
fn with_path(self, path: impl Into<std::path::PathBuf>) -> Self {
self.with_source(path.into())
}
pub fn try_read_from(
path: &std::path::Path,
data_format: Option<DataFormat>,
) -> crate::assert::Result<Self> {
let data =
std::fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let data = Self::binary(data);
let data = match data_format {
Some(df) => data.is(df),
None => {
let inferred_format = DataFormat::from(path);
match inferred_format {
#[cfg(feature = "json")]
DataFormat::Json | DataFormat::JsonLines => data.coerce_to(inferred_format),
#[cfg(feature = "term-svg")]
DataFormat::TermSvg => {
let data = data.coerce_to(DataFormat::Text);
data.is(inferred_format)
}
_ => data.coerce_to(DataFormat::Text),
}
}
};
Ok(data.with_path(path))
}
pub fn write_to(&self, source: &DataSource) -> crate::assert::Result<()> {
match &source.inner {
source::DataSourceInner::Path(p) => self.write_to_path(p),
source::DataSourceInner::Inline(p) => runtime::get()
.write(self, p)
.map_err(|err| err.to_string().into()),
}
}
pub fn write_to_path(&self, path: &std::path::Path) -> crate::assert::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
format!("Failed to create parent dir for {}: {}", path.display(), e)
})?;
}
let bytes = self.to_bytes()?;
std::fs::write(path, bytes)
.map_err(|e| format!("Failed to write {}: {}", path.display(), e).into())
}
pub fn render(&self) -> Option<String> {
match &self.inner.value {
DataValue::Error(_) => None,
DataValue::Binary(_) => None,
DataValue::Text(data) => Some(data.to_owned()),
#[cfg(feature = "json")]
DataValue::Json(_) => Some(self.to_string()),
#[cfg(feature = "json")]
DataValue::JsonLines(_) => Some(self.to_string()),
#[cfg(feature = "term-svg")]
DataValue::TermSvg(data) => Some(data.to_owned()),
}
}
pub fn to_bytes(&self) -> crate::assert::Result<Vec<u8>> {
match &self.inner.value {
DataValue::Error(err) => Err(err.error.clone()),
DataValue::Binary(data) => Ok(data.clone()),
DataValue::Text(data) => Ok(data.clone().into_bytes()),
#[cfg(feature = "json")]
DataValue::Json(_) => Ok(self.to_string().into_bytes()),
#[cfg(feature = "json")]
DataValue::JsonLines(_) => Ok(self.to_string().into_bytes()),
#[cfg(feature = "term-svg")]
DataValue::TermSvg(data) => Ok(data.clone().into_bytes()),
}
}
pub fn is(self, format: DataFormat) -> Self {
let filters = self.inner.filters;
let source = self.inner.source.clone();
match self.try_is(format) {
Ok(new) => new,
Err(err) => {
let value = DataValue::Error(DataError {
error: err,
intended: format,
});
Self {
inner: Box::new(DataInner {
value,
source,
filters,
}),
}
}
}
}
fn try_is(self, format: DataFormat) -> crate::assert::Result<Self> {
let original = self.format();
let source = self.inner.source;
let filters = self.inner.filters;
let value = match (self.inner.value, format) {
(DataValue::Error(inner), _) => DataValue::Error(inner),
(DataValue::Binary(inner), DataFormat::Binary) => DataValue::Binary(inner),
(DataValue::Text(inner), DataFormat::Text) => DataValue::Text(inner),
#[cfg(feature = "json")]
(DataValue::Json(inner), DataFormat::Json) => DataValue::Json(inner),
#[cfg(feature = "json")]
(DataValue::JsonLines(inner), DataFormat::JsonLines) => DataValue::JsonLines(inner),
#[cfg(feature = "term-svg")]
(DataValue::TermSvg(inner), DataFormat::TermSvg) => DataValue::TermSvg(inner),
(DataValue::Binary(inner), _) => {
let value = String::from_utf8(inner).map_err(|_err| "invalid UTF-8".to_owned())?;
Self::text(value).try_is(format)?.inner.value
}
#[cfg(feature = "json")]
(DataValue::Text(inner), DataFormat::Json) => {
let value = serde_json::from_str::<serde_json::Value>(&inner)
.map_err(|err| err.to_string())?;
DataValue::Json(value)
}
#[cfg(feature = "json")]
(DataValue::Text(inner), DataFormat::JsonLines) => {
let value = parse_jsonlines(&inner).map_err(|err| err.to_string())?;
DataValue::JsonLines(serde_json::Value::Array(value))
}
#[cfg(feature = "term-svg")]
(DataValue::Text(inner), DataFormat::TermSvg) => DataValue::TermSvg(inner),
(value, DataFormat::Binary) => {
let remake = Self::with_value(value);
DataValue::Binary(remake.to_bytes().expect("error case handled"))
}
#[cfg(feature = "structured-data")]
(value, DataFormat::Text) => {
if let Some(str) = Self::with_value(value).render() {
DataValue::Text(str)
} else {
return Err(format!("cannot convert {original:?} to {format:?}").into());
}
}
(_, _) => return Err(format!("cannot convert {original:?} to {format:?}").into()),
};
Ok(Self {
inner: Box::new(DataInner {
value,
source,
filters,
}),
})
}
fn against(mut self, format: DataFormat) -> Data {
self.inner.filters = self.inner.filters.against(format);
self
}
pub fn coerce_to(self, format: DataFormat) -> Self {
let source = self.inner.source;
let filters = self.inner.filters;
let value = match (self.inner.value, format) {
(DataValue::Error(inner), _) => DataValue::Error(inner),
(value, DataFormat::Error) => value,
(DataValue::Binary(inner), DataFormat::Binary) => DataValue::Binary(inner),
(DataValue::Text(inner), DataFormat::Text) => DataValue::Text(inner),
#[cfg(feature = "json")]
(DataValue::Json(inner), DataFormat::Json) => DataValue::Json(inner),
#[cfg(feature = "json")]
(DataValue::JsonLines(inner), DataFormat::JsonLines) => DataValue::JsonLines(inner),
#[cfg(feature = "json")]
(DataValue::JsonLines(inner), DataFormat::Json) => DataValue::Json(inner),
#[cfg(feature = "json")]
(DataValue::Json(inner), DataFormat::JsonLines) => DataValue::JsonLines(inner),
#[cfg(feature = "term-svg")]
(DataValue::TermSvg(inner), DataFormat::TermSvg) => DataValue::TermSvg(inner),
(DataValue::Binary(inner), _) => {
if is_binary(&inner) {
DataValue::Binary(inner)
} else {
match String::from_utf8(inner) {
Ok(str) => {
let coerced = Self::text(str).coerce_to(format);
let coerced = if coerced.format() != format {
coerced.coerce_to(DataFormat::Binary)
} else {
coerced
};
coerced.inner.value
}
Err(err) => {
let bin = err.into_bytes();
DataValue::Binary(bin)
}
}
}
}
#[cfg(feature = "json")]
(DataValue::Text(inner), DataFormat::Json) => {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&inner) {
DataValue::Json(json)
} else {
DataValue::Text(inner)
}
}
#[cfg(feature = "json")]
(DataValue::Text(inner), DataFormat::JsonLines) => {
if let Ok(jsonlines) = parse_jsonlines(&inner) {
DataValue::JsonLines(serde_json::Value::Array(jsonlines))
} else {
DataValue::Text(inner)
}
}
#[cfg(feature = "term-svg")]
(DataValue::Text(inner), DataFormat::TermSvg) => {
DataValue::TermSvg(anstyle_svg::Term::new().render_svg(&inner))
}
(value, DataFormat::Binary) => {
let remake = Self::with_value(value);
DataValue::Binary(remake.to_bytes().expect("error case handled"))
}
#[cfg(feature = "structured-data")]
(value, DataFormat::Text) => {
let remake = Self::with_value(value);
if let Some(str) = remake.render() {
DataValue::Text(str)
} else {
remake.inner.value
}
}
#[allow(unreachable_patterns)]
#[cfg(feature = "json")]
(value, DataFormat::Json) => value,
#[allow(unreachable_patterns)]
#[cfg(feature = "json")]
(value, DataFormat::JsonLines) => value,
#[allow(unreachable_patterns)]
#[cfg(feature = "term-svg")]
(value, DataFormat::TermSvg) => value,
};
Self {
inner: Box::new(DataInner {
value,
source,
filters,
}),
}
}
pub fn source(&self) -> Option<&DataSource> {
self.inner.source.as_ref()
}
pub fn format(&self) -> DataFormat {
match &self.inner.value {
DataValue::Error(_) => DataFormat::Error,
DataValue::Binary(_) => DataFormat::Binary,
DataValue::Text(_) => DataFormat::Text,
#[cfg(feature = "json")]
DataValue::Json(_) => DataFormat::Json,
#[cfg(feature = "json")]
DataValue::JsonLines(_) => DataFormat::JsonLines,
#[cfg(feature = "term-svg")]
DataValue::TermSvg(_) => DataFormat::TermSvg,
}
}
pub(crate) fn intended_format(&self) -> DataFormat {
match &self.inner.value {
DataValue::Error(DataError { intended, .. }) => *intended,
DataValue::Binary(_) => DataFormat::Binary,
DataValue::Text(_) => DataFormat::Text,
#[cfg(feature = "json")]
DataValue::Json(_) => DataFormat::Json,
#[cfg(feature = "json")]
DataValue::JsonLines(_) => DataFormat::JsonLines,
#[cfg(feature = "term-svg")]
DataValue::TermSvg(_) => DataFormat::TermSvg,
}
}
pub(crate) fn against_format(&self) -> DataFormat {
self.inner
.filters
.get_against()
.unwrap_or_else(|| self.intended_format())
}
pub(crate) fn relevant(&self) -> Option<&str> {
match &self.inner.value {
DataValue::Error(_) => None,
DataValue::Binary(_) => None,
DataValue::Text(_) => None,
#[cfg(feature = "json")]
DataValue::Json(_) => None,
#[cfg(feature = "json")]
DataValue::JsonLines(_) => None,
#[cfg(feature = "term-svg")]
DataValue::TermSvg(data) => term_svg_body(data),
}
}
}
impl std::fmt::Display for Data {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.inner.value {
DataValue::Error(data) => data.fmt(f),
DataValue::Binary(data) => String::from_utf8_lossy(data).fmt(f),
DataValue::Text(data) => data.fmt(f),
#[cfg(feature = "json")]
DataValue::Json(data) => serde_json::to_string_pretty(data).unwrap().fmt(f),
#[cfg(feature = "json")]
DataValue::JsonLines(data) => {
let array = data.as_array().expect("jsonlines is always an array");
for value in array {
writeln!(f, "{}", serde_json::to_string(value).unwrap())?;
}
Ok(())
}
#[cfg(feature = "term-svg")]
DataValue::TermSvg(data) => data.fmt(f),
}
}
}
impl PartialEq for Data {
fn eq(&self, other: &Data) -> bool {
match (&self.inner.value, &other.inner.value) {
(DataValue::Error(left), DataValue::Error(right)) => left == right,
(DataValue::Binary(left), DataValue::Binary(right)) => left == right,
(DataValue::Text(left), DataValue::Text(right)) => left == right,
#[cfg(feature = "json")]
(DataValue::Json(left), DataValue::Json(right)) => left == right,
#[cfg(feature = "json")]
(DataValue::JsonLines(left), DataValue::JsonLines(right)) => left == right,
#[cfg(feature = "term-svg")]
(DataValue::TermSvg(left), DataValue::TermSvg(right)) => {
let left = term_svg_body(left.as_str()).unwrap_or(left.as_str());
let right = term_svg_body(right.as_str()).unwrap_or(right.as_str());
left == right
}
(_, _) => false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DataError {
error: crate::assert::Error,
intended: DataFormat,
}
impl std::fmt::Display for DataError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.error.fmt(f)
}
}
#[cfg(feature = "json")]
fn parse_jsonlines(text: &str) -> Result<Vec<serde_json::Value>, serde_json::Error> {
let mut lines = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let json = serde_json::from_str::<serde_json::Value>(line)?;
lines.push(json);
}
Ok(lines)
}
#[cfg(feature = "term-svg")]
fn term_svg_body(svg: &str) -> Option<&str> {
let (_header, body, _footer) = split_term_svg(svg)?;
Some(body)
}
#[cfg(feature = "term-svg")]
pub(crate) fn split_term_svg(svg: &str) -> Option<(&str, &str, &str)> {
let open_elem_start_idx = svg.find("<text")?;
_ = svg[open_elem_start_idx..].find('>')?;
let open_elem_line_start_idx = svg[..open_elem_start_idx]
.rfind('\n')
.map(|idx| idx + 1)
.unwrap_or(svg.len());
let close_elem = "</text>";
let close_elem_start_idx = svg.rfind(close_elem).unwrap_or(svg.len());
let close_elem_line_end_idx = svg[close_elem_start_idx..]
.find('\n')
.map(|idx| idx + close_elem_start_idx + 1)
.unwrap_or(svg.len());
let header = &svg[..open_elem_line_start_idx];
let body = &svg[open_elem_line_start_idx..close_elem_line_end_idx];
let footer = &svg[close_elem_line_end_idx..];
Some((header, body, footer))
}
impl Eq for Data {}
impl Default for Data {
fn default() -> Self {
Self::new()
}
}
impl<'d> From<&'d Data> for Data {
fn from(other: &'d Data) -> Self {
other.into_data()
}
}
impl From<Vec<u8>> for Data {
fn from(other: Vec<u8>) -> Self {
other.into_data()
}
}
impl<'b> From<&'b [u8]> for Data {
fn from(other: &'b [u8]) -> Self {
other.into_data()
}
}
impl From<String> for Data {
fn from(other: String) -> Self {
other.into_data()
}
}
impl<'s> From<&'s String> for Data {
fn from(other: &'s String) -> Self {
other.into_data()
}
}
impl<'s> From<&'s str> for Data {
fn from(other: &'s str) -> Self {
other.into_data()
}
}
impl From<Inline> for Data {
fn from(other: Inline) -> Self {
other.into_data()
}
}
#[cfg(feature = "detect-encoding")]
fn is_binary(data: &[u8]) -> bool {
match content_inspector::inspect(data) {
content_inspector::ContentType::BINARY |
content_inspector::ContentType::UTF_16LE |
content_inspector::ContentType::UTF_16BE |
content_inspector::ContentType::UTF_32LE |
content_inspector::ContentType::UTF_32BE => {
true
},
content_inspector::ContentType::UTF_8 |
content_inspector::ContentType::UTF_8_BOM => {
false
},
}
}
#[cfg(not(feature = "detect-encoding"))]
fn is_binary(_data: &[u8]) -> bool {
false
}
#[doc(hidden)]
pub fn generate_snapshot_path(fn_path: &str, format: Option<DataFormat>) -> std::path::PathBuf {
use std::fmt::Write as _;
let fn_path_normalized = fn_path.replace("::", "__");
let mut path = format!("tests/snapshots/{fn_path_normalized}");
let count = runtime::get().count(&path);
if 0 < count {
write!(&mut path, "@{count}").unwrap();
}
path.push('.');
path.push_str(format.unwrap_or(DataFormat::Text).ext());
path.into()
}
#[cfg(test)]
mod test {
use super::*;
#[track_caller]
fn validate_cases(cases: &[(&str, bool)], input_format: DataFormat) {
for (input, valid) in cases.iter().copied() {
let (expected_is_format, expected_coerced_format) = if valid {
(input_format, input_format)
} else {
(DataFormat::Error, DataFormat::Text)
};
let actual_is = Data::text(input).is(input_format);
assert_eq!(
actual_is.format(),
expected_is_format,
"\n{input}\n{actual_is}"
);
let actual_coerced = Data::text(input).coerce_to(input_format);
assert_eq!(
actual_coerced.format(),
expected_coerced_format,
"\n{input}\n{actual_coerced}"
);
if valid {
assert_eq!(actual_is, actual_coerced);
let rendered = actual_is.render().unwrap();
let bytes = actual_is.to_bytes().unwrap();
assert_eq!(rendered, std::str::from_utf8(&bytes).unwrap());
assert_eq!(Data::text(&rendered).is(input_format), actual_is);
}
}
}
#[test]
fn text() {
let cases = [("", true), ("good", true), ("{}", true), ("\"\"", true)];
validate_cases(&cases, DataFormat::Text);
}
#[cfg(feature = "json")]
#[test]
fn json() {
let cases = [("", false), ("bad", false), ("{}", true), ("\"\"", true)];
validate_cases(&cases, DataFormat::Json);
}
#[cfg(feature = "json")]
#[test]
fn jsonlines() {
let cases = [
("", true),
("bad", false),
("{}", true),
("\"\"", true),
(
"
{}
{}
", true,
),
(
"
{}
{}
", true,
),
(
"
{}
bad
{}
",
false,
),
];
validate_cases(&cases, DataFormat::JsonLines);
}
}