Repository: mitsuhiko/insta
Pull request #347 (2023-02-14 11:07:11)

glob!: Add three-arg version to specify base_path
The new three-arg glob! allows users to specify the base directory the glob is working against. This works around the limitation that blobs like `../foo/*.json` do not resolve with the current walkdir + globset implementation.

This allows the usage of `glob!` in nested situations where the globbed files are in a parent directory:

```
src
├── main.rs
├── snapshots
│   └── insta_glob_test__insta_glob_dir.snap
├── submod
│   └── mod.rs -- glob! tests are here
└── test_data
    └── foo.json
```

```rust
glob!("../test_data", "*.json", |path| { ... })
```

the-kenny: Note that this implementation could provide a nicer interface where `glob!` or `glob_exec` would accept a glob like `../test_data/*.json`. However, implementation of this isn't as easy as I hoped as we would need to detect & split the path elements from the glob elements (i.E. `../test_data/*.json -> ("../test_data/", "*.json")`).

I can tackle this if you prefer this simpler interface.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index de4e5ae..1f1acd0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
 
 All notable changes to insta and cargo-insta are documented here.
 
+## 1.28.0
+
+- Added `allow_duplicates!` to enable multiple assertions for a
+  single snapshot. (#346)
+- Ensure that expressions formatted with `rustfmt` use unix newlines.
+- Added a way to disable diffing in review. (#348)
+
 ## 1.27.0
 
 - Fix an issue where the inline snapshot patcher could panic in
diff --git a/Cargo.toml b/Cargo.toml
index 31d9a1b..a67972b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -47,7 +47,7 @@ yaml = ["serde"]
 _cargo_insta_internal = []
 
 [dependencies]
-dep_csv = { package = "csv", version = "1.1.4", optional = true }
+dep_csv = { package = "csv", version = "=1.1.6", optional = true }
 console = { version = "0.15.4", optional = true, default-features = false }
 pest = { version = "2.1.3", optional = true }
 pest_derive = { version = "2.1.0", optional = true }
diff --git a/cargo-insta/src/cli.rs b/cargo-insta/src/cli.rs
index 8b64b54..81ae011 100644
--- a/cargo-insta/src/cli.rs
+++ b/cargo-insta/src/cli.rs
@@ -8,8 +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,
-    UnreferencedSnapshots,
+    is_ci, SnapshotPrinter, SnapshotUpdate, TestRunner, ToolConfig, UnreferencedSnapshots,
 };
 use serde::Serialize;
 use structopt::clap::AppSettings;
@@ -228,6 +227,7 @@ fn query_snapshot(
     n: usize,
     snapshot_file: Option<&Path>,
     show_info: &mut bool,
+    show_diff: &mut bool,
 ) -> Result<Operation, Box<dyn Error>> {
     loop {
         term.clear_screen()?;
@@ -246,7 +246,12 @@ 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);
+        printer.set_show_diff(*show_diff);
+        printer.print();
 
         println!();
         println!(
@@ -270,6 +275,12 @@ fn query_snapshot(
             if *show_info { "hide" } else { "show" },
             style("toggles extended snapshot info").dim()
         );
+        println!(
+            "  {} {} diff  {}",
+            style("d").cyan().bold(),
+            if *show_diff { "hide" } else { "show" },
+            style("toggle snapshot diff").dim()
+        );
 
         loop {
             match term.read_key()? {
@@ -280,6 +291,10 @@ fn query_snapshot(
                     *show_info = !*show_info;
                     break;
                 }
+                Key::Char('d') => {
+                    *show_diff = !*show_diff;
+                    break;
+                }
                 _ => {}
             }
         }
@@ -421,6 +436,7 @@ fn process_snapshots(
     let mut skipped = vec![];
     let mut num = 0;
     let mut show_info = true;
+    let mut show_diff = true;
 
     for (snapshot_container, package) in snapshot_containers.iter_mut() {
         let target_file = snapshot_container.target_file().to_path_buf();
@@ -453,6 +469,7 @@ fn process_snapshots(
                     snapshot_count,
                     snapshot_file.as_ref().map(|x| x.as_path()),
                     &mut show_info,
+                    &mut show_diff,
                 )?,
             };
             match op {
@@ -877,7 +894,11 @@ fn prepare_test_runner<'snapshot_ref>(
 fn show_cmd(cmd: ShowCommand) -> Result<(), Box<dyn Error>> {
     let loc = handle_target_args(&cmd.target_args)?;
     let snapshot = Snapshot::from_file(&cmd.path)?;
-    print_snapshot(&loc.workspace_root, &snapshot, Some(&cmd.path), None, true);
+    let mut printer = SnapshotPrinter::new(&loc.workspace_root, None, &snapshot);
+    printer.set_snapshot_file(Some(&cmd.path));
+    printer.set_show_info(true);
+    printer.set_show_diff(false);
+    printer.print();
     Ok(())
 }
 
diff --git a/src/glob.rs b/src/glob.rs
index dd0eb9c..c7b1723 100644
--- a/src/glob.rs
+++ b/src/glob.rs
@@ -39,6 +39,15 @@ lazy_static::lazy_static! {
 }
 
 pub fn glob_exec<F: FnMut(&Path)>(manifest_dir: &str, base: &Path, pattern: &str, mut f: F) {
+    // If settings.allow_empty_glob() == true and `base` doesn't exist, skip
+    // everything. This is necessary as `base` is user-controlled via `glob!/3`
+    // and may not exist.
+    let mut settings = Settings::clone_current();
+
+    if settings.allow_empty_glob() && !base.exists() {
+        return;
+    }
+
     let glob = GlobBuilder::new(pattern)
         .case_insensitive(true)
         .literal_separator(true)
@@ -48,7 +57,6 @@ pub fn glob_exec<F: FnMut(&Path)>(manifest_dir: &str, base: &Path, pattern: &str
 
     let walker = WalkDir::new(base).follow_links(true);
     let mut glob_found_matches = false;
-    let mut settings = Settings::clone_current();
 
     GLOB_STACK.lock().unwrap().push(GlobCollector {
         failed: 0,
diff --git a/src/lib.rs b/src/lib.rs
index 718c1db..afc2870 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::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 6d0c759..ec47690 100644
--- a/src/macros.rs
+++ b/src/macros.rs
@@ -538,19 +538,68 @@ macro_rules! with_settings {
 /// are deferred until the end of of it.  In other words this means that each snapshot
 /// assertion within the `glob!` block are reported.  It can be disabled by setting
 /// `INSTA_GLOB_FAIL_FAST` environment variable to `1`.
+///
+/// A three-argument version of this macro allows specifying a base directory
+/// for the glob to start in. This allows globbing in arbitrary directories,
+/// including parent directories:
+///
+/// ```
+/// # use insta::{assert_snapshot, glob, Settings};
+/// # let mut settings = Settings::clone_current();
+/// # settings.set_allow_empty_glob(true);
+/// # let _dropguard = settings.bind_to_scope();
+/// use std::fs;
+///
+/// glob!("../test_data", "inputs/*.txt", |path| {
+///     let input = fs::read_to_string(path).unwrap();
+///     assert_snapshot!(input.to_uppercase());
+/// });
+/// ```
 #[cfg(feature = "glob")]
 #[cfg_attr(docsrs, doc(cfg(feature = "glob")))]
 #[macro_export]
 macro_rules! glob {
-    ($glob:expr, $closure:expr) => {{
+    ($base_path:expr, $glob:expr, $closure:expr) => {{
+        use std::path::Path;
         let base = $crate::_macro_support::get_cargo_workspace(env!("CARGO_MANIFEST_DIR"))
-            .join(file!())
-            .parent()
-            .unwrap()
+            .join(Path::new(file!()).parent().unwrap())
+            .join($base_path)
             .to_path_buf();
+
         // we try to canonicalize but on some platforms (eg: wasm) that might not work, so
         // we instead silently fall back.
         let base = base.canonicalize().unwrap_or_else(|_| base);
         $crate::_macro_support::glob_exec(env!("CARGO_MANIFEST_DIR"), &base, $glob, $closure);
     }};
+
+    ($glob:expr, $closure:expr) => {{
+        insta::glob!(".", $glob, $closure)
+    }};
+}
+
+/// Utility macro to permit a multi-snapshot run where all snapshots match.
+///
+/// Within this block, insta will allow an assertion to be run more than once
+/// (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");
+///     }
+/// }
+/// ```
+///
+/// The first snapshot assertion will be used as a gold master and every further
+/// assertion will be checked against it.  If they don't match the assertion will
+/// fail.
+#[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 328cf95..9a074a1 100644
--- a/src/output.rs
+++ b/src/output.rs
@@ -7,6 +7,218 @@ 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<u32>,
+    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<u32>) {
+        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();
+        if self.show_diff {
+            self.print_changeset();
+        } else {
+            self.print_snapshot();
+        }
+    }
+
+    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_snapshot(&self) {
+        print_line(term_width());
+
+        let new_contents = self.new_snapshot.contents_str();
+
+        let width = term_width();
+        if self.show_info {
+            self.print_info();
+        }
+        println!("Snapshot Contents:");
+        println!("──────┬{:─^1$}", "", width.saturating_sub(13));
+        for (idx, line) in new_contents.lines().enumerate() {
+            println!("{:>5} │ {}", style(idx + 1).cyan().dim().bold(), line);
+        }
+        println!("──────┴{:─^1$}", "", width.saturating_sub(13),);
+    }
+
+    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,96 +266,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<u32>,
-    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(
-    workspace_root: &Path,
-    new: &Snapshot,
-    snapshot_file: Option<&Path>,
-    mut line: Option<u32>,
-    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 new_contents = new.contents_str();
-
-    let width = term_width();
-    if show_info {
-        print_info(new.metadata(), width);
-    }
-    println!("Snapshot Contents:");
-    println!("──────┬{:─^1$}", "", width.saturating_sub(13));
-    for (idx, line) in new_contents.lines().enumerate() {
-        println!("{:>5} │ {}", style(idx + 1).cyan().dim().bold(), line);
-    }
-    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 +338,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 15e2cb2..4efbc7c 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<Vec<BTreeMap<String, Snapshot>>> = 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<Cow<'a, str>>,
     snapshot_file: Option<PathBuf>,
+    duplication_key: Option<String>,
     old_snapshot: Option<Snapshot>,
     pending_snapshots_path: Option<PathBuf>,
     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,
         })
     }
@@ -434,31 +458,32 @@ fn prevent_inline_duplicate(function_name: &str, assertion_file: &str, assertion
     if set.contains(&key) {
         // drop the lock so we don't poison it
         drop(set);
-        panic!("Insta does not allow inline snapshot assertions in loops");
+        panic!(
+            "Insta does not allow inline snapshot assertions in loops. \
+            Wrap your assertions in allow_duplicates! to change this."
+        );
     }
     set.insert(key);
 }
 
 /// 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 +574,56 @@ fn finalize_assertion(ctx: &SnapshotAssertionContext, update_result: SnapshotUpd
     }
 }
 
+fn record_snapshot_duplicate(
+    results: &mut BTreeMap<String, Snapshot>,
+    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<R, F>(f: F) -> R
+where
+    F: FnOnce() -> R,
+{
+    RECORDED_DUPLICATES.with(|x| x.borrow_mut().push(BTreeMap::new()));
+    let rv = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
+    RECORDED_DUPLICATES.with(|x| x.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 +663,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/src/utils.rs b/src/utils.rs
index d4d5a0d..35a39da 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -100,7 +100,7 @@ pub fn format_rust_expression(value: &str) -> Cow<'_, str> {
                 let end = output.stdout.len() - SUFFIX.len();
                 return std::str::from_utf8(&output.stdout[start..end])
                     .unwrap()
-                    .to_owned()
+                    .replace("\r\n", "\n")
                     .into();
             }
         }


Test patch:
diff --git a/tests/glob_submodule/mod.rs b/tests/glob_submodule/mod.rs
new file mode 100644
index 0000000..984ac36
--- /dev/null
+++ b/tests/glob_submodule/mod.rs
@@ -0,0 +1,53 @@
+#![cfg(feature = "glob")]
+
+#[test]
+fn test_basic_globbing_parent_dir() {
+    insta::glob!("../inputs", "*.txt", |path| {
+        let contents = std::fs::read_to_string(path).unwrap();
+        insta::assert_json_snapshot!(&contents);
+    });
+}
+
+#[test]
+fn test_basic_globbing_nested_parent_dir_base_path() {
+    insta::glob!("../inputs-nested", "*/*.txt", |path| {
+        let contents = std::fs::read_to_string(path).unwrap();
+        insta::assert_snapshot!(&contents);
+    });
+}
+
+#[test]
+fn test_basic_globbing_nested_parent_glob() {
+    insta::glob!("..", "inputs-nested/*/*.txt", |path| {
+        let contents = std::fs::read_to_string(path).unwrap();
+        insta::assert_snapshot!(&contents);
+    });
+}
+
+#[test]
+fn test_globs_follow_links_parent_dir_base_path() {
+    insta::glob!("../link-to-inputs", "*.txt", |path| {
+        let contents = std::fs::read_to_string(path).unwrap();
+        insta::assert_json_snapshot!(&contents);
+    });
+}
+
+#[test]
+fn test_globs_follow_links_parent_dir_glob() {
+    insta::glob!("..", "link-to-inputs/*.txt", |path| {
+        let contents = std::fs::read_to_string(path).unwrap();
+        insta::assert_json_snapshot!(&contents);
+    });
+}
+
+#[test]
+fn test_basic_globbing_absolute_dir() {
+    insta::glob!(
+        concat!(env!("CARGO_MANIFEST_DIR"), "/tests/inputs"),
+        "*.txt",
+        |path| {
+            let contents = std::fs::read_to_string(path).unwrap();
+            insta::assert_json_snapshot!(&contents);
+        }
+    );
+}
diff --git a/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_absolute_dir@goodbye.txt.snap b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_absolute_dir@goodbye.txt.snap
new file mode 100644
index 0000000..a7d917c
--- /dev/null
+++ b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_absolute_dir@goodbye.txt.snap
@@ -0,0 +1,6 @@
+---
+source: tests/glob_submodule/mod.rs
+expression: "&contents"
+input_file: tests/inputs/goodbye.txt
+---
+"Contents of goodbye"
diff --git a/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_absolute_dir@hello.txt.snap b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_absolute_dir@hello.txt.snap
new file mode 100644
index 0000000..f4c2da6
--- /dev/null
+++ b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_absolute_dir@hello.txt.snap
@@ -0,0 +1,6 @@
+---
+source: tests/glob_submodule/mod.rs
+expression: "&contents"
+input_file: tests/inputs/hello.txt
+---
+"Contents of hello"
diff --git a/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_nested_parent_dir_base_path@a__file.txt.snap b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_nested_parent_dir_base_path@a__file.txt.snap
new file mode 100644
index 0000000..43e4ce8
--- /dev/null
+++ b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_nested_parent_dir_base_path@a__file.txt.snap
@@ -0,0 +1,7 @@
+---
+source: tests/glob_submodule/mod.rs
+expression: "&contents"
+input_file: tests/inputs-nested/a/file.txt
+---
+Hello A
+
diff --git a/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_nested_parent_dir_base_path@b__file.txt.snap b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_nested_parent_dir_base_path@b__file.txt.snap
new file mode 100644
index 0000000..9208fa1
--- /dev/null
+++ b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_nested_parent_dir_base_path@b__file.txt.snap
@@ -0,0 +1,7 @@
+---
+source: tests/glob_submodule/mod.rs
+expression: "&contents"
+input_file: tests/inputs-nested/b/file.txt
+---
+Hello B
+
diff --git a/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_nested_parent_glob@a__file.txt.snap b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_nested_parent_glob@a__file.txt.snap
new file mode 100644
index 0000000..43e4ce8
--- /dev/null
+++ b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_nested_parent_glob@a__file.txt.snap
@@ -0,0 +1,7 @@
+---
+source: tests/glob_submodule/mod.rs
+expression: "&contents"
+input_file: tests/inputs-nested/a/file.txt
+---
+Hello A
+
diff --git a/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_nested_parent_glob@b__file.txt.snap b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_nested_parent_glob@b__file.txt.snap
new file mode 100644
index 0000000..9208fa1
--- /dev/null
+++ b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_nested_parent_glob@b__file.txt.snap
@@ -0,0 +1,7 @@
+---
+source: tests/glob_submodule/mod.rs
+expression: "&contents"
+input_file: tests/inputs-nested/b/file.txt
+---
+Hello B
+
diff --git a/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_parent_dir@goodbye.txt.snap b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_parent_dir@goodbye.txt.snap
new file mode 100644
index 0000000..a7d917c
--- /dev/null
+++ b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_parent_dir@goodbye.txt.snap
@@ -0,0 +1,6 @@
+---
+source: tests/glob_submodule/mod.rs
+expression: "&contents"
+input_file: tests/inputs/goodbye.txt
+---
+"Contents of goodbye"
diff --git a/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_parent_dir@hello.txt.snap b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_parent_dir@hello.txt.snap
new file mode 100644
index 0000000..f4c2da6
--- /dev/null
+++ b/tests/glob_submodule/snapshots/test_glob__glob_submodule__basic_globbing_parent_dir@hello.txt.snap
@@ -0,0 +1,6 @@
+---
+source: tests/glob_submodule/mod.rs
+expression: "&contents"
+input_file: tests/inputs/hello.txt
+---
+"Contents of hello"
diff --git a/tests/glob_submodule/snapshots/test_glob__glob_submodule__globs_follow_links_parent_dir_base_path@goodbye.txt.snap b/tests/glob_submodule/snapshots/test_glob__glob_submodule__globs_follow_links_parent_dir_base_path@goodbye.txt.snap
new file mode 100644
index 0000000..a7d917c
--- /dev/null
+++ b/tests/glob_submodule/snapshots/test_glob__glob_submodule__globs_follow_links_parent_dir_base_path@goodbye.txt.snap
@@ -0,0 +1,6 @@
+---
+source: tests/glob_submodule/mod.rs
+expression: "&contents"
+input_file: tests/inputs/goodbye.txt
+---
+"Contents of goodbye"
diff --git a/tests/glob_submodule/snapshots/test_glob__glob_submodule__globs_follow_links_parent_dir_base_path@hello.txt.snap b/tests/glob_submodule/snapshots/test_glob__glob_submodule__globs_follow_links_parent_dir_base_path@hello.txt.snap
new file mode 100644
index 0000000..f4c2da6
--- /dev/null
+++ b/tests/glob_submodule/snapshots/test_glob__glob_submodule__globs_follow_links_parent_dir_base_path@hello.txt.snap
@@ -0,0 +1,6 @@
+---
+source: tests/glob_submodule/mod.rs
+expression: "&contents"
+input_file: tests/inputs/hello.txt
+---
+"Contents of hello"
diff --git a/tests/glob_submodule/snapshots/test_glob__glob_submodule__globs_follow_links_parent_dir_glob@goodbye.txt.snap b/tests/glob_submodule/snapshots/test_glob__glob_submodule__globs_follow_links_parent_dir_glob@goodbye.txt.snap
new file mode 100644
index 0000000..a7d917c
--- /dev/null
+++ b/tests/glob_submodule/snapshots/test_glob__glob_submodule__globs_follow_links_parent_dir_glob@goodbye.txt.snap
@@ -0,0 +1,6 @@
+---
+source: tests/glob_submodule/mod.rs
+expression: "&contents"
+input_file: tests/inputs/goodbye.txt
+---
+"Contents of goodbye"
diff --git a/tests/glob_submodule/snapshots/test_glob__glob_submodule__globs_follow_links_parent_dir_glob@hello.txt.snap b/tests/glob_submodule/snapshots/test_glob__glob_submodule__globs_follow_links_parent_dir_glob@hello.txt.snap
new file mode 100644
index 0000000..f4c2da6
--- /dev/null
+++ b/tests/glob_submodule/snapshots/test_glob__glob_submodule__globs_follow_links_parent_dir_glob@hello.txt.snap
@@ -0,0 +1,6 @@
+---
+source: tests/glob_submodule/mod.rs
+expression: "&contents"
+input_file: tests/inputs/hello.txt
+---
+"Contents of hello"
diff --git a/tests/test_allow_duplicates.rs b/tests/test_allow_duplicates.rs
new file mode 100644
index 0000000..3a862de
--- /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");
+        }
+    }
+}
diff --git a/tests/test_glob.rs b/tests/test_glob.rs
index 512601e..2585a99 100644
--- a/tests/test_glob.rs
+++ b/tests/test_glob.rs
@@ -1,5 +1,7 @@
 #![cfg(feature = "glob")]
 
+mod glob_submodule;
+
 #[test]
 fn test_basic_globbing() {
     insta::glob!("inputs/*.txt", |path| {