Repository: mitsuhiko/insta
Pull request #112 (2020-04-13 14:40:29)

Add glob support
This is another attempt of implementing globbing. Replaces #109

The motivation here is that the glob support is just a thin wrapper around an expanded snapshot naming system. You can now set suffixes for snapshot names and reference files for the meta data independent of the `glob!` macro so users can use something other than globbing if wanted.


Patch:
diff --git a/Cargo.toml b/Cargo.toml
index c2e9fca..82cacc7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -26,6 +26,9 @@ default = []
 # snapshots.
 redactions = ["pest", "pest_derive"]
 
+# Glob support
+glob = ["globwalk"]
+
 # This feature is now just always enabled because we use yaml internally now.
 serialization = []
 
@@ -40,3 +43,4 @@ pest = { version = "2.1.0", optional = true }
 pest_derive = { version = "2.1.0", optional = true }
 ron = { version = "0.5.1", optional = true }
 backtrace = { version = "0.3.42", optional = true }
+globwalk = { version = "0.8.0", optional = true }
diff --git a/README.md b/README.md
index 432f12c..5d77756 100644
--- a/README.md
+++ b/README.md
@@ -270,6 +270,31 @@ assert_yaml_snapshot!(&User {
 });
 ```
 
+## Globbing
+
+**Feature:** `glob`
+
+Sometimes it can be useful to run code against multiple input files.
+The easiest way to accomplish this is to use the `glob!` macro which
+runs a closure for each input file that matches.  Before the closure
+is executed the settings are updated to set a reference to the input
+file and the appropriate snapshot suffix.
+
+Example:
+
+```rust
+use std::fs;
+
+glob!("inputs/*.txt", |path| {
+    let input = fs::read_to_string(path).unwrap();
+    assert_json_snapshot!(input.to_uppercase());
+});
+```
+
+The path to the glob macro is relative to the location of the test
+file.  It uses the [`globset`](https://crates.io/crates/globset) crate
+for actual glob operations.
+
 ## Inline Snapshots
 
 Additionally snapshots can also be stored inline.  In that case the format
@@ -300,6 +325,7 @@ The following features exist:
 
 * `ron`: enables RON support (`assert_ron_snapshot!`)
 * `redactions`: enables support for redactions
+* `glob`: enables support for globbing (`glob!`)
 
 ## Settings
 
diff --git a/src/glob.rs b/src/glob.rs
new file mode 100644
index 0000000..8c330c4
--- /dev/null
+++ b/src/glob.rs
@@ -0,0 +1,26 @@
+use std::path::Path;
+
+use globwalk::{FileType, GlobWalkerBuilder};
+
+use crate::settings::Settings;
+
+pub fn glob_exec<F: FnMut(&Path)>(base: &Path, pattern: &str, mut f: F) {
+    let walker = GlobWalkerBuilder::new(base, pattern)
+        .case_insensitive(true)
+        .file_type(FileType::FILE)
+        .build()
+        .unwrap();
+
+    for file in walker {
+        let file = file.unwrap();
+        let path = file.path();
+
+        let mut settings = Settings::clone_current();
+        settings.set_input_file(&path);
+        settings.set_snapshot_suffix(path.file_name().unwrap().to_str().unwrap());
+
+        settings.bind(|| {
+            f(path);
+        });
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 1278f5e..70e3fd1 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -199,7 +199,7 @@
 //!
 //! * `diff` (default): prints the diffs
 //! * `summary`: prints only summaries (name of snapshot files etc.)
-//! * `mimimal`: like `summary` but more minimal
+//! * `minimal`: like `summary` but more minimal
 //! * `none`: insta will not output any extra information
 //!
 //! # Redactions
@@ -273,6 +273,31 @@
 //! });
 //! ```
 //!
+//! # Globbing
+//!
+//! **Feature:** `glob`
+//!
+//! Sometimes it can be useful to run code against multiple input files.
+//! The easiest way to accomplish this is to use the `glob!` macro which
+//! runs a closure for each input file that matches.  Before the closure
+//! is executed the settings are updated to set a reference to the input
+//! file and the appropriate snapshot suffix.
+//!
+//! Example:
+//!
+//! ```rust,ignore
+//! use std::fs;
+//!
+//! glob!("inputs/*.txt", |path| {
+//!     let input = fs::read_to_string(path).unwrap();
+//!     assert_json_snapshot!(input.to_uppercase());
+//! });
+//! ```
+//!
+//! The path to the glob macro is relative to the location of the test
+//! file.  It uses the [`globset`](https://crates.io/crates/globset) crate
+//! for actual glob operations.
+//!
 //! # Inline Snapshots
 //!
 //! Additionally snapshots can also be stored inline.  In that case the format
@@ -303,6 +328,7 @@
 //!
 //! * `ron`: enables RON support (`assert_ron_snapshot!`)
 //! * `redactions`: enables support for redactions
+//! * `glob`: enables support for globbing (`glob!`)
 //!
 //! # Settings
 //!
@@ -334,6 +360,9 @@ mod legacy_macros;
 #[cfg(feature = "redactions")]
 mod redaction;
 
+#[cfg(feature = "glob")]
+mod glob;
+
 #[cfg(test)]
 mod test;
 
@@ -372,6 +401,9 @@ pub mod _macro_support {
     pub use crate::runtime::{assert_snapshot, AutoName, ReferenceValue};
     pub use crate::serialization::{serialize_value, SerializationFormat, SnapshotLocation};
 
+    #[cfg(feature = "glob")]
+    pub use crate::glob::glob_exec;
+
     #[cfg(feature = "redactions")]
     pub use crate::{
         redaction::Redaction, redaction::Selector, serialization::serialize_value_redacted,
diff --git a/src/macros.rs b/src/macros.rs
index 6194c54..ef252de 100644
--- a/src/macros.rs
+++ b/src/macros.rs
@@ -333,3 +333,20 @@ macro_rules! with_settings {
         settings.bind(|| $body)
     }}
 }
+
+/// Executes a closure for all input files matching a glob.
+///
+/// The closure is passed the path to the file.
+#[cfg(feature = "glob")]
+#[macro_export]
+macro_rules! glob {
+    ($glob:expr, $closure:expr) => {{
+        let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+            .join(file!())
+            .parent()
+            .unwrap()
+            .canonicalize()
+            .unwrap();
+        $crate::_macro_support::glob_exec(&base, $glob, $closure);
+    }};
+}
diff --git a/src/runtime.rs b/src/runtime.rs
index 839b72c..8898aa1 100644
--- a/src/runtime.rs
+++ b/src/runtime.rs
@@ -313,7 +313,7 @@ pub fn get_snapshot_filename(
             .join(format!(
                 "{}__{}.snap",
                 module_path.replace("::", "__"),
-                snapshot_name
+                snapshot_name.replace("/", "__").replace("\\", "__")
             ))
     })
 }
@@ -354,6 +354,10 @@ pub fn print_snapshot_summary(
             }
         );
     }
+
+    if let Some(ref value) = snapshot.metadata().input_file() {
+        println!("Input file: {}", style(value).cyan());
+    }
 }
 
 /// Prints a diff against an old snapshot.
@@ -515,20 +519,28 @@ fn generate_snapshot_name_for_thread(module_path: &str) -> Result<String, &'stat
                  names from the call stack.");
         }
     }
+
+    // clean test name first
     let mut name = name.rsplit("::").next().unwrap();
-    // we really do not care about poisoning here.
-    let key = format!("{}::{}", module_path.replace("::", "__"), name);
-    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;
     if name.starts_with("test_") {
         name = &name[5..];
     }
+
+    // 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);
+
+    // 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)
 }
 
@@ -820,6 +832,16 @@ fn update_snapshots(
     Ok(())
 }
 
+/// 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)
+    })
+}
+
 #[allow(clippy::too_many_arguments)]
 pub fn assert_snapshot(
     refval: ReferenceValue<'_>,
@@ -835,8 +857,8 @@ pub fn assert_snapshot(
 
     let (snapshot_name, snapshot_file, old, pending_snapshots) = match refval {
         ReferenceValue::Named(snapshot_name) => {
-            let snapshot_name: Cow<str> = match snapshot_name {
-                Some(snapshot_name) => snapshot_name,
+            let snapshot_name = match snapshot_name {
+                Some(snapshot_name) => add_suffix_to_snapshot_name(snapshot_name),
                 None => generate_snapshot_name_for_thread(module_path)
                     .unwrap()
                     .into(),
@@ -884,6 +906,17 @@ pub fn assert_snapshot(
         MetaData {
             source: Some(path_to_storage(file)),
             expression: Some(expr.to_string()),
+            input_file: Settings::with(|settings| {
+                settings
+                    .input_file()
+                    .and_then(|x| cargo_workspace.join(x).canonicalize().ok())
+                    .and_then(|s| {
+                        s.strip_prefix(cargo_workspace)
+                            .ok()
+                            .map(|x| x.to_path_buf())
+                    })
+                    .map(path_to_storage)
+            }),
         },
         new_snapshot_contents,
     );
diff --git a/src/settings.rs b/src/settings.rs
index e7828b6..9000c1e 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -13,6 +13,8 @@ lazy_static! {
     static ref DEFAULT_SETTINGS: Arc<ActualSettings> = Arc::new(ActualSettings {
         sort_maps: false,
         snapshot_path: "snapshots".into(),
+        snapshot_suffix: "".into(),
+        input_file: None,
         #[cfg(feature = "redactions")]
         redactions: Redactions::default(),
     });
@@ -41,6 +43,8 @@ impl<'a> From<Vec<(&'a str, Redaction)>> for Redactions {
 pub struct ActualSettings {
     pub sort_maps: bool,
     pub snapshot_path: PathBuf,
+    pub snapshot_suffix: String,
+    pub input_file: Option<PathBuf>,
     #[cfg(feature = "redactions")]
     pub redactions: Redactions,
 }
@@ -62,7 +66,7 @@ pub struct ActualSettings {
 /// ```rust,ignore
 /// use insta;
 ///
-/// let mut settings = insta::Settings::new();
+/// let mut settings = insta::Settings::clone_current();
 /// settings.set_sort_maps(true);
 /// settings.bind(|| {
 ///     insta::assert_snapshot!(...);
@@ -83,10 +87,18 @@ impl Default for Settings {
 
 impl Settings {
     /// Returns the default settings.
+    ///
+    /// It's recommended to use `clone_current` instead so that
+    /// already applied modifications are not discarded.
     pub fn new() -> Settings {
         Settings::default()
     }
 
+    /// Returns a copy of the current settings.
+    pub fn clone_current() -> Settings {
+        Settings::with(|x| x.clone())
+    }
+
     /// Internal helper for macros
     #[doc(hidden)]
     pub fn _private_inner_mut(&mut self) -> &mut ActualSettings {
@@ -108,6 +120,51 @@ impl Settings {
         self.inner.sort_maps
     }
 
+    /// Sets the snapshot suffix.
+    ///
+    /// The snapshot suffix is added to all snapshot names with an `@` sign
+    /// between.  For instance if the snapshot suffix is set to `"foo"` and
+    /// the snapshot would be named `"snapshot"` it turns into `"snapshot@foo"`.
+    /// This is useful to separate snapshots if you want to use test
+    /// parameterization.
+    pub fn set_snapshot_suffix<I: Into<String>>(&mut self, suffix: I) {
+        self._private_inner_mut().snapshot_suffix = suffix.into();
+    }
+
+    /// Removes the snapshot suffix.
+    pub fn remove_snapshot_suffix(&mut self) {
+        self.set_snapshot_suffix("");
+    }
+
+    /// Returns the current snapshot suffix.
+    pub fn snapshot_suffix(&self) -> Option<&str> {
+        if self.inner.snapshot_suffix.is_empty() {
+            None
+        } else {
+            Some(&self.inner.snapshot_suffix)
+        }
+    }
+
+    /// Sets the input file reference.
+    ///
+    /// This value is completely unused by the snapshot testing system but
+    /// it lets you store some meta data with a snapshot that refers you back
+    /// to the input file.  The path stored here is made relative to the
+    /// workspace root before storing with the snapshot.
+    pub fn set_input_file<P: AsRef<Path>>(&mut self, p: P) {
+        self._private_inner_mut().input_file = Some(p.as_ref().to_path_buf());
+    }
+
+    /// Removes the input file reference.
+    pub fn remove_input_file(&mut self) {
+        self._private_inner_mut().input_file = None;
+    }
+
+    /// Returns the current input file reference.
+    pub fn input_file(&self) -> Option<&Path> {
+        self.inner.input_file.as_deref()
+    }
+
     /// Registers redactions that should be applied.
     ///
     /// This can be useful if redactions must be shared across multiple
diff --git a/src/snapshot.rs b/src/snapshot.rs
index f4dc0db..717fe21 100644
--- a/src/snapshot.rs
+++ b/src/snapshot.rs
@@ -76,6 +76,9 @@ pub struct MetaData {
     /// Optionally the expression that created the snapshot.
     #[serde(skip_serializing_if = "Option::is_none")]
     pub(crate) expression: Option<String>,
+    /// Reference to the input file.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub(crate) input_file: Option<String>,
 }
 
 impl MetaData {
@@ -99,6 +102,11 @@ impl MetaData {
                 .unwrap_or_else(|| base.to_path_buf())
         })
     }
+
+    /// Returns the input file reference.
+    pub fn input_file(&self) -> Option<&str> {
+        self.input_file.as_deref()
+    }
 }
 
 /// A helper to work with stored snapshots.


Test patch:
diff --git a/tests/inputs/hello.txt b/tests/inputs/hello.txt
new file mode 100644
index 0000000..bbee74b
--- /dev/null
+++ b/tests/inputs/hello.txt
@@ -0,0 +1,1 @@
+Contents of hello
\ No newline at end of file
diff --git a/tests/snapshots/test_suffixes__basic_globbing@hello.txt.snap b/tests/snapshots/test_suffixes__basic_globbing@hello.txt.snap
new file mode 100644
index 0000000..b6e81c0
--- /dev/null
+++ b/tests/snapshots/test_suffixes__basic_globbing@hello.txt.snap
@@ -0,0 +1,6 @@
+---
+source: tests/test_suffixes.rs
+expression: "&contents"
+input_file: tests/inputs/hello.txt
+---
+"Contents of hello"
diff --git a/tests/snapshots/test_suffixes__basic_suffixes@1.snap b/tests/snapshots/test_suffixes__basic_suffixes@1.snap
new file mode 100644
index 0000000..ae952e9
--- /dev/null
+++ b/tests/snapshots/test_suffixes__basic_suffixes@1.snap
@@ -0,0 +1,5 @@
+---
+source: tests/test_suffixes.rs
+expression: "&value"
+---
+1
diff --git a/tests/snapshots/test_suffixes__basic_suffixes@2.snap b/tests/snapshots/test_suffixes__basic_suffixes@2.snap
new file mode 100644
index 0000000..43655f2
--- /dev/null
+++ b/tests/snapshots/test_suffixes__basic_suffixes@2.snap
@@ -0,0 +1,5 @@
+---
+source: tests/test_suffixes.rs
+expression: "&value"
+---
+2
diff --git a/tests/snapshots/test_suffixes__basic_suffixes@3.snap b/tests/snapshots/test_suffixes__basic_suffixes@3.snap
new file mode 100644
index 0000000..a8fd7b3
--- /dev/null
+++ b/tests/snapshots/test_suffixes__basic_suffixes@3.snap
@@ -0,0 +1,5 @@
+---
+source: tests/test_suffixes.rs
+expression: "&value"
+---
+3
diff --git a/tests/test_suffixes.rs b/tests/test_suffixes.rs
new file mode 100644
index 0000000..aa56f2f
--- /dev/null
+++ b/tests/test_suffixes.rs
@@ -0,0 +1,17 @@
+#[test]
+fn test_basic_suffixes() {
+    for value in vec![1, 2, 3] {
+        insta::with_settings!({snapshot_suffix => value.to_string()}, {
+            insta::assert_json_snapshot!(&value);
+        });
+    }
+}
+
+#[cfg(feature = "glob")]
+#[test]
+fn test_basic_globbing() {
+    insta::glob!("inputs/*.txt", |path| {
+        let contents = std::fs::read_to_string(path).unwrap();
+        insta::assert_json_snapshot!(&contents);
+    });
+}