blob: b3a75aa789c701e312d555bac253f43e183d7178 [file] [log] [blame] [edit]
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::error::Error;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::str;
use std::sync::{Arc, Mutex};
use once_cell::sync::Lazy;
use crate::env::{
force_pass, force_update_snapshots, get_cargo_workspace, get_output_behavior,
get_snapshot_update_behavior, memoize_snapshot_file, OutputBehavior, SnapshotUpdate,
};
use crate::output::{print_snapshot_diff_with_title, print_snapshot_summary_with_title};
use crate::settings::Settings;
use crate::snapshot::{MetaData, PendingInlineSnapshot, Snapshot, SnapshotContents};
use crate::utils::style;
static TEST_NAME_COUNTERS: Lazy<Mutex<BTreeMap<String, usize>>> =
Lazy::new(|| Mutex::new(BTreeMap::new()));
static TEST_NAME_CLASH_DETECTION: Lazy<Mutex<BTreeMap<String, bool>>> =
Lazy::new(|| Mutex::new(BTreeMap::new()));
// This macro is basically eprintln but without being captured and
// hidden by the test runner.
macro_rules! elog {
() => (write!(std::io::stderr()).ok());
($($arg:tt)*) => ({
writeln!(std::io::stderr(), $($arg)*).ok();
})
}
/// Special marker to use an automatic name.
///
/// This can be passed as a snapshot name in a macro to explicitly tell
/// insta to use the automatic name. This is useful in ambiguous syntax
/// situations.
#[derive(Debug)]
pub struct AutoName;
impl From<AutoName> for ReferenceValue<'static> {
fn from(_value: AutoName) -> ReferenceValue<'static> {
ReferenceValue::Named(None)
}
}
impl From<Option<String>> for ReferenceValue<'static> {
fn from(value: Option<String>) -> ReferenceValue<'static> {
ReferenceValue::Named(value.map(Cow::Owned))
}
}
impl From<String> for ReferenceValue<'static> {
fn from(value: String) -> ReferenceValue<'static> {
ReferenceValue::Named(Some(Cow::Owned(value)))
}
}
impl<'a> From<Option<&'a str>> for ReferenceValue<'a> {
fn from(value: Option<&'a str>) -> ReferenceValue<'a> {
ReferenceValue::Named(value.map(Cow::Borrowed))
}
}
impl<'a> From<&'a str> for ReferenceValue<'a> {
fn from(value: &'a str) -> ReferenceValue<'a> {
ReferenceValue::Named(Some(Cow::Borrowed(value)))
}
}
pub enum ReferenceValue<'a> {
Named(Option<Cow<'a, str>>),
Inline(&'a str),
}
fn detect_snapshot_name(function_name: &str, module_path: &str) -> Result<String, &'static str> {
let name = Cow::Borrowed(function_name);
// clean test name first
let mut name = name.rsplit("::").next().unwrap();
let mut test_prefixed = false;
if name.starts_with("test_") {
name = &name[5..];
test_prefixed = true;
}
// next check if we need to add a suffix
let name = add_suffix_to_snapshot_name(Cow::Borrowed(name));
let key = format!("{}::{}", module_path.replace("::", "__"), name);
// because fn foo and fn test_foo end up with the same snapshot name we
// make sure we detect this here and raise an error.
let mut name_clash_detection = TEST_NAME_CLASH_DETECTION
.lock()
.unwrap_or_else(|x| x.into_inner());
match name_clash_detection.get(&key) {
None => {
name_clash_detection.insert(key.clone(), test_prefixed);
}
Some(&was_test_prefixed) => {
if was_test_prefixed != test_prefixed {
panic!(
"Insta snapshot name clash detected between '{}' \
and 'test_{}' in '{}'. Rename one function.",
name, name, module_path
);
}
}
}
// if the snapshot name clashes we need to increment a counter.
// we really do not care about poisoning here.
let mut counters = TEST_NAME_COUNTERS.lock().unwrap_or_else(|x| x.into_inner());
let test_idx = counters.get(&key).cloned().unwrap_or(0) + 1;
let rv = if test_idx == 1 {
name.to_string()
} else {
format!("{}-{}", name, test_idx)
};
counters.insert(key, test_idx);
Ok(rv)
}
/// If there is a suffix on the settings, append it to the snapshot name.
fn add_suffix_to_snapshot_name(name: Cow<'_, str>) -> Cow<'_, str> {
Settings::with(|settings| {
settings
.snapshot_suffix()
.map(|suffix| Cow::Owned(format!("{}@{}", name, suffix)))
.unwrap_or_else(|| name)
})
}
fn get_snapshot_filename(
module_path: &str,
snapshot_name: &str,
cargo_workspace: &Path,
base: &str,
) -> PathBuf {
let root = Path::new(cargo_workspace);
let base = Path::new(base);
Settings::with(|settings| {
root.join(base.parent().unwrap())
.join(settings.snapshot_path())
.join({
use std::fmt::Write;
let mut f = String::new();
if settings.prepend_module_to_snapshot() {
write!(&mut f, "{}__", module_path.replace("::", "__")).unwrap();
}
write!(
&mut f,
"{}.snap",
snapshot_name.replace("/", "__").replace("\\", "__")
)
.unwrap();
f
})
})
}
#[derive(Debug)]
struct SnapshotAssertionContext<'a> {
cargo_workspace: Arc<PathBuf>,
module_path: &'a str,
snapshot_name: Option<Cow<'a, str>>,
snapshot_file: Option<PathBuf>,
old_snapshot: Option<Snapshot>,
pending_snapshots_path: Option<PathBuf>,
assertion_file: &'a str,
assertion_line: u32,
}
impl<'a> SnapshotAssertionContext<'a> {
fn prepare(
refval: ReferenceValue<'a>,
manifest_dir: &'a str,
function_name: &'a str,
module_path: &'a str,
assertion_file: &'a str,
assertion_line: u32,
) -> Result<SnapshotAssertionContext<'a>, Box<dyn Error>> {
let cargo_workspace = get_cargo_workspace(manifest_dir);
let snapshot_name;
let mut snapshot_file = None;
let mut old_snapshot = None;
let mut pending_snapshots_path = None;
match refval {
ReferenceValue::Named(name) => {
let name = match name {
Some(name) => add_suffix_to_snapshot_name(name),
None => detect_snapshot_name(function_name, module_path)
.unwrap()
.into(),
};
let file =
get_snapshot_filename(module_path, &name, &cargo_workspace, assertion_file);
if fs::metadata(&file).is_ok() {
old_snapshot = Some(Snapshot::from_file(&file)?);
}
snapshot_name = Some(name);
snapshot_file = Some(file);
}
ReferenceValue::Inline(contents) => {
snapshot_name = detect_snapshot_name(function_name, module_path)
.ok()
.map(Cow::Owned);
let mut pending_file = cargo_workspace.join(assertion_file);
pending_file.set_file_name(format!(
".{}.pending-snap",
pending_file
.file_name()
.expect("no filename")
.to_str()
.expect("non unicode filename")
));
pending_snapshots_path = Some(pending_file);
old_snapshot = Some(Snapshot::from_components(
module_path.replace("::", "__"),
None,
MetaData::default(),
SnapshotContents::from_inline(contents),
));
}
};
Ok(SnapshotAssertionContext {
cargo_workspace,
module_path,
snapshot_name,
snapshot_file,
old_snapshot,
pending_snapshots_path,
assertion_file,
assertion_line,
})
}
/// Given a path returns the local path within the workspace.
pub fn localize_path(&self, p: &Path) -> Option<PathBuf> {
self.cargo_workspace
.join(p)
.canonicalize()
.ok()
.and_then(|s| {
s.strip_prefix(self.cargo_workspace.as_path())
.ok()
.map(|x| x.to_path_buf())
})
}
/// Creates the new snapshot from input values.
pub fn new_snapshot(&self, contents: SnapshotContents, expr: &str) -> Snapshot {
Snapshot::from_components(
self.module_path.replace("::", "__"),
self.snapshot_name.as_ref().map(|x| x.to_string()),
MetaData::new(
self.assertion_file,
expr,
Some(self.assertion_line),
Settings::with(|s| s.input_file().and_then(|x| self.localize_path(x))),
),
contents,
)
}
/// Cleanup logic for passing snapshots.
pub fn cleanup_passing(&self) -> Result<(), Box<dyn Error>> {
// let's just make sure there are no more pending files lingering
// around.
if let Some(ref snapshot_file) = self.snapshot_file {
let mut snapshot_file = snapshot_file.clone();
snapshot_file.set_extension("snap.new");
fs::remove_file(snapshot_file).ok();
}
// and add a null pending snapshot to a pending snapshot file if needed
if let Some(ref pending_snapshots) = self.pending_snapshots_path {
if fs::metadata(pending_snapshots).is_ok() {
PendingInlineSnapshot::new(None, None, self.assertion_line)
.save(pending_snapshots)?;
}
}
Ok(())
}
/// Writes the changes of the snapshot back.
pub fn update_snapshot(
&self,
new_snapshot: Snapshot,
) -> Result<SnapshotUpdate, Box<dyn Error>> {
let unseen = self
.snapshot_file
.as_ref()
.map_or(false, |x| fs::metadata(x).is_ok());
let should_print = get_output_behavior() != OutputBehavior::Nothing;
let snapshot_update = get_snapshot_update_behavior(unseen);
match snapshot_update {
SnapshotUpdate::InPlace => {
if let Some(ref snapshot_file) = self.snapshot_file {
new_snapshot.save(snapshot_file)?;
if should_print {
elog!(
"{} {}",
if unseen {
style("created previously unseen snapshot").green()
} else {
style("updated snapshot").green()
},
style(snapshot_file.display()).cyan().underlined(),
);
}
} else if should_print {
elog!(
"{}",
style("error: cannot update inline snapshots in-place")
.red()
.bold(),
);
}
}
SnapshotUpdate::NewFile => {
if let Some(ref snapshot_file) = self.snapshot_file {
let mut new_path = snapshot_file.to_path_buf();
new_path.set_extension("snap.new");
new_snapshot.save_new(&new_path)?;
if should_print {
elog!(
"{} {}",
style("stored new snapshot").green(),
style(new_path.display()).cyan().underlined(),
);
}
} else {
PendingInlineSnapshot::new(
Some(new_snapshot),
self.old_snapshot.clone(),
self.assertion_line,
)
.save(self.pending_snapshots_path.as_ref().unwrap())?;
}
}
SnapshotUpdate::NoUpdate => {}
}
Ok(snapshot_update)
}
}
/// This prints the information about the snapshot
fn print_snapshot_info(ctx: &SnapshotAssertionContext, new_snapshot: &Snapshot) {
match get_output_behavior() {
OutputBehavior::Summary => {
print_snapshot_summary_with_title(
ctx.cargo_workspace.as_path(),
new_snapshot,
ctx.old_snapshot.as_ref(),
ctx.assertion_line,
ctx.snapshot_file.as_deref(),
);
}
OutputBehavior::Diff => {
print_snapshot_diff_with_title(
ctx.cargo_workspace.as_path(),
new_snapshot,
ctx.old_snapshot.as_ref(),
ctx.assertion_line,
ctx.snapshot_file.as_deref(),
);
}
_ => {}
}
}
/// Finalizes the assertion based on the update result.
fn finalize_assertion(ctx: &SnapshotAssertionContext, update_result: SnapshotUpdate) {
if update_result == SnapshotUpdate::NewFile && get_output_behavior() != OutputBehavior::Nothing
{
println!(
"{hint}",
hint = style("To update snapshots run `cargo insta review`").dim(),
);
}
if update_result != SnapshotUpdate::InPlace && !force_pass() {
panic!(
"snapshot assertion for '{}' failed in line {}",
ctx.snapshot_name
.as_ref()
.map_or("unnamed snapshot", |x| &*x),
ctx.assertion_line
);
}
}
/// This function is invoked from the macros to run the main assertion logic.
///
/// This will create the assertion context, run the main logic to assert
/// on snapshots and write changes to the pending snapshot files. It will
/// also print the necessary bits of information to the output and fail the
/// assertion with a panic if needed.
#[allow(clippy::too_many_arguments)]
pub fn assert_snapshot(
refval: ReferenceValue<'_>,
new_snapshot_value: &str,
manifest_dir: &str,
function_name: &str,
module_path: &str,
assertion_file: &str,
assertion_line: u32,
expr: &str,
) -> Result<(), Box<dyn Error>> {
let ctx = SnapshotAssertionContext::prepare(
refval,
manifest_dir,
function_name,
module_path,
assertion_file,
assertion_line,
)?;
let new_snapshot = ctx.new_snapshot(new_snapshot_value.into(), expr);
// memoize the snapshot file if requested.
if let Some(ref snapshot_file) = ctx.snapshot_file {
memoize_snapshot_file(snapshot_file);
}
// pass if the snapshots are missing
if ctx.old_snapshot.as_ref().map(|x| x.contents()) == Some(new_snapshot.contents()) {
ctx.cleanup_passing()?;
if force_update_snapshots() {
ctx.update_snapshot(new_snapshot)?;
}
// otherwise print information and update snapshots.
} else {
print_snapshot_info(&ctx, &new_snapshot);
let update_result = ctx.update_snapshot(new_snapshot)?;
finalize_assertion(&ctx, update_result);
}
Ok(())
}