#[cfg(test)]
mod tests;
use std::collections::BTreeMap;
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use std::sync::Arc;
use std::sync::Mutex;
use anyhow::{Context, Result};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
#[macro_export]
macro_rules! assert {
($actual:expr) => {{
let g = $crate::_new_goldie!();
if let Err(err) = g.assert($actual) {
::std::panic!("{}", err);
}
}};
}
#[macro_export]
macro_rules! assert_debug {
($actual:expr) => {{
let g = $crate::_new_goldie!();
if let Err(err) = g.assert_debug($actual) {
::std::panic!("{}", err);
}
}};
}
#[macro_export]
macro_rules! assert_template {
($ctx:expr, $actual:expr) => {{
let g = $crate::_new_goldie!();
if let Err(err) = g.assert_template($ctx, $actual) {
::std::panic!("{}", err);
}
}};
}
#[macro_export]
macro_rules! assert_json {
($actual:expr) => {{
let g = $crate::_new_goldie!();
if let Err(err) = g.assert_json($actual) {
::std::panic!("{}", err);
}
}};
}
#[doc(hidden)]
#[macro_export]
macro_rules! _new_goldie {
() => {{
let source_file = $crate::cargo_workspace_dir(env!("CARGO_MANIFEST_DIR")).join(file!());
let function_path = $crate::_function_path!();
$crate::Goldie::new(source_file, function_path)
}};
}
#[doc(hidden)]
#[macro_export]
macro_rules! _function_path {
() => {{
fn f() {}
fn type_name_of_val<T>(_: T) -> &'static str {
::std::any::type_name::<T>()
}
let mut name = type_name_of_val(f).strip_suffix("::f").unwrap_or("");
while let Some(rest) = name.strip_suffix("::{{closure}}") {
name = rest;
}
name
}};
}
#[derive(Debug)]
pub struct Goldie {
golden_file: PathBuf,
update: bool,
}
impl Goldie {
pub fn new(source_file: impl AsRef<Path>, function_path: impl AsRef<str>) -> Self {
Self::new_impl(source_file.as_ref(), function_path.as_ref())
}
fn new_impl(source_file: &Path, function_path: &str) -> Self {
let (_, name) = function_path.rsplit_once("::").unwrap();
let golden_file = {
let mut p = source_file.parent().unwrap().to_owned();
p.push("testdata");
p.push(name);
p.set_extension("golden");
p
};
let update = matches!(
env::var("GOLDIE_UPDATE").ok().as_deref(),
Some("1" | "true")
);
Self {
golden_file,
update,
}
}
#[track_caller]
pub fn assert(&self, actual: impl AsRef<str>) -> Result<()> {
if self.update {
let dir = self.golden_file.parent().unwrap();
fs::create_dir_all(dir)?;
fs::write(&self.golden_file, actual.as_ref())?;
} else {
let expected = fs::read_to_string(&self.golden_file)
.with_context(|| self.error("failed to read golden file"))?;
pretty_assertions::assert_eq!(
actual.as_ref(),
expected,
"\n\ngolden file `{}` does not match",
self.golden_file
.strip_prefix(env::current_dir()?)?
.display(),
);
}
Ok(())
}
#[track_caller]
pub fn assert_debug(&self, actual: impl std::fmt::Debug) -> Result<()> {
self.assert(format!("{actual:#?}"))
}
#[track_caller]
pub fn assert_template(&self, ctx: impl Serialize, actual: impl AsRef<str>) -> Result<()> {
static ENGINE: Lazy<upon::Engine> = Lazy::new(|| {
upon::Engine::with_syntax(upon::SyntaxBuilder::new().expr("{{", "}}").build())
});
let contents = fs::read_to_string(&self.golden_file)
.with_context(|| self.error("failed to read golden file"))?;
let expected = ENGINE
.compile(&contents)
.with_context(|| self.error("failed to compile golden file template"))?
.render(&ENGINE, &ctx)
.to_string()
.with_context(|| self.error("failed to render golden file template"))?;
pretty_assertions::assert_eq!(
actual.as_ref(),
expected,
"\n\ngolden file `{}` does not match",
self.golden_file
.strip_prefix(env::current_dir()?)?
.display(),
);
Ok(())
}
#[track_caller]
pub fn assert_json(&self, actual: impl Serialize) -> Result<()> {
if self.update {
let dir = self.golden_file.parent().unwrap();
fs::create_dir_all(dir)?;
fs::write(
&self.golden_file,
serde_json::to_string_pretty(&actual).unwrap(),
)?;
} else {
let contents = fs::read_to_string(&self.golden_file)
.with_context(|| self.error("failed to read golden file"))?;
let expected: serde_json::Value =
serde_json::from_str(&contents).with_context(|| self.error("bad JSON"))?;
let actual: serde_json::Value = serde_json::to_value(&actual)?;
pretty_assertions::assert_eq!(
actual,
expected,
"\n\ngolden file `{}` does not match",
self.golden_file
.strip_prefix(env::current_dir()?)?
.display(),
);
}
Ok(())
}
fn error(&self, msg: &str) -> String {
use yansi::Paint;
format!(
"\n\n{}: {}\nrun with {} to regenerate the golden file\n\n",
msg.red(),
self.golden_file.display(),
"GOLDIE_UPDATE=1".blue().bold(),
)
}
}
#[doc(hidden)]
pub fn cargo_workspace_dir(manifest_dir: &str) -> PathBuf {
static DIRS: Lazy<Mutex<BTreeMap<String, Arc<Path>>>> =
Lazy::new(|| Mutex::new(BTreeMap::new()));
let mut dirs = DIRS.lock().unwrap();
if let Some(dir) = dirs.get(manifest_dir) {
return dir.to_path_buf();
}
let dir = env::var("CARGO_WORKSPACE_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| {
#[derive(Deserialize)]
struct Manifest {
workspace_root: PathBuf,
}
let cargo = env::var_os("CARGO");
let cargo = cargo.as_deref().unwrap_or_else(|| OsStr::new("cargo"));
let output = process::Command::new(cargo)
.args(["metadata", "--format-version=1", "--no-deps"])
.current_dir(manifest_dir)
.output()
.unwrap();
let manifest: Manifest = serde_json::from_slice(&output.stdout).unwrap();
manifest.workspace_root
});
dirs.insert(
String::from(manifest_dir),
dir.clone().into_boxed_path().into(),
);
dir
}