diff --git a/cargo-insta/src/cli.rs b/cargo-insta/src/cli.rs index 8b64b544..9f1e54d8 100644 --- a/cargo-insta/src/cli.rs +++ b/cargo-insta/src/cli.rs @@ -8,7 +8,7 @@ use std::{io, process}; use console::{set_colors_enabled, style, Key, Term}; use insta::Snapshot; use insta::_cargo_insta_support::{ - is_ci, print_snapshot, print_snapshot_diff, SnapshotUpdate, TestRunner, ToolConfig, + is_ci, print_snapshot, SnapshotPrinter, SnapshotUpdate, TestRunner, ToolConfig, UnreferencedSnapshots, }; use serde::Serialize; @@ -246,7 +246,10 @@ fn query_snapshot( pkg_version, ); - print_snapshot_diff(workspace_root, new, old, snapshot_file, line, *show_info); + let mut printer = SnapshotPrinter::new(workspace_root, old, new); + printer.set_snapshot_file(snapshot_file); + printer.set_line(line); + printer.set_show_info(*show_info); println!(); println!( diff --git a/src/lib.rs b/src/lib.rs index 718c1dbd..5f58156d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -295,7 +295,7 @@ pub mod _cargo_insta_support { Error as ToolConfigError, OutputBehavior, SnapshotUpdate, TestRunner, ToolConfig, UnreferencedSnapshots, }, - output::{print_snapshot, print_snapshot_diff}, + output::{print_snapshot, SnapshotPrinter}, snapshot::PendingInlineSnapshot, snapshot::SnapshotContents, utils::is_ci, @@ -311,7 +311,7 @@ pub use crate::redaction::{dynamic_redaction, sorted_redaction}; pub mod _macro_support { pub use crate::content::Content; pub use crate::env::get_cargo_workspace; - pub use crate::runtime::{assert_snapshot, AutoName, ReferenceValue}; + pub use crate::runtime::{assert_snapshot, with_allow_duplicates, AutoName, ReferenceValue}; #[cfg(feature = "serde")] pub use crate::serialization::{serialize_value, SerializationFormat, SnapshotLocation}; diff --git a/src/macros.rs b/src/macros.rs index 6d0c7593..006a81b1 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -554,3 +554,26 @@ macro_rules! glob { $crate::_macro_support::glob_exec(env!("CARGO_MANIFEST_DIR"), &base, $glob, $closure); }}; } + +/// Utility macro to create a multi-snapshot run where all snapshots match. +/// +/// Within this block, insta will allow an assertion to be run twice (even inline) without +/// generating another snapshot. Instead it will assert that snapshot expressions visited +/// more than once are matching. +/// +/// ```rust +/// insta::allow_duplicates! { +/// for x in (0..10).step_by(2) { +/// let is_even = x % 2 == 0; +/// insta::assert_debug_snapshot!(is_even, @"true"); +/// } +/// } +/// ``` +#[macro_export] +macro_rules! allow_duplicates { + ($($x:tt)*) => { + $crate::_macro_support::with_allow_duplicates(|| { + $($x)* + }) + } +} diff --git a/src/output.rs b/src/output.rs index 328cf954..65ab9774 100644 --- a/src/output.rs +++ b/src/output.rs @@ -7,6 +7,197 @@ use crate::content::yaml; use crate::snapshot::{MetaData, Snapshot}; use crate::utils::{format_rust_expression, style, term_width}; +/// Snapshot printer utility. +pub struct SnapshotPrinter<'a> { + workspace_root: &'a Path, + old_snapshot: Option<&'a Snapshot>, + new_snapshot: &'a Snapshot, + old_snapshot_hint: &'a str, + new_snapshot_hint: &'a str, + show_info: bool, + show_diff: bool, + title: Option<&'a str>, + line: Option, + snapshot_file: Option<&'a Path>, +} + +impl<'a> SnapshotPrinter<'a> { + pub fn new( + workspace_root: &'a Path, + old_snapshot: Option<&'a Snapshot>, + new_snapshot: &'a Snapshot, + ) -> SnapshotPrinter<'a> { + SnapshotPrinter { + workspace_root, + old_snapshot, + new_snapshot, + old_snapshot_hint: "old snapshot", + new_snapshot_hint: "new results", + show_info: false, + show_diff: false, + title: None, + line: None, + snapshot_file: None, + } + } + + pub fn set_snapshot_hints(&mut self, old: &'a str, new: &'a str) { + self.old_snapshot_hint = old; + self.new_snapshot_hint = new; + } + + pub fn set_show_info(&mut self, yes: bool) { + self.show_info = yes; + } + + pub fn set_show_diff(&mut self, yes: bool) { + self.show_diff = yes; + } + + pub fn set_title(&mut self, title: Option<&'a str>) { + self.title = title; + } + + pub fn set_line(&mut self, line: Option) { + self.line = line; + } + + pub fn set_snapshot_file(&mut self, file: Option<&'a Path>) { + self.snapshot_file = file; + } + + pub fn print(&self) { + if let Some(title) = self.title { + let width = term_width(); + println!( + "{title:━^width$}", + title = style(format!(" {} ", title)).bold(), + width = width + ); + } + self.print_snapshot_diff(); + } + + fn print_snapshot_diff(&self) { + self.print_snapshot_summary(); + self.print_changeset(); + } + + fn print_snapshot_summary(&self) { + print_snapshot_summary( + self.workspace_root, + self.new_snapshot, + self.snapshot_file, + self.line, + ); + } + + fn print_info(&self) { + print_info(self.new_snapshot.metadata()); + } + + fn print_changeset(&self) { + let old = self.old_snapshot.as_ref().map_or("", |x| x.contents_str()); + let new = self.new_snapshot.contents_str(); + let newlines_matter = newlines_matter(old, new); + + let width = term_width(); + let diff = TextDiff::configure() + .algorithm(Algorithm::Patience) + .timeout(Duration::from_millis(500)) + .diff_lines(old, new); + print_line(width); + + if self.show_info { + self.print_info(); + } + + if !old.is_empty() { + println!( + "{}", + style(format_args!("-{}", self.old_snapshot_hint)).red() + ); + } + println!( + "{}", + style(format_args!("+{}", self.new_snapshot_hint)).green() + ); + + println!("────────────┬{:─^1$}", "", width.saturating_sub(13)); + let mut has_changes = false; + for (idx, group) in diff.grouped_ops(4).iter().enumerate() { + if idx > 0 { + println!("┈┈┈┈┈┈┈┈┈┈┈┈┼{:┈^1$}", "", width.saturating_sub(13)); + } + for op in group { + for change in diff.iter_inline_changes(op) { + match change.tag() { + ChangeTag::Insert => { + has_changes = true; + print!( + "{:>5} {:>5} │{}", + "", + style(change.new_index().unwrap()).cyan().dim().bold(), + style("+").green(), + ); + for &(emphasized, change) in change.values() { + let change = render_invisible(change, newlines_matter); + if emphasized { + print!("{}", style(change).green().underlined()); + } else { + print!("{}", style(change).green()); + } + } + } + ChangeTag::Delete => { + has_changes = true; + print!( + "{:>5} {:>5} │{}", + style(change.old_index().unwrap()).cyan().dim(), + "", + style("-").red(), + ); + for &(emphasized, change) in change.values() { + let change = render_invisible(change, newlines_matter); + if emphasized { + print!("{}", style(change).red().underlined()); + } else { + print!("{}", style(change).red()); + } + } + } + ChangeTag::Equal => { + print!( + "{:>5} {:>5} │ ", + style(change.old_index().unwrap()).cyan().dim(), + style(change.new_index().unwrap()).cyan().dim().bold(), + ); + for &(_, change) in change.values() { + let change = render_invisible(change, newlines_matter); + print!("{}", style(change).dim()); + } + } + } + if change.missing_newline() { + println!(); + } + } + } + } + + if !has_changes { + println!( + "{:>5} {:>5} │{}", + "", + style("-").dim(), + style(" snapshots are matching").cyan(), + ); + } + + println!("────────────┴{:─^1$}", "", width.saturating_sub(13),); + } +} + /// Prints the summary of a snapshot pub fn print_snapshot_summary( workspace_root: &Path, @@ -54,26 +245,6 @@ pub fn print_snapshot_summary( } } -/// Prints a diff against an old snapshot. -pub fn print_snapshot_diff( - workspace_root: &Path, - new: &Snapshot, - old_snapshot: Option<&Snapshot>, - snapshot_file: Option<&Path>, - mut line: Option, - show_info: bool, -) { - // default to old assertion line from snapshot. - if line.is_none() { - line = new.metadata().assertion_line(); - } - - print_snapshot_summary(workspace_root, new, snapshot_file, line); - let old_contents = old_snapshot.as_ref().map_or("", |x| x.contents_str()); - let new_contents = new.contents_str(); - print_changeset(old_contents, new_contents, new.metadata(), show_info); -} - /// Prints the snapshot not as diff. #[cfg(feature = "_cargo_insta_internal")] pub fn print_snapshot( @@ -93,7 +264,7 @@ pub fn print_snapshot( let width = term_width(); if show_info { - print_info(new.metadata(), width); + print_info(new.metadata()); } println!("Snapshot Contents:"); println!("──────┬{:─^1$}", "", width.saturating_sub(13)); @@ -103,47 +274,6 @@ pub fn print_snapshot( println!("──────┴{:─^1$}", "", width.saturating_sub(13),); } -pub fn print_snapshot_diff_with_title( - workspace_root: &Path, - new_snapshot: &Snapshot, - old_snapshot: Option<&Snapshot>, - line: u32, - snapshot_file: Option<&Path>, -) { - let width = term_width(); - println!( - "{title:━^width$}", - title = style(" Snapshot Differences ").bold(), - width = width - ); - print_snapshot_diff( - workspace_root, - new_snapshot, - old_snapshot, - snapshot_file, - Some(line), - true, - ); -} - -pub fn print_snapshot_summary_with_title( - workspace_root: &Path, - new_snapshot: &Snapshot, - old_snapshot: Option<&Snapshot>, - line: u32, - snapshot_file: Option<&Path>, -) { - let _old_snapshot = old_snapshot; - let width = term_width(); - println!( - "{title:━^width$}", - title = style(" Snapshot Summary ").bold(), - width = width - ); - print_snapshot_summary(workspace_root, new_snapshot, snapshot_file, Some(line)); - println!("{title:━^width$}", title = "", width = width); -} - fn print_line(width: usize) { println!("{:─^1$}", "", width); } @@ -216,100 +346,8 @@ fn render_invisible(s: &str, newlines_matter: bool) -> Cow<'_, str> { } } -pub fn print_changeset(old: &str, new: &str, metadata: &MetaData, show_info: bool) { - let newlines_matter = newlines_matter(old, new); - +fn print_info(metadata: &MetaData) { let width = term_width(); - let diff = TextDiff::configure() - .algorithm(Algorithm::Patience) - .timeout(Duration::from_millis(500)) - .diff_lines(old, new); - print_line(width); - - if show_info { - print_info(metadata, width); - } - - if !old.is_empty() { - println!("{}", style("-old snapshot").red()); - } - println!("{}", style("+new results").green()); - - println!("────────────┬{:─^1$}", "", width.saturating_sub(13)); - let mut has_changes = false; - for (idx, group) in diff.grouped_ops(4).iter().enumerate() { - if idx > 0 { - println!("┈┈┈┈┈┈┈┈┈┈┈┈┼{:┈^1$}", "", width.saturating_sub(13)); - } - for op in group { - for change in diff.iter_inline_changes(op) { - match change.tag() { - ChangeTag::Insert => { - has_changes = true; - print!( - "{:>5} {:>5} │{}", - "", - style(change.new_index().unwrap()).cyan().dim().bold(), - style("+").green(), - ); - for &(emphasized, change) in change.values() { - let change = render_invisible(change, newlines_matter); - if emphasized { - print!("{}", style(change).green().underlined()); - } else { - print!("{}", style(change).green()); - } - } - } - ChangeTag::Delete => { - has_changes = true; - print!( - "{:>5} {:>5} │{}", - style(change.old_index().unwrap()).cyan().dim(), - "", - style("-").red(), - ); - for &(emphasized, change) in change.values() { - let change = render_invisible(change, newlines_matter); - if emphasized { - print!("{}", style(change).red().underlined()); - } else { - print!("{}", style(change).red()); - } - } - } - ChangeTag::Equal => { - print!( - "{:>5} {:>5} │ ", - style(change.old_index().unwrap()).cyan().dim(), - style(change.new_index().unwrap()).cyan().dim().bold(), - ); - for &(_, change) in change.values() { - let change = render_invisible(change, newlines_matter); - print!("{}", style(change).dim()); - } - } - } - if change.missing_newline() { - println!(); - } - } - } - } - - if !has_changes { - println!( - "{:>5} {:>5} │{}", - "", - style("-").dim(), - style(" snapshots are matching").cyan(), - ); - } - - println!("────────────┴{:─^1$}", "", width.saturating_sub(13),); -} - -fn print_info(metadata: &MetaData, width: usize) { if let Some(expr) = metadata.expression() { println!("Expression: {}", style(format_rust_expression(expr))); print_line(width); diff --git a/src/runtime.rs b/src/runtime.rs index 15e2cb2f..e7e8cff6 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet}; use std::error::Error; use std::fs; @@ -11,7 +12,7 @@ use crate::env::{ get_cargo_workspace, get_tool_config, memoize_snapshot_file, snapshot_update_behavior, OutputBehavior, SnapshotUpdateBehavior, ToolConfig, }; -use crate::output::{print_snapshot_diff_with_title, print_snapshot_summary_with_title}; +use crate::output::SnapshotPrinter; use crate::settings::Settings; use crate::snapshot::{MetaData, PendingInlineSnapshot, Snapshot, SnapshotContents}; use crate::utils::{path_to_storage, style}; @@ -25,6 +26,10 @@ lazy_static::lazy_static! { Mutex::new(BTreeSet::new()); } +thread_local! { + static RECORDED_DUPLICATES: RefCell>> = RefCell::default() +} + // This macro is basically eprintln but without being captured and // hidden by the test runner. macro_rules! elog { @@ -126,6 +131,12 @@ fn detect_snapshot_name( } } + // The rest of the code just deals with duplicates, which we in some + // cases do not want to guard against. + if allow_duplicates() { + return Ok(name.to_string()); + } + // 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()); @@ -200,6 +211,7 @@ struct SnapshotAssertionContext<'a> { module_path: &'a str, snapshot_name: Option>, snapshot_file: Option, + duplication_key: Option, old_snapshot: Option, pending_snapshots_path: Option, assertion_file: &'a str, @@ -219,6 +231,7 @@ impl<'a> SnapshotAssertionContext<'a> { let tool_config = get_tool_config(manifest_dir); let cargo_workspace = get_cargo_workspace(manifest_dir); let snapshot_name; + let mut duplication_key = None; let mut snapshot_file = None; let mut old_snapshot = None; let mut pending_snapshots_path = None; @@ -232,6 +245,9 @@ impl<'a> SnapshotAssertionContext<'a> { .unwrap() .into(), }; + if allow_duplicates() { + duplication_key = Some(format!("named:{}|{}", module_path, name)); + } let file = get_snapshot_filename( module_path, assertion_file, @@ -247,7 +263,14 @@ impl<'a> SnapshotAssertionContext<'a> { snapshot_file = Some(file); } ReferenceValue::Inline(contents) => { - prevent_inline_duplicate(function_name, assertion_file, assertion_line); + if allow_duplicates() { + duplication_key = Some(format!( + "inline:{}|{}|{}", + function_name, assertion_file, assertion_line + )); + } else { + prevent_inline_duplicate(function_name, assertion_file, assertion_line); + } snapshot_name = detect_snapshot_name(function_name, module_path, true, is_doctest) .ok() .map(Cow::Owned); @@ -280,6 +303,7 @@ impl<'a> SnapshotAssertionContext<'a> { pending_snapshots_path, assertion_file, assertion_line, + duplication_key, is_doctest, }) } @@ -441,24 +465,22 @@ fn prevent_inline_duplicate(function_name: &str, assertion_file: &str, assertion /// This prints the information about the snapshot fn print_snapshot_info(ctx: &SnapshotAssertionContext, new_snapshot: &Snapshot) { + let mut printer = SnapshotPrinter::new( + ctx.cargo_workspace.as_path(), + ctx.old_snapshot.as_ref(), + new_snapshot, + ); + printer.set_line(Some(ctx.assertion_line)); + printer.set_snapshot_file(ctx.snapshot_file.as_deref()); + printer.set_title(Some("Snapshot Summary")); + printer.set_show_info(true); match ctx.tool_config.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(), - ); + printer.print(); } 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(), - ); + printer.set_show_diff(true); + printer.print(); } _ => {} } @@ -549,6 +571,61 @@ fn finalize_assertion(ctx: &SnapshotAssertionContext, update_result: SnapshotUpd } } +fn record_snapshot_duplicate( + results: &mut BTreeMap, + snapshot: &Snapshot, + ctx: &SnapshotAssertionContext, +) { + let key = ctx.duplication_key.as_deref().unwrap(); + if let Some(prev_snapshot) = results.get(key) { + if prev_snapshot.contents() != snapshot.contents() { + println!("Snapshots in allow-duplicates block do not match."); + let mut printer = + SnapshotPrinter::new(ctx.cargo_workspace.as_path(), Some(prev_snapshot), snapshot); + printer.set_line(Some(ctx.assertion_line)); + printer.set_snapshot_file(ctx.snapshot_file.as_deref()); + printer.set_title(Some("Differences in Block")); + printer.set_snapshot_hints("previous assertion", "current assertion"); + if ctx.tool_config.output_behavior() == OutputBehavior::Diff { + printer.set_show_diff(true); + } + printer.print(); + panic!( + "snapshot assertion for '{}' failed in line {}. Result \ + does not match previous snapshot in allow-duplicates block.", + ctx.snapshot_name.as_deref().unwrap_or("unnamed snapshot"), + ctx.assertion_line + ); + } + } else { + results.insert(key.to_string(), snapshot.clone()); + } +} + +/// Do we allow recording of duplicates? +fn allow_duplicates() -> bool { + RECORDED_DUPLICATES.with(|x| !x.borrow().is_empty()) +} + +/// Helper function to support perfect duplicate detection. +pub fn with_allow_duplicates(f: F) -> R +where + F: FnOnce() -> R, +{ + RECORDED_DUPLICATES.with(|cell| { + cell.borrow_mut().push(BTreeMap::new()); + }); + let rv = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + RECORDED_DUPLICATES.with(|cell| cell.borrow_mut().pop().unwrap()); + + match rv { + Ok(rv) => rv, + Err(payload) => { + std::panic::resume_unwind(payload); + } + } +} + /// 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 @@ -588,6 +665,15 @@ pub fn assert_snapshot( memoize_snapshot_file(snapshot_file); } + // If we allow assertion with duplicates, we record the duplicate now. This will + // in itself fail the assertion if the previous visit of the same assertion macro + // did not yield the same result. + RECORDED_DUPLICATES.with(|x| { + if let Some(results) = x.borrow_mut().last_mut() { + record_snapshot_duplicate(results, &new_snapshot, &ctx); + } + }); + // pass if the snapshots are missing if ctx.old_snapshot.as_ref().map(|x| x.contents()) == Some(new_snapshot.contents()) { ctx.cleanup_passing()?; diff --git a/tests/test_allow_duplicates.rs b/tests/test_allow_duplicates.rs new file mode 100644 index 00000000..3a862de6 --- /dev/null +++ b/tests/test_allow_duplicates.rs @@ -0,0 +1,22 @@ +use insta::{allow_duplicates, assert_debug_snapshot}; + +#[test] +fn test_basic_duplicates_passes() { + allow_duplicates! { + for x in (0..10).step_by(2) { + let is_even = x % 2 == 0; + assert_debug_snapshot!(is_even, @"true"); + } + } +} + +#[test] +#[should_panic = "snapshot assertion for 'basic_duplicates_assertion_failed' failed in line"] +fn test_basic_duplicates_assertion_failed() { + allow_duplicates! { + for x in (0..10).step_by(3) { + let is_even = x % 2 == 0; + assert_debug_snapshot!(is_even, @"true"); + } + } +}