Repository: mitsuhiko/insta
Pull request #610 (2024-09-22 22:38:58)

Binary snapshots (in-memory)
The biggest difference to the previous attempt in #489 is that the snapshot contents are now kept in-memory instead of being written into a temporary file directly.

For `SnapshotContents` I decided to make it an enum with two variants like in the previous attempt. I first attempted to change it to three variants so it would also replace the `SnapshotKind` enum but I realized that a lot of logic is still shared between inline and file text snapshots. That approach would definitely also work though and it's perhaps also a question of how much the logic between the two is expected to diverge in the future.

In `runtime::assert_snapshot` I replaced the combination of the `refval` and `new_snapshot_value` with an enum. In that case it is a three variant enum and some `From` impls for two value tuples for the macros. Maybe it would be better to make it a two variant enum there too for consistency and keep the `ReferenceValue` enum for the text case. That might be a bit weird because both binary and non-inline text snapshot have a name and that would then be in two different enums.

I should probably also add some tests in `cargo-insta`. I'll do that as soon as possible, but I need to figure out how the testing there works.

max-sixty: > For `SnapshotContents` I decided to make it an enum with two variants like in the previous attempt. I first attempted to change it to three variants so it would also replace the `SnapshotKind` enum but I realized that a lot of logic is still shared between inline and file text snapshots. That approach would definitely also work though and it's perhaps also a question of how much the logic between the two is expected to diverge in the future.

FWIW I think the ontology is quite an interesting case — `FileText` & `FileBinary` share some properties, but `FileText` & `InlineText` also share some completely different properties. So there's no perfect hierarchy.

The proposed structure seems reasonable (as above I'd like to see if we can reduce the number of data structures a bit, but I don't think there are any easy large clean-ups)
max-sixty: To sketch out what I think we need to do to merge:
- Tests — we already have a couple good integration tests. Would be great to extend those to any corner cases — for example, what if someone uses the same extension for a text snapshot? What does it look like if someone has an older version of `cargo-insta`? (maybe those are too specific, but anything in that direction)
- @mitsuhiko needs to review, I think focused on the external API
  - Obv great if we can make the internal code nice, but also we can do that later, and would be a loss if this withered based on refinements.
- We should decide whether we mark as experimental. If we don't mark it as experimental, we're stuck with the API, and in some ways insta has more stringent backward-compatibility constraints than other libraries, since it needs to work with older versions of itself in the form of `cargo-insta`)
- Docs — we should add a short section on this in https://github.com/mitsuhiko/insta-website

max-sixty: Re additional tests: we should ensure we cover named snapshots. Possibly I'm making a mistake but this doesn't compile atm:

```rust
#[test]
fn test_binary_snapshot_named() {
    insta::assert_binary_snapshot!("txt", "name", b"test".to_vec());
}
```

---

I'm thinking of whether we can retain more of the structure of existing familiar macros, rather than using different parameters in the form of `$extension, $name, $value`. (developers of the library can undervalue the benefits of consistency because we're so "in" it, while most folks reading tests would highly value consistency; particularly for macros, which don't easily expose the names of their parameters)

Not confident, but one possible way to retain `$name, $value` is to push `$extension` into the `$name` param:
- `*.tar.gz` would be used for an unnamed snapshot
- `name.tar.gz` would be used for a named snapshot. The resulting file would be `${prefix}_name.snap.tar.gz`

I think this would be reasonably simple? On the downside, `name.tar.gz` looks like a file name, and we don't exactly get that file name; and it's not possible to name a snapshot beginning with `*`.
lasernoises: You're right, the named binary snapshots were broken. I've now fixed them and also changed how the macros work in in the text case. The change is not necessarily an obvious win, because it introduces a little bit of weirdness with an ignored parameter, but I do think I makes it a bit clearer what is going on and it requires a little bit less `From` magic.

Syntax wise we have a lot of options for how to pass the extensions and I'm not quite sure what the best one is. We could possibly even use ident tokens or something like that, but I suppose that's not optimal either because it would cause weirdness around keywords.
max-sixty: > What I'm a bit more worried about is the `internals` module. I am changing some things that are exposed in there. The docstring does imply that it's primarily for documentation. So that might imply that it's not part of the stable API?

Yes, this is `hidden` and so not part of the public API (somewhat by convention). We only have it public so `cargo-insta` can access it.

> I think older versions of `cargo-insta` will definitely not be able to support binary snapshots. I think that's simply not solvable.

For sure — my point re older `cargo-insta` versions is: if we release this in 1.41 and then realize we should have done it differently, we can't break the 1.41 format, unless we've marked it as experimental. And we're a bit more constrained than other apps because things like pending snapshots are an interface between `cargo-insta` & `insta`, which themselves can be on different versions...

> that would mean we'd need to parse that string at runtime because I don't think the contents of a string literal can be parsed by a `macro_rules` macro.

Yes, but I think that's fine? The existing approach for text snapshots — `Autoname` — is at runtime too, albeit the macro knows at compile time which path it'll take.

I would be less keen on the ident approach — I don't see much benefit and it's less consistent.
max-sixty: @mitsuhiko I'll DM you in the next few days about this PR. The things we want from you:
- OK in principle?
- Any views on what the macro params should be? The currently proposed code has `$extension, ($name,)? $value, ($debug_expr)?`.
    - I offered a proposal [above](https://github.com/mitsuhiko/insta/pull/610#issuecomment-2372105382) for `$name.extension, $value, ($debug_expr)?`, where `name` can be `*`.
- OK to merge when we're happy with it, after we've added docs etc? Or you want to review again?

mitsuhiko: Sorry for ghosting this for so long.  These PRs always require actual brainpower to be put to it, so they tend to slip down the list automatically when I have limited capacity for it.  So first of all: definitely on board with having this.

I will give this some more deeper thoughts but here are some very immediate questions that came up.  The most obvious one is this one:

> ```rust
> assert_binary_snapshot!("some_name.jpg", b"test".to_vec());
> ```

This was rejected above there because the string requires parsing at runtime.  Is this a problem?  Because at least in theory this makes the most sense from a user experience point of view and all the other stuff proposed seems a bit odd.
lasernoises: > > ```rust
> > assert_binary_snapshot!("some_name.jpg", b"test".to_vec());
> > ```
> 
> This was rejected above there because the string requires parsing at runtime. Is this a problem? Because at least in theory this makes the most sense from a user experience point of view and all the other stuff proposed seems a bit odd.

I just would have preferred to make it a compile time error if the `.` was missing. We could also interpret it just as an extension in that case, but I don't think that's a good idea.

I totally agree that all of the other options I layed out are a bit weird and not great. Also given that this is a testing library I think checking things at runtime is much less of an issue than for other libraries so I suppose the thing about not being able to check the format of the string at compile time is more of an annoyance than a real issue.

I'll give this a go tomorrow. I think for unnamed snapshots I'll just use `".jpg"` instead of `"*.jpg"` because I think that makes it look less like a glob, which I think could make it a bit confusing when reading one of those for the first time. But that might just be me.
max-sixty: > . I think for unnamed snapshots I'll just use `".jpg"` instead of `"*.jpg"` because I think that makes it look less like a glob, which I think could make it a bit confusing when reading one of those for the first time.

I'd be OK with this. The downside is it prevents using a leading `.` as a file name (though _technically_ `*.jpy` prevents using a leading `*` as a file name!).

And the `*.jpy` looks sufficiently different from a name. But also understand it looks like a glob. Open to other ideas; could use a `$` or `_` or `{name}` etc.


max-sixty: Great, thanks @lasernoises , I think the functionality is good!

From the list above: https://github.com/mitsuhiko/insta/pull/610#issuecomment-2369071150, we should add some docs here, I would suggest marking it as experimental. And if possible we could add a very short section on the website, and then I think we're good to go. (a couple of code clean-ups too)

(I'll let @mitsuhiko weigh-in if he has other feedback, but won't wait for another review unless he requests one...)

lasernoises: > From the list above: [#610 (comment)](https://github.com/mitsuhiko/insta/pull/610#issuecomment-2369071150), we should add some docs here, I would suggest marking it as experimental. And if possible we could add a very short section on the website, and then I think we're good to go. (a couple of code clean-ups too)

I hope I'll have some time tomorrow to add something to the website.

Regarding the experimental thing: Should I just add a doc comment to the macro that states that the feature is experimental? Also: if we consider the feature experimental, maybe it would make sense to not write the `snapshot_kind` metadata field for text snapshots yet?

---

On an unrelated note: I was trying to test if the feature for deleting unreferenced snapshots works correctly for binary snapshots, but I can't seem to get that to remove unreferenced snapshots at all, even on master. It might be an issue with my machine or something like that.

From the code I have a suspicion that once I get it to work it will not handle binary snapshots right yet. So it probably makes sense to wait with merging this until I've figured out what I'm doing wrong there.
max-sixty: > Regarding the experimental thing: Should I just add a doc comment to the macro that states that the feature is experimental?

Yes great! And the changelog / basically anywhere we mention it. We can say something in the macro docstring like "we may make incompatible changes for the next couple of versions after 1.41"

> maybe it would make sense to not write the `snapshot_kind` metadata field for text snapshots yet?

I think it's fine to add. I think maybe 20% chance we change something about the interface somewhere, but that piece seems quite robust.

> I was trying to test if the feature for deleting unreferenced snapshots works correctly for binary snapshots, but I can't seem to get that to remove unreferenced snapshots at all, even on master

Whoops, this no longer works — not your machine! Will fix and add a test (test coverage _gradually_ growing from a very low base!)
max-sixty: A couple of examples and then let's hit the big button. Thank you *very* much for all your persistence here, I know it was a long road. It's a great feature for insta!
lasernoises: The deleting of unreferenced snapshots does not work for binary snapshots yet. I'm working on fixing that now.
lasernoises: Now it works, but I'm not quite sure what the best error handling strategy there is. It needs to load the snapshot now to know whether it's binary and to get the extension. It still ignores errors when deleting the files like before. So I think it should probably also ignore errors when loading the snapshot and just skip it and maybe print a log message.
lasernoises: > > What I'm a bit more worried about is the `internals` module. I am changing some things that are exposed in there. The docstring does imply that it's primarily for documentation. So that might imply that it's not part of the stable API?
> 
> Yes, this is `hidden` and so not part of the public API (somewhat by convention). We only have it public so `cargo-insta` can access it.

I meant to reply to this earlier but forgot:
I think you meant the `_cargo_insta_support` module. Because the `internals` module is actually not hidden. I think given that it's called `internals` we can probably justify changing things exposed in it. Maybe it would make sense to add a comment there that explicitly states that this is not guaranteed to be stable.
lasernoises: I've started adding a section for the website: https://github.com/mitsuhiko/insta-website/compare/main...lasernoises:insta-website:binary-snapshots?expand=1. I think I'll wait with opening a PR until this is merged.
max-sixty: > I think you meant the `_cargo_insta_support` module. Because the `internals` module is actually not hidden. I think given that it's called `internals` we can probably justify changing things exposed in it. Maybe it would make sense to add a comment there that explicitly states that this is not guaranteed to be stable.

Yes v good point. Would be great to add the comment (but no need to slow down this PR ofc)
max-sixty: @lasernoises anything else? Or we merge? :)
lasernoises: I think from my point of view we can merge.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 910f005..4eedbe6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ All notable changes to insta and cargo-insta are documented here.
 
 - Inline snapshots only use `#` characters as delimiters when required.  #603
 
+- Experimental support for binary snapshots.  #610 (Florian Plattner)
+
 ## 1.40.0
 
 - `cargo-insta` no longer panics when running `cargo insta test --accept --workspace`
diff --git a/Cargo.lock b/Cargo.lock
index 2b7844a..be882cb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -75,6 +75,7 @@ dependencies = [
  "insta",
  "itertools",
  "lazy_static",
+ "open",
  "os_pipe",
  "proc-macro2",
  "semver",
@@ -393,6 +394,15 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
 [[package]]
 name = "is-terminal"
 version = "0.4.12"
@@ -404,6 +414,16 @@ dependencies = [
  "windows-sys 0.52.0",
 ]
 
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
 [[package]]
 name = "itertools"
 version = "0.10.5"
@@ -466,9 +486,20 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
 
 [[package]]
 name = "once_cell"
-version = "1.14.0"
+version = "1.19.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f7254b99e31cad77da24b08ebf628882739a608578bb1bcdfc1f9c21260d7c0"
+checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+
+[[package]]
+name = "open"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61a877bf6abd716642a53ef1b89fb498923a4afca5c754f9050b4d081c05c4b3"
+dependencies = [
+ "is-wsl",
+ "libc",
+ "pathdiff",
+]
 
 [[package]]
 name = "os_pipe"
@@ -480,6 +511,12 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "pathdiff"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
+
 [[package]]
 name = "pest"
 version = "2.7.11"
diff --git a/cargo-insta/Cargo.toml b/cargo-insta/Cargo.toml
index 11dd43b..f0202d9 100644
--- a/cargo-insta/Cargo.toml
+++ b/cargo-insta/Cargo.toml
@@ -30,6 +30,7 @@ tempfile = "3.5.0"
 semver = {version = "1.0.7", features = ["serde"]}
 lazy_static = "1.4.0"
 clap = { workspace=true }
+open = "5.3.0"
 itertools = "0.10.0"
 
 [dev-dependencies]
diff --git a/cargo-insta/src/cli.rs b/cargo-insta/src/cli.rs
index b1ec18c..d9bbe44 100644
--- a/cargo-insta/src/cli.rs
+++ b/cargo-insta/src/cli.rs
@@ -321,6 +321,24 @@ fn query_snapshot(
             style("toggle snapshot diff").dim()
         );
 
+        let new_is_binary = new.contents().is_binary();
+        let old_is_binary = old.map(|o| o.contents().is_binary()).unwrap_or(false);
+
+        if new_is_binary || old_is_binary {
+            println!(
+                "  {} open       {}",
+                style("o").cyan().bold(),
+                style(if new_is_binary && old_is_binary {
+                    "open snapshot files in external tool"
+                } else if new_is_binary {
+                    "open new snapshot file in external tool"
+                } else {
+                    "open old snapshot file in external tool"
+                })
+                .dim()
+            );
+        }
+
         loop {
             match term.read_key()? {
                 Key::Char('a') | Key::Enter => return Ok(Operation::Accept),
@@ -334,6 +352,21 @@ fn query_snapshot(
                     *show_diff = !*show_diff;
                     break;
                 }
+                Key::Char('o') => {
+                    if let Some(old) = old {
+                        if let Some(path) = old.build_binary_path(snapshot_file.unwrap()) {
+                            open::that_detached(path)?;
+                        }
+                    }
+
+                    if let Some(path) =
+                        new.build_binary_path(snapshot_file.unwrap().with_extension("snap.new"))
+                    {
+                        open::that_detached(path)?;
+                    }
+
+                    // there's no break here because there's no need to re-output anything
+                }
                 _ => {}
             }
         }
@@ -811,6 +844,18 @@ fn handle_unreferenced_snapshots(
             }
             eprintln!("  {}", path.display());
             if matches!(action, Action::Delete) {
+                let snapshot = match Snapshot::from_file(&path) {
+                    Ok(snapshot) => snapshot,
+                    Err(e) => {
+                        eprintln!("Error loading snapshot at {:?}: {}", &path, e);
+                        continue;
+                    }
+                };
+
+                if let Some(binary_path) = snapshot.build_binary_path(&path) {
+                    fs::remove_file(&binary_path).ok();
+                }
+
                 fs::remove_file(&path).ok();
             }
         }
@@ -1085,8 +1130,11 @@ fn pending_snapshots_cmd(cmd: PendingSnapshotsCommand) -> Result<(), Box<dyn Err
         let is_inline = snapshot_container.snapshot_file().is_none();
         for snapshot_ref in snapshot_container.iter_snapshots() {
             if cmd.as_json {
-                let old_snapshot = snapshot_ref.old.as_ref().map(|x| x.contents_string());
-                let new_snapshot = snapshot_ref.new.contents_string();
+                let old_snapshot = snapshot_ref
+                    .old
+                    .as_ref()
+                    .map(|x| x.contents_string().unwrap());
+                let new_snapshot = snapshot_ref.new.contents_string().unwrap();
                 let info = if is_inline {
                     SnapshotKey::InlineSnapshot {
                         path: &target_file,
diff --git a/cargo-insta/src/container.rs b/cargo-insta/src/container.rs
index 125b2fe..1764cfa 100644
--- a/cargo-insta/src/container.rs
+++ b/cargo-insta/src/container.rs
@@ -3,7 +3,7 @@ use std::fs;
 use std::path::{Path, PathBuf};
 
 use insta::Snapshot;
-pub(crate) use insta::SnapshotKind;
+pub(crate) use insta::TextSnapshotKind;
 use insta::_cargo_insta_support::{ContentError, PendingInlineSnapshot};
 
 use crate::inline::FilePatcher;
@@ -51,7 +51,7 @@ pub(crate) struct SnapshotContainer {
     pending_path: PathBuf,
     // Path of the target snapshot file (generally a `.snap` file)
     target_path: PathBuf,
-    kind: SnapshotKind,
+    kind: TextSnapshotKind,
     snapshots: Vec<PendingSnapshot>,
     patcher: Option<FilePatcher>,
 }
@@ -60,11 +60,11 @@ impl SnapshotContainer {
     pub(crate) fn load(
         pending_path: PathBuf,
         target_path: PathBuf,
-        kind: SnapshotKind,
+        kind: TextSnapshotKind,
     ) -> Result<SnapshotContainer, Box<dyn Error>> {
         let mut snapshots = Vec::new();
         let patcher = match kind {
-            SnapshotKind::File => {
+            TextSnapshotKind::File => {
                 let old = if fs::metadata(&target_path).is_err() {
                     None
                 } else {
@@ -80,7 +80,7 @@ impl SnapshotContainer {
                 });
                 None
             }
-            SnapshotKind::Inline => {
+            TextSnapshotKind::Inline => {
                 let mut pending_vec = PendingInlineSnapshot::load_batch(&pending_path)?;
                 let mut have_new = false;
 
@@ -136,8 +136,8 @@ impl SnapshotContainer {
 
     pub(crate) fn snapshot_file(&self) -> Option<&Path> {
         match self.kind {
-            SnapshotKind::File => Some(&self.target_path),
-            SnapshotKind::Inline => None,
+            TextSnapshotKind::File => Some(&self.target_path),
+            TextSnapshotKind::Inline => None,
         }
     }
 
@@ -167,7 +167,7 @@ impl SnapshotContainer {
         // Try removing the snapshot file. If it fails, it's
         // likely because it another process removed it; which
         // is fine — print a message and continue.
-        let try_removing_snapshot = |p| {
+        let try_removing_snapshot = |p: &Path| {
             fs::remove_file(p).unwrap_or_else(|_| {
                     eprintln!(
                         "Pending snapshot file at {:?} couldn't be removed. It was likely removed by another process.",
@@ -184,7 +184,10 @@ impl SnapshotContainer {
             for (idx, snapshot) in self.snapshots.iter().enumerate() {
                 match snapshot.op {
                     Operation::Accept => {
-                        patcher.set_new_content(idx, snapshot.new.contents());
+                        patcher.set_new_content(
+                            idx,
+                            snapshot.new.contents().as_string_contents().unwrap(),
+                        );
                         did_accept = true;
                     }
                     Operation::Reject => {}
@@ -213,22 +216,28 @@ impl SnapshotContainer {
             for snapshot in self.snapshots.iter() {
                 match snapshot.op {
                     Operation::Accept => {
-                        let snapshot = Snapshot::from_file(&self.pending_path).map_err(|e| {
-                            // If it's an IO error, pass a ContentError back so
-                            // we get a slightly clearer error message
-                            match e.downcast::<std::io::Error>() {
-                                Ok(io_error) => Box::new(ContentError::FileIo(
-                                    *io_error,
-                                    self.pending_path.to_path_buf(),
-                                )),
-                                Err(other_error) => other_error,
-                            }
-                        })?;
-                        snapshot.save(&self.target_path)?;
                         try_removing_snapshot(&self.pending_path);
+
+                        if let Some(ref old) = snapshot.old {
+                            if let Some(path) = old.build_binary_path(&self.target_path) {
+                                try_removing_snapshot(&path);
+                            }
+                        }
+
+                        if let Some(path) = snapshot.new.build_binary_path(&self.pending_path) {
+                            try_removing_snapshot(&path);
+                        }
+
+                        // We save at the end because we might write a binary file into the same
+                        // path again.
+                        snapshot.new.save(&self.target_path)?;
                     }
                     Operation::Reject => {
                         try_removing_snapshot(&self.pending_path);
+
+                        if let Some(path) = snapshot.new.build_binary_path(&self.pending_path) {
+                            try_removing_snapshot(&path);
+                        }
                     }
                     Operation::Skip => {}
                 }
diff --git a/cargo-insta/src/inline.rs b/cargo-insta/src/inline.rs
index 5aee2f4..a08972d 100644
--- a/cargo-insta/src/inline.rs
+++ b/cargo-insta/src/inline.rs
@@ -4,7 +4,7 @@ use std::fs;
 use std::io::Write;
 use std::path::{Path, PathBuf};
 
-use insta::_cargo_insta_support::SnapshotContents;
+use insta::_cargo_insta_support::TextSnapshotContents;
 use proc_macro2::TokenTree;
 
 use syn::__private::ToTokens;
@@ -90,7 +90,7 @@ impl FilePatcher {
         self.inline_snapshots[id].start.0 + 1
     }
 
-    pub(crate) fn set_new_content(&mut self, id: usize, snapshot: &SnapshotContents) {
+    pub(crate) fn set_new_content(&mut self, id: usize, snapshot: &TextSnapshotContents) {
         let inline = &mut self.inline_snapshots[id];
 
         // find prefix and suffix on the first and last lines
diff --git a/cargo-insta/src/walk.rs b/cargo-insta/src/walk.rs
index 52449bf..5592aee 100644
--- a/cargo-insta/src/walk.rs
+++ b/cargo-insta/src/walk.rs
@@ -5,7 +5,7 @@ use std::path::Path;
 use ignore::overrides::OverrideBuilder;
 use ignore::{DirEntry, Walk, WalkBuilder};
 
-use crate::container::{SnapshotContainer, SnapshotKind};
+use crate::container::{SnapshotContainer, TextSnapshotKind};
 
 #[derive(Debug, Copy, Clone)]
 pub(crate) struct FindFlags {
@@ -38,7 +38,7 @@ pub(crate) fn find_pending_snapshots<'a>(
                 Some(SnapshotContainer::load(
                     path.clone(),
                     path.with_file_name(new_fname),
-                    SnapshotKind::File,
+                    TextSnapshotKind::File,
                 ))
             } else if let Some(new_fname) = fname
                 .strip_prefix('.')
@@ -47,7 +47,7 @@ pub(crate) fn find_pending_snapshots<'a>(
                 Some(SnapshotContainer::load(
                     path.clone(),
                     path.with_file_name(new_fname),
-                    SnapshotKind::Inline,
+                    TextSnapshotKind::Inline,
                 ))
             } else {
                 None
diff --git a/insta/src/content/mod.rs b/insta/src/content/mod.rs
index 3be7144..60e445c 100644
--- a/insta/src/content/mod.rs
+++ b/insta/src/content/mod.rs
@@ -22,7 +22,6 @@ use std::fmt;
 pub enum Error {
     FailedParsingYaml(std::path::PathBuf),
     UnexpectedDataType,
-    #[cfg(feature = "_cargo_insta_internal")]
     MissingField,
     FileIo(std::io::Error, std::path::PathBuf),
 }
@@ -36,7 +35,6 @@ impl fmt::Display for Error {
             Error::UnexpectedDataType => {
                 f.write_str("The present data type wasn't what was expected")
             }
-            #[cfg(feature = "_cargo_insta_internal")]
             Error::MissingField => f.write_str("A required field was missing"),
             Error::FileIo(e, p) => {
                 f.write_str(format!("File error for {:?}: {}", p.display(), e).as_str())
diff --git a/insta/src/lib.rs b/insta/src/lib.rs
index 142256b..410c271 100644
--- a/insta/src/lib.rs
+++ b/insta/src/lib.rs
@@ -294,7 +294,7 @@ mod glob;
 mod test;
 
 pub use crate::settings::Settings;
-pub use crate::snapshot::{MetaData, Snapshot, SnapshotKind};
+pub use crate::snapshot::{MetaData, Snapshot, TextSnapshotKind};
 
 /// Exposes some library internals.
 ///
@@ -327,6 +327,7 @@ pub mod _cargo_insta_support {
         output::SnapshotPrinter,
         snapshot::PendingInlineSnapshot,
         snapshot::SnapshotContents,
+        snapshot::TextSnapshotContents,
         utils::is_ci,
     };
 }
@@ -340,7 +341,10 @@ pub use crate::redaction::{dynamic_redaction, rounded_redaction, sorted_redactio
 pub mod _macro_support {
     pub use crate::content::Content;
     pub use crate::env::get_cargo_workspace;
-    pub use crate::runtime::{assert_snapshot, with_allow_duplicates, AutoName, ReferenceValue};
+    pub use crate::runtime::{
+        assert_snapshot, with_allow_duplicates, AutoName, BinarySnapshotValue, InlineValue,
+        SnapshotValue,
+    };
 
     #[cfg(feature = "serde")]
     pub use crate::serialization::{serialize_value, SerializationFormat, SnapshotLocation};
diff --git a/insta/src/macros.rs b/insta/src/macros.rs
index 222ef3c..af1e3fe 100644
--- a/insta/src/macros.rs
+++ b/insta/src/macros.rs
@@ -339,7 +339,7 @@ macro_rules! _assert_snapshot_base {
         $crate::_assert_snapshot_base!(
             transform = $transform,
             #[allow(clippy::needless_raw_string_hashes)]
-            $crate::_macro_support::ReferenceValue::Inline($snapshot),
+            $crate::_macro_support::InlineValue($snapshot),
             $($arg),*
         )
     };
@@ -359,9 +359,54 @@ macro_rules! _assert_snapshot_base {
     // The main macro body — every call to this macro should end up here.
     (transform=$transform:expr, $name:expr, $value:expr, $debug_expr:expr $(,)?) => {
         $crate::_macro_support::assert_snapshot(
-            $name.into(),
-            #[allow(clippy::redundant_closure_call)]
-            &$transform(&$value),
+            (
+                $name,
+                #[allow(clippy::redundant_closure_call)]
+                $transform(&$value).as_str(),
+            ).into(),
+            $crate::_get_workspace_root!().as_path(),
+            $crate::_function_name!(),
+            module_path!(),
+            file!(),
+            line!(),
+            $debug_expr,
+        )
+        .unwrap()
+    };
+}
+
+/// (Experimental)
+/// Asserts a binary snapshot in the form of a [`Vec<u8>`].
+///
+/// The contents get stored in a separate file next to the metadata file. The extension for this
+/// file must be passed as part of the name. For an implicit snapshot name just an extension can be
+/// passed starting with a `.`.
+///
+/// This feature is considered experimental: we may make incompatible changes for the next couple
+/// of versions after 1.41.
+///
+/// Examples:
+///
+/// ```no_run
+/// // implicit name:
+/// insta::assert_binary_snapshot!(".txt", b"abcd".to_vec());
+///
+/// // named:
+/// insta::assert_binary_snapshot!("my_snapshot.bin", [0, 1, 2, 3].to_vec());
+/// ```
+#[macro_export]
+macro_rules! assert_binary_snapshot {
+    ($name_and_extension:expr, $value:expr $(,)?) => {
+        $crate::assert_binary_snapshot!($name_and_extension, $value, stringify!($value));
+    };
+
+    ($name_and_extension:expr, $value:expr, $debug_expr:expr $(,)?) => {
+        $crate::_macro_support::assert_snapshot(
+            $crate::_macro_support::BinarySnapshotValue {
+                name_and_extension: $name_and_extension,
+                content: $value,
+            }
+            .into(),
             $crate::_get_workspace_root!().as_path(),
             $crate::_function_name!(),
             module_path!(),
diff --git a/insta/src/output.rs b/insta/src/output.rs
index 089af78..d63d30c 100644
--- a/insta/src/output.rs
+++ b/insta/src/output.rs
@@ -4,7 +4,7 @@ use std::{path::Path, time::Duration};
 use similar::{Algorithm, ChangeTag, TextDiff};
 
 use crate::content::yaml;
-use crate::snapshot::{MetaData, Snapshot};
+use crate::snapshot::{MetaData, Snapshot, SnapshotContents};
 use crate::utils::{format_rust_expression, style, term_width};
 
 /// Snapshot printer utility.
@@ -103,121 +103,198 @@ impl<'a> SnapshotPrinter<'a> {
     fn print_snapshot(&self) {
         print_line(term_width());
 
-        let new_contents = self.new_snapshot.contents_string();
-
         let width = term_width();
         if self.show_info {
             self.print_info();
         }
         println!("Snapshot Contents:");
-        println!("──────┬{:─^1$}", "", width.saturating_sub(7));
-        for (idx, line) in new_contents.lines().enumerate() {
-            println!("{:>5} │ {}", style(idx + 1).cyan().dim().bold(), line);
+
+        match self.new_snapshot.contents() {
+            SnapshotContents::Text(new_contents) => {
+                let new_contents = new_contents.to_string();
+
+                println!("──────┬{:─^1$}", "", width.saturating_sub(7));
+                for (idx, line) in new_contents.lines().enumerate() {
+                    println!("{:>5} │ {}", style(idx + 1).cyan().dim().bold(), line);
+                }
+                println!("──────┴{:─^1$}", "", width.saturating_sub(7));
+            }
+            SnapshotContents::Binary(_) => {
+                println!(
+                    "{}",
+                    encode_file_link_escape(
+                        &self
+                            .new_snapshot
+                            .build_binary_path(
+                                self.snapshot_file.unwrap().with_extension("snap.new")
+                            )
+                            .unwrap()
+                    )
+                );
+            }
         }
-        println!("──────┴{:─^1$}", "", width.saturating_sub(7));
     }
 
     fn print_changeset(&self) {
-        let old: String = self
-            .old_snapshot
-            .map_or("".to_string(), |x| x.contents_string());
-        let new = self.new_snapshot.contents_string();
-        let newlines_matter = newlines_matter(old.as_str(), new.as_str());
-
         let width = term_width();
-        let diff = TextDiff::configure()
-            .algorithm(Algorithm::Patience)
-            .timeout(Duration::from_millis(500))
-            .diff_lines(old.as_str(), new.as_str());
         print_line(width);
 
         if self.show_info {
             self.print_info();
         }
 
-        if !old.is_empty() {
+        if let Some(old_snapshot) = self.old_snapshot {
+            if old_snapshot.contents().is_binary() {
+                println!(
+                    "{}",
+                    style(format_args!(
+                        "-{}: {}",
+                        self.old_snapshot_hint,
+                        encode_file_link_escape(
+                            &old_snapshot
+                                .build_binary_path(self.snapshot_file.unwrap())
+                                .unwrap()
+                        ),
+                    ))
+                    .red()
+                );
+            }
+        }
+
+        if self.new_snapshot.contents().is_binary() {
             println!(
                 "{}",
-                style(format_args!("-{}", self.old_snapshot_hint)).red()
+                style(format_args!(
+                    "+{}: {}",
+                    self.new_snapshot_hint,
+                    encode_file_link_escape(
+                        &self
+                            .new_snapshot
+                            .build_binary_path(
+                                self.snapshot_file.unwrap().with_extension("snap.new")
+                            )
+                            .unwrap()
+                    ),
+                ))
+                .green()
             );
         }
-        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));
+        if let Some((old, new)) = match (
+            self.old_snapshot.as_ref().map(|o| o.contents()),
+            self.new_snapshot.contents(),
+        ) {
+            (Some(SnapshotContents::Binary(_)) | None, SnapshotContents::Text(new)) => {
+                Some((None, Some(new.to_string())))
+            }
+            (Some(SnapshotContents::Text(old)), SnapshotContents::Binary { .. }) => {
+                Some((Some(old.to_string()), None))
+            }
+            (Some(SnapshotContents::Text(old)), SnapshotContents::Text(new)) => {
+                Some((Some(old.to_string()), Some(new.to_string())))
             }
-            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());
+            _ => None,
+        } {
+            let old_text = old.as_deref().unwrap_or("");
+            let new_text = new.as_deref().unwrap_or("");
+
+            let newlines_matter = newlines_matter(old_text, new_text);
+            let diff = TextDiff::configure()
+                .algorithm(Algorithm::Patience)
+                .timeout(Duration::from_millis(500))
+                .diff_lines(old_text, new_text);
+
+            if old.is_some() {
+                println!(
+                    "{}",
+                    style(format_args!("-{}", self.old_snapshot_hint)).red()
+                );
+            }
+
+            if new.is_some() {
+                println!(
+                    "{}",
+                    style(format_args!("+{}", self.new_snapshot_hint)).green()
+                );
+            }
+
+            println!("────────────┬{:─^1$}", "", width.saturating_sub(13));
+
+            // This is to make sure that binary and text snapshots are never reported as being
+            // equal (that would otherwise happen if the text snapshot is an empty string).
+            let mut has_changes = old.is_none() || new.is_none();
+
+            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::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());
+                            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 change.missing_newline() {
+                            println!();
+                        }
                     }
                 }
             }
-        }
 
-        if !has_changes {
-            println!(
-                "{:>5} {:>5} │{}",
-                "",
-                style("-").dim(),
-                style(" snapshots are matching").cyan(),
-            );
-        }
+            if !has_changes {
+                println!(
+                    "{:>5} {:>5} │{}",
+                    "",
+                    style("-").dim(),
+                    style(" snapshots are matching").cyan(),
+                );
+            }
 
-        println!("────────────┴{:─^1$}", "", width.saturating_sub(13));
+            println!("────────────┴{:─^1$}", "", width.saturating_sub(13));
+        }
     }
 }
 
@@ -354,6 +431,17 @@ fn print_info(metadata: &MetaData) {
     }
 }
 
+/// Encodes a path as an OSC-8 escape sequence. This makes it a clickable link in supported
+/// terminal emulators.
+fn encode_file_link_escape(path: &Path) -> String {
+    assert!(path.is_absolute());
+    format!(
+        "\x1b]8;;file://{}\x1b\\{}\x1b]8;;\x1b\\",
+        path.display(),
+        path.display()
+    )
+}
+
 #[test]
 fn test_invisible() {
     assert_eq!(
diff --git a/insta/src/runtime.rs b/insta/src/runtime.rs
index e852cf2..5bc0d74 100644
--- a/insta/src/runtime.rs
+++ b/insta/src/runtime.rs
@@ -2,14 +2,17 @@ use std::cell::RefCell;
 use std::collections::{BTreeMap, BTreeSet};
 use std::error::Error;
 use std::fs;
-use std::io::Write;
+use std::io::{ErrorKind, Write};
 use std::path::{Path, PathBuf};
+use std::rc::Rc;
 use std::str;
 use std::sync::{Arc, Mutex};
 use std::{borrow::Cow, env};
 
 use crate::settings::Settings;
-use crate::snapshot::{MetaData, PendingInlineSnapshot, Snapshot, SnapshotContents};
+use crate::snapshot::{
+    MetaData, PendingInlineSnapshot, Snapshot, SnapshotContents, SnapshotKind, TextSnapshotContents,
+};
 use crate::utils::{path_to_storage, style};
 use crate::{env::get_tool_config, output::SnapshotPrinter};
 use crate::{
@@ -17,7 +20,7 @@ use crate::{
         memoize_snapshot_file, snapshot_update_behavior, OutputBehavior, SnapshotUpdateBehavior,
         ToolConfig,
     },
-    snapshot::SnapshotKind,
+    snapshot::TextSnapshotKind,
 };
 
 lazy_static::lazy_static! {
@@ -62,43 +65,126 @@ macro_rules! print_or_panic {
 #[derive(Debug)]
 pub struct AutoName;
 
-impl From<AutoName> for ReferenceValue<'static> {
-    fn from(_value: AutoName) -> ReferenceValue<'static> {
-        ReferenceValue::File(None)
+pub struct InlineValue<'a>(pub &'a str);
+
+/// The name of a snapshot, from which the path is derived.
+type SnapshotName<'a> = Option<Cow<'a, str>>;
+
+pub struct BinarySnapshotValue<'a> {
+    pub name_and_extension: &'a str,
+    pub content: Vec<u8>,
+}
+
+pub enum SnapshotValue<'a> {
+    /// A text snapshot that gets stored along with the metadata in the same file.
+    FileText {
+        name: SnapshotName<'a>,
+
+        /// The new generated value to compare against any previously approved content.
+        content: &'a str,
+    },
+
+    /// An inline snapshot.
+    InlineText {
+        /// The reference content from the macro invocation that will be compared against.
+        reference_content: &'a str,
+
+        /// The new generated value to compare against any previously approved content.
+        content: &'a str,
+    },
+
+    /// A binary snapshot that gets stored as a separate file next to the metadata file.
+    Binary {
+        name: SnapshotName<'a>,
+
+        /// The new generated value to compare against any previously approved content.
+        content: Vec<u8>,
+
+        /// The extension of the separate file.
+        extension: &'a str,
+    },
+}
+
+impl<'a> From<(AutoName, &'a str)> for SnapshotValue<'a> {
+    fn from((_, content): (AutoName, &'a str)) -> Self {
+        SnapshotValue::FileText {
+            name: None,
+            content,
+        }
     }
 }
 
-impl From<Option<String>> for ReferenceValue<'static> {
-    fn from(value: Option<String>) -> ReferenceValue<'static> {
-        ReferenceValue::File(value.map(Cow::Owned))
+impl<'a> From<(Option<String>, &'a str)> for SnapshotValue<'a> {
+    fn from((name, content): (Option<String>, &'a str)) -> Self {
+        SnapshotValue::FileText {
+            name: name.map(Cow::Owned),
+            content,
+        }
     }
 }
 
-impl From<String> for ReferenceValue<'static> {
-    fn from(value: String) -> ReferenceValue<'static> {
-        ReferenceValue::File(Some(Cow::Owned(value)))
+impl<'a> From<(String, &'a str)> for SnapshotValue<'a> {
+    fn from((name, content): (String, &'a str)) -> Self {
+        SnapshotValue::FileText {
+            name: Some(Cow::Owned(name)),
+            content,
+        }
     }
 }
 
-impl<'a> From<Option<&'a str>> for ReferenceValue<'a> {
-    fn from(value: Option<&'a str>) -> ReferenceValue<'a> {
-        ReferenceValue::File(value.map(Cow::Borrowed))
+impl<'a> From<(Option<&'a str>, &'a str)> for SnapshotValue<'a> {
+    fn from((name, content): (Option<&'a str>, &'a str)) -> Self {
+        SnapshotValue::FileText {
+            name: name.map(Cow::Borrowed),
+            content,
+        }
     }
 }
 
-impl<'a> From<&'a str> for ReferenceValue<'a> {
-    fn from(value: &'a str) -> ReferenceValue<'a> {
-        ReferenceValue::File(Some(Cow::Borrowed(value)))
+impl<'a> From<(&'a str, &'a str)> for SnapshotValue<'a> {
+    fn from((name, content): (&'a str, &'a str)) -> Self {
+        SnapshotValue::FileText {
+            name: Some(Cow::Borrowed(name)),
+            content,
+        }
     }
 }
 
-#[derive(Debug)]
-/// A reference to a snapshot
-pub enum ReferenceValue<'a> {
-    /// A file snapshot, where the inner value is the snapshot name.
-    File(Option<Cow<'a, str>>),
-    /// An inline snapshot, where the inner value is the snapshot contents.
-    Inline(&'a str),
+impl<'a> From<(InlineValue<'a>, &'a str)> for SnapshotValue<'a> {
+    fn from((InlineValue(reference_content), content): (InlineValue<'a>, &'a str)) -> Self {
+        SnapshotValue::InlineText {
+            reference_content,
+            content,
+        }
+    }
+}
+
+impl<'a> From<BinarySnapshotValue<'a>> for SnapshotValue<'a> {
+    fn from(
+        BinarySnapshotValue {
+            name_and_extension,
+            content,
+        }: BinarySnapshotValue<'a>,
+    ) -> Self {
+        let (name, extension) = name_and_extension.split_once('.').unwrap_or_else(|| {
+            panic!(
+                "\"{}\" does not match the format \"name.extension\"",
+                name_and_extension,
+            )
+        });
+
+        let name = if name.is_empty() {
+            None
+        } else {
+            Some(Cow::Borrowed(name))
+        };
+
+        SnapshotValue::Binary {
+            name,
+            extension,
+            content,
+        }
+    }
 }
 
 fn is_doctest(function_name: &str) -> bool {
@@ -226,11 +312,12 @@ struct SnapshotAssertionContext<'a> {
     assertion_file: &'a str,
     assertion_line: u32,
     is_doctest: bool,
+    snapshot_kind: SnapshotKind,
 }
 
 impl<'a> SnapshotAssertionContext<'a> {
     fn prepare(
-        refval: ReferenceValue<'a>,
+        new_snapshot_value: &SnapshotValue<'a>,
         workspace: &'a Path,
         function_name: &'a str,
         module_path: &'a str,
@@ -245,10 +332,10 @@ impl<'a> SnapshotAssertionContext<'a> {
         let mut pending_snapshots_path = None;
         let is_doctest = is_doctest(function_name);
 
-        match refval {
-            ReferenceValue::File(name) => {
-                let name = match name {
-                    Some(name) => add_suffix_to_snapshot_name(name),
+        match new_snapshot_value {
+            SnapshotValue::FileText { name, .. } | SnapshotValue::Binary { name, .. } => {
+                let name = match &name {
+                    Some(name) => add_suffix_to_snapshot_name(name.clone()),
                     None => {
                         if is_doctest {
                             panic!("Cannot determine reliable names for snapshot in doctests.  Please use explicit names instead.");
@@ -274,7 +361,10 @@ impl<'a> SnapshotAssertionContext<'a> {
                 snapshot_name = Some(name);
                 snapshot_file = Some(file);
             }
-            ReferenceValue::Inline(contents) => {
+            SnapshotValue::InlineText {
+                reference_content: contents,
+                ..
+            } => {
                 if allow_duplicates() {
                     duplication_key = Some(format!(
                         "inline:{}|{}|{}",
@@ -300,11 +390,19 @@ impl<'a> SnapshotAssertionContext<'a> {
                     module_path.replace("::", "__"),
                     None,
                     MetaData::default(),
-                    SnapshotContents::new(contents.to_string(), SnapshotKind::Inline),
+                    TextSnapshotContents::new(contents.to_string(), TextSnapshotKind::Inline)
+                        .into(),
                 ));
             }
         };
 
+        let snapshot_type = match new_snapshot_value {
+            SnapshotValue::FileText { .. } | SnapshotValue::InlineText { .. } => SnapshotKind::Text,
+            &SnapshotValue::Binary { extension, .. } => SnapshotKind::Binary {
+                extension: extension.to_string(),
+            },
+        };
+
         Ok(SnapshotAssertionContext {
             tool_config,
             workspace,
@@ -317,6 +415,7 @@ impl<'a> SnapshotAssertionContext<'a> {
             assertion_line,
             duplication_key,
             is_doctest,
+            snapshot_kind: snapshot_type,
         })
     }
 
@@ -329,6 +428,11 @@ impl<'a> SnapshotAssertionContext<'a> {
 
     /// Creates the new snapshot from input values.
     pub fn new_snapshot(&self, contents: SnapshotContents, expr: &str) -> Snapshot {
+        assert_eq!(
+            contents.is_binary(),
+            matches!(self.snapshot_kind, SnapshotKind::Binary { .. })
+        );
+
         Snapshot::from_components(
             self.module_path.replace("::", "__"),
             self.snapshot_name.as_ref().map(|x| x.to_string()),
@@ -346,6 +450,7 @@ impl<'a> SnapshotAssertionContext<'a> {
                     .input_file()
                     .and_then(|x| self.localize_path(x))
                     .map(|x| path_to_storage(&x)),
+                snapshot_kind: self.snapshot_kind.clone(),
             }),
             contents,
         )
@@ -370,6 +475,43 @@ impl<'a> SnapshotAssertionContext<'a> {
         Ok(())
     }
 
+    /// Removes any old .snap.new.* files that belonged to previous pending snapshots. This should
+    /// only ever remove maxium one file because we do this every time before we create a new
+    /// pending snapshot.
+    pub fn cleanup_previous_pending_binary_snapshots(&self) -> Result<(), Box<dyn Error>> {
+        if let Some(ref path) = self.snapshot_file {
+            // The file name to compare against has to be valid utf-8 as it is generated by this crate
+            // out of utf-8 strings.
+            let file_name_prefix = format!("{}.new.", path.file_name().unwrap().to_str().unwrap());
+
+            let read_dir = path.parent().unwrap().read_dir();
+
+            match read_dir {
+                Err(e) if e.kind() == ErrorKind::NotFound => return Ok(()),
+                _ => (),
+            }
+
+            // We have to loop over where whole directory here because there is no filesystem API
+            // for getting files by prefix.
+            for entry in read_dir? {
+                let entry = entry?;
+                let entry_file_name = entry.file_name();
+
+                // We'll just skip over files with non-utf-8 names. The assumption being that those
+                // would not have been generated by this crate.
+                if entry_file_name
+                    .to_str()
+                    .map(|f| f.starts_with(&file_name_prefix))
+                    .unwrap_or(false)
+                {
+                    std::fs::remove_file(entry.path())?;
+                }
+            }
+        }
+
+        Ok(())
+    }
+
     /// Writes the changes of the snapshot back.
     pub fn update_snapshot(
         &self,
@@ -613,8 +755,7 @@ where
 /// assertion with a panic if needed.
 #[allow(clippy::too_many_arguments)]
 pub fn assert_snapshot(
-    refval: ReferenceValue,
-    new_snapshot_value: &str,
+    snapshot_value: SnapshotValue<'_>,
     workspace: &Path,
     function_name: &str,
     module_path: &str,
@@ -623,7 +764,7 @@ pub fn assert_snapshot(
     expr: &str,
 ) -> Result<(), Box<dyn Error>> {
     let ctx = SnapshotAssertionContext::prepare(
-        refval,
+        &snapshot_value,
         workspace,
         function_name,
         module_path,
@@ -631,17 +772,38 @@ pub fn assert_snapshot(
         assertion_line,
     )?;
 
-    // apply filters if they are available
-    #[cfg(feature = "filters")]
-    let new_snapshot_value =
-        Settings::with(|settings| settings.filters().apply_to(new_snapshot_value));
+    ctx.cleanup_previous_pending_binary_snapshots()?;
+
+    let content = match snapshot_value {
+        SnapshotValue::FileText { content, .. } | SnapshotValue::InlineText { content, .. } => {
+            // apply filters if they are available
+            #[cfg(feature = "filters")]
+            let content = Settings::with(|settings| settings.filters().apply_to(content));
+
+            let kind = match ctx.snapshot_file {
+                Some(_) => TextSnapshotKind::File,
+                None => TextSnapshotKind::Inline,
+            };
 
-    let kind = match ctx.snapshot_file {
-        Some(_) => SnapshotKind::File,
-        None => SnapshotKind::Inline,
+            TextSnapshotContents::new(content.into(), kind).into()
+        }
+        SnapshotValue::Binary {
+            content, extension, ..
+        } => {
+            assert!(
+                extension != "new",
+                "'.new' is not allowed as a file extension"
+            );
+            assert!(
+                !extension.starts_with("new."),
+                "file extensions starting with 'new.' are not allowed",
+            );
+
+            SnapshotContents::Binary(Rc::new(content))
+        }
     };
-    let new_snapshot =
-        ctx.new_snapshot(SnapshotContents::new(new_snapshot_value.into(), kind), expr);
+
+    let new_snapshot = ctx.new_snapshot(content, expr);
 
     // memoize the snapshot file if requested, as part of potentially removing unreferenced snapshots
     if let Some(ref snapshot_file) = ctx.snapshot_file {
diff --git a/insta/src/snapshot.rs b/insta/src/snapshot.rs
index e1df2c3..08037b8 100644
--- a/insta/src/snapshot.rs
+++ b/insta/src/snapshot.rs
@@ -3,6 +3,7 @@ use std::error::Error;
 use std::fs;
 use std::io::{BufRead, BufReader, Write};
 use std::path::{Path, PathBuf};
+use std::rc::Rc;
 use std::time::{SystemTime, UNIX_EPOCH};
 use std::{borrow::Cow, fmt};
 
@@ -94,10 +95,10 @@ impl PendingInlineSnapshot {
                     Some("run_id") => run_id = value.as_str().map(|x| x.to_string()),
                     Some("line") => line = value.as_u64().map(|x| x as u32),
                     Some("old") if !value.is_nil() => {
-                        old = Some(Snapshot::from_content(value, SnapshotKind::Inline)?)
+                        old = Some(Snapshot::from_content(value, TextSnapshotKind::Inline)?)
                     }
                     Some("new") if !value.is_nil() => {
-                        new = Some(Snapshot::from_content(value, SnapshotKind::Inline)?)
+                        new = Some(Snapshot::from_content(value, TextSnapshotKind::Inline)?)
                     }
                     _ => {}
                 }
@@ -138,6 +139,18 @@ impl PendingInlineSnapshot {
     }
 }
 
+#[derive(Debug, Clone, PartialEq)]
+pub enum SnapshotKind {
+    Text,
+    Binary { extension: String },
+}
+
+impl Default for SnapshotKind {
+    fn default() -> Self {
+        SnapshotKind::Text
+    }
+}
+
 /// Snapshot metadata information.
 #[derive(Debug, Default, Clone, PartialEq)]
 pub struct MetaData {
@@ -154,6 +167,8 @@ pub struct MetaData {
     pub(crate) info: Option<Content>,
     /// Reference to the input file.
     pub(crate) input_file: Option<String>,
+    /// The type of the snapshot (string or binary).
+    pub(crate) snapshot_kind: SnapshotKind,
 }
 
 impl MetaData {
@@ -207,6 +222,13 @@ impl MetaData {
             let mut expression = None;
             let mut info = None;
             let mut input_file = None;
+            let mut snapshot_type = TmpSnapshotKind::Text;
+            let mut extension = None;
+
+            enum TmpSnapshotKind {
+                Text,
+                Binary,
+            }
 
             for (key, value) in map.into_iter() {
                 match key.as_str() {
@@ -216,6 +238,15 @@ impl MetaData {
                     Some("expression") => expression = value.as_str().map(Into::into),
                     Some("info") if !value.is_nil() => info = Some(value),
                     Some("input_file") => input_file = value.as_str().map(Into::into),
+                    Some("snapshot_kind") => {
+                        snapshot_type = match value.as_str() {
+                            Some("binary") => TmpSnapshotKind::Binary,
+                            _ => TmpSnapshotKind::Text,
+                        }
+                    }
+                    Some("extension") => {
+                        extension = value.as_str().map(Into::into);
+                    }
                     _ => {}
                 }
             }
@@ -227,6 +258,12 @@ impl MetaData {
                 expression,
                 info,
                 input_file,
+                snapshot_kind: match snapshot_type {
+                    TmpSnapshotKind::Text => SnapshotKind::Text,
+                    TmpSnapshotKind::Binary => SnapshotKind::Binary {
+                        extension: extension.ok_or(content::Error::MissingField)?,
+                    },
+                },
             })
         } else {
             Err(content::Error::UnexpectedDataType.into())
@@ -254,6 +291,16 @@ impl MetaData {
             fields.push(("input_file", Content::from(input_file)));
         }
 
+        let snapshot_type = Content::from(match self.snapshot_kind {
+            SnapshotKind::Text => "text",
+            SnapshotKind::Binary { ref extension } => {
+                fields.push(("extension", Content::from(extension.clone())));
+                "binary"
+            }
+        });
+
+        fields.push(("snapshot_kind", snapshot_type));
+
         Content::Struct("MetaData", fields)
     }
 
@@ -277,8 +324,8 @@ impl MetaData {
     }
 }
 
-#[derive(Debug, Clone, Copy)]
-pub enum SnapshotKind {
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+pub enum TextSnapshotKind {
     Inline,
     File,
 }
@@ -342,14 +389,30 @@ impl Snapshot {
             rv
         };
 
-        buf.clear();
-        for (idx, line) in f.lines().enumerate() {
-            let line = line?;
-            if idx > 0 {
-                buf.push('\n');
+        let contents = match metadata.snapshot_kind {
+            SnapshotKind::Text => {
+                buf.clear();
+                for (idx, line) in f.lines().enumerate() {
+                    let line = line?;
+                    if idx > 0 {
+                        buf.push('\n');
+                    }
+                    buf.push_str(&line);
+                }
+
+                TextSnapshotContents {
+                    contents: buf,
+                    kind: TextSnapshotKind::File,
+                }
+                .into()
             }
-            buf.push_str(&line);
-        }
+            SnapshotKind::Binary { ref extension } => {
+                let path = build_binary_path(extension, p);
+                let contents = fs::read(path)?;
+
+                SnapshotContents::Binary(Rc::new(contents))
+            }
+        };
 
         let (snapshot_name, module_name) = names_of_path(p);
 
@@ -357,10 +420,7 @@ impl Snapshot {
             module_name,
             Some(snapshot_name),
             metadata,
-            SnapshotContents {
-                contents: buf,
-                kind: SnapshotKind::File,
-            },
+            contents,
         ))
     }
 
@@ -380,7 +440,7 @@ impl Snapshot {
     }
 
     #[cfg(feature = "_cargo_insta_internal")]
-    fn from_content(content: Content, kind: SnapshotKind) -> Result<Snapshot, Box<dyn Error>> {
+    fn from_content(content: Content, kind: TextSnapshotKind) -> Result<Snapshot, Box<dyn Error>> {
         if let Content::Map(map) = content {
             let mut module_name = None;
             let mut snapshot_name = None;
@@ -393,13 +453,16 @@ impl Snapshot {
                     Some("snapshot_name") => snapshot_name = value.as_str().map(|x| x.to_string()),
                     Some("metadata") => metadata = Some(MetaData::from_content(value)?),
                     Some("snapshot") => {
-                        snapshot = Some(SnapshotContents {
-                            contents: value
-                                .as_str()
-                                .ok_or(content::Error::UnexpectedDataType)?
-                                .to_string(),
-                            kind,
-                        });
+                        snapshot = Some(
+                            TextSnapshotContents {
+                                contents: value
+                                    .as_str()
+                                    .ok_or(content::Error::UnexpectedDataType)?
+                                    .to_string(),
+                                kind,
+                            }
+                            .into(),
+                        );
                     }
                     _ => {}
                 }
@@ -424,7 +487,10 @@ impl Snapshot {
             fields.push(("snapshot_name", Content::from(name)));
         }
         fields.push(("metadata", self.metadata.as_content()));
-        fields.push(("snapshot", Content::from(self.snapshot.to_string())));
+
+        if let SnapshotContents::Text(ref content) = self.snapshot {
+            fields.push(("snapshot", Content::from(content.to_string())));
+        }
 
         Content::Struct("Content", fields)
     }
@@ -450,38 +516,49 @@ impl Snapshot {
     }
 
     /// Snapshot contents match another snapshot's.
-    pub fn matches(&self, other: &Snapshot) -> bool {
+    pub fn matches(&self, other: &Self) -> bool {
         self.contents() == other.contents()
-    }
-
-    pub fn kind(&self) -> SnapshotKind {
-        self.snapshot.kind
+            // For binary snapshots the extension also need to be the same:
+            && self.metadata.snapshot_kind == other.metadata.snapshot_kind
     }
 
     /// Both the exact snapshot contents and the persisted metadata match another snapshot's.
     // (could rename to `matches_exact` for consistency, after some current
     // pending merge requests are merged)
-    pub fn matches_fully(&self, other: &Snapshot) -> bool {
-        let contents_match_exact = self.contents().matches_fully(other.contents());
-        match self.kind() {
-            SnapshotKind::File => {
-                self.metadata.trim_for_persistence() == other.metadata.trim_for_persistence()
-                    && contents_match_exact
+    pub fn matches_fully(&self, other: &Self) -> bool {
+        match (self.contents(), other.contents()) {
+            (SnapshotContents::Text(self_contents), SnapshotContents::Text(other_contents)) => {
+                let contents_match_exact = self_contents == other_contents;
+                match self_contents.kind {
+                    TextSnapshotKind::File => {
+                        self.metadata.trim_for_persistence()
+                            == other.metadata.trim_for_persistence()
+                            && contents_match_exact
+                    }
+                    TextSnapshotKind::Inline => contents_match_exact,
+                }
             }
-            SnapshotKind::Inline => contents_match_exact,
+            _ => self.matches(other),
         }
     }
 
     /// The normalized snapshot contents as a String
-    pub fn contents_string(&self) -> String {
-        self.snapshot.normalize()
+    pub fn contents_string(&self) -> Option<String> {
+        match self.contents() {
+            SnapshotContents::Text(contents) => Some(contents.normalize()),
+            SnapshotContents::Binary(_) => None,
+        }
     }
 
     fn serialize_snapshot(&self, md: &MetaData) -> String {
         let mut buf = yaml::to_string(&md.as_content());
         buf.push_str("---\n");
-        buf.push_str(self.contents_string().as_str());
-        buf.push('\n');
+
+        if let Some(ref contents_str) = self.contents_string() {
+            buf.push_str(contents_str);
+            buf.push('\n');
+        }
+
         buf
     }
 
@@ -493,9 +570,23 @@ impl Snapshot {
         let serialized_snapshot = self.serialize_snapshot(md);
         fs::write(path, serialized_snapshot)
             .map_err(|e| content::Error::FileIo(e, path.to_path_buf()))?;
+
+        if let SnapshotContents::Binary(ref contents) = self.snapshot {
+            fs::write(self.build_binary_path(path).unwrap(), &**contents)
+                .map_err(|e| content::Error::FileIo(e, path.to_path_buf()))?;
+        }
+
         Ok(())
     }
 
+    pub fn build_binary_path(&self, path: impl Into<PathBuf>) -> Option<PathBuf> {
+        if let SnapshotKind::Binary { ref extension } = self.metadata.snapshot_kind {
+            Some(build_binary_path(extension, path))
+        } else {
+            None
+        }
+    }
+
     /// Saves the snapshot.
     ///
     /// Returns `true` if the snapshot was saved.  This will return `false` if there
@@ -517,39 +608,59 @@ impl Snapshot {
 }
 
 /// The contents of a Snapshot
-// Could be Cow, but I think limited savings
 #[derive(Debug, Clone)]
-pub struct SnapshotContents {
+pub enum SnapshotContents {
+    Text(TextSnapshotContents),
+
+    // This is in an `Rc` because we need to be able to clone this struct cheaply and the contents
+    // of the `Vec` could be rather large. The reason it's not an `Rc<[u8]>` is because creating one
+    // of those would require re-allocating because of the additional size needed for the reference
+    // count.
+    Binary(Rc<Vec<u8>>),
+}
+
+// Could be Cow, but I think limited savings
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct TextSnapshotContents {
     contents: String,
-    pub kind: SnapshotKind,
+    pub kind: TextSnapshotKind,
+}
+
+impl From<TextSnapshotContents> for SnapshotContents {
+    fn from(value: TextSnapshotContents) -> Self {
+        SnapshotContents::Text(value)
+    }
 }
 
 impl SnapshotContents {
-    pub fn new(contents: String, kind: SnapshotKind) -> SnapshotContents {
-        // We could store a normalized version of the string as part of `new`;
-        // it would avoid allocating a new `String` when we get the normalized
-        // versions, which we may do a few times. (We want to store the
-        // unnormalized version because it allows us to use `matches_fully`.)
-        SnapshotContents { contents, kind }
+    pub fn is_binary(&self) -> bool {
+        matches!(self, SnapshotContents::Binary(_))
     }
 
-    /// Returns the snapshot contents as string without any normalization
-    pub fn as_str_exact(&self) -> &str {
-        self.contents.as_str()
+    pub fn as_string_contents(&self) -> Option<&TextSnapshotContents> {
+        match self {
+            SnapshotContents::Text(contents) => Some(contents),
+            SnapshotContents::Binary(_) => None,
+        }
     }
+}
 
-    /// Matches another snapshot without any normalization
-    pub fn matches_fully(&self, other: &SnapshotContents) -> bool {
-        self.as_str_exact() == other.as_str_exact()
+impl TextSnapshotContents {
+    pub fn new(contents: String, kind: TextSnapshotKind) -> TextSnapshotContents {
+        // We could store a normalized version of the string as part of `new`;
+        // it would avoid allocating a new `String` when we get the normalized
+        // versions, which we may do a few times. (We want to store the
+        // unnormalized version because it allows us to use `matches_fully`.)
+        TextSnapshotContents { contents, kind }
     }
 
     /// Snapshot matches based on the latest format.
-    pub fn matches_latest(&self, other: &SnapshotContents) -> bool {
+    pub fn matches_latest(&self, other: &Self) -> bool {
         self.to_string() == other.to_string()
     }
 
-    pub fn matches_legacy(&self, other: &SnapshotContents) -> bool {
-        fn as_str_legacy(sc: &SnapshotContents) -> String {
+    pub fn matches_legacy(&self, other: &Self) -> bool {
+        fn as_str_legacy(sc: &TextSnapshotContents) -> String {
             let out = sc.to_string();
             // Legacy inline snapshots have `---` at the start, so this strips that if
             // it exists.
@@ -558,13 +669,25 @@ impl SnapshotContents {
                 None => out,
             };
             match sc.kind {
-                SnapshotKind::Inline => legacy_inline_normalize(&out),
-                SnapshotKind::File => out,
+                TextSnapshotKind::Inline => legacy_inline_normalize(&out),
+                TextSnapshotKind::File => out,
             }
         }
         as_str_legacy(self) == as_str_legacy(other)
     }
 
+    fn normalize(&self) -> String {
+        let kind_specific_normalization = match self.kind {
+            TextSnapshotKind::Inline => normalize_inline_snapshot(&self.contents),
+            TextSnapshotKind::File => self.contents.clone(),
+        };
+        // Then this we do for both kinds
+        let out = kind_specific_normalization
+            .trim_start_matches(['\r', '\n'])
+            .trim_end();
+        out.replace("\r\n", "\n")
+    }
+
     /// Returns the string literal, including `#` delimiters, to insert into a
     /// Rust source file.
     pub fn to_inline(&self, indentation: usize) -> String {
@@ -611,24 +734,11 @@ impl SnapshotContents {
 
         out.push('"');
         out.push_str(&delimiter);
-
         out
     }
-
-    fn normalize(&self) -> String {
-        let kind_specific_normalization = match self.kind {
-            SnapshotKind::Inline => normalize_inline_snapshot(&self.contents),
-            SnapshotKind::File => self.contents.clone(),
-        };
-        // Then this we do for both kinds
-        let out = kind_specific_normalization
-            .trim_start_matches(['\r', '\n'])
-            .trim_end();
-        out.replace("\r\n", "\n")
-    }
 }
 
-impl fmt::Display for SnapshotContents {
+impl fmt::Display for TextSnapshotContents {
     /// Returns the snapshot contents as a normalized string (for example,
     /// removing surrounding whitespace)
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -638,18 +748,33 @@ impl fmt::Display for SnapshotContents {
 
 impl PartialEq for SnapshotContents {
     fn eq(&self, other: &Self) -> bool {
-        // Ideally match on current rules, but otherwise fall back to legacy rules
-        if self.matches_latest(other) {
-            true
-        } else if self.matches_legacy(other) {
-            elog!("{} {}\n{}",style("Snapshot passes but is a legacy format. Please run `cargo insta test --force-update-snapshots --accept` to update to a newer format.").yellow().bold(),"Snapshot contents:", self.to_string());
-            true
-        } else {
-            false
+        match (self, other) {
+            (SnapshotContents::Text(this), SnapshotContents::Text(other)) => {
+                // Ideally match on current rules, but otherwise fall back to legacy rules
+                if this.matches_latest(other) {
+                    true
+                } else if this.matches_legacy(other) {
+                    elog!("{} {}\n{}",style("Snapshot passes but is a legacy format. Please run `cargo insta test --force-update-snapshots --accept` to update to a newer format.").yellow().bold(),"Snapshot contents:", this.to_string());
+                    true
+                } else {
+                    false
+                }
+            }
+            (SnapshotContents::Binary(this), SnapshotContents::Binary(other)) => this == other,
+            _ => false,
         }
     }
 }
 
+fn build_binary_path(extension: &str, path: impl Into<PathBuf>) -> PathBuf {
+    let path = path.into();
+    let mut new_extension = path.extension().unwrap().to_os_string();
+    new_extension.push(".");
+    new_extension.push(extension);
+
+    path.with_extension(new_extension)
+}
+
 /// The number of `#` we need to surround a raw string literal with.
 fn required_hashes(text: &str) -> usize {
     let splits = text.split('"');
@@ -800,13 +925,15 @@ fn legacy_inline_normalize(frozen_value: &str) -> String {
 #[test]
 fn test_snapshot_contents() {
     use similar_asserts::assert_eq;
-    assert_eq!(
-        SnapshotContents::new("testing".to_string(), SnapshotKind::Inline).to_inline(0),
-        r#""testing""#
-    );
+    let snapshot_contents =
+        TextSnapshotContents::new("testing".to_string(), TextSnapshotKind::Inline);
+    assert_eq!(snapshot_contents.to_inline(0), r#""testing""#);
 
+    let t = &"
+a
+b"[1..];
     assert_eq!(
-        SnapshotContents::new("\na\nb".to_string(), SnapshotKind::Inline).to_inline(0),
+        TextSnapshotContents::new(t.to_string(), TextSnapshotKind::Inline).to_inline(0),
         r##"r"
 a
 b
@@ -814,7 +941,7 @@ b
     );
 
     assert_eq!(
-        SnapshotContents::new("a\nb".to_string(), SnapshotKind::Inline).to_inline(4),
+        TextSnapshotContents::new("a\nb".to_string(), TextSnapshotKind::Inline).to_inline(4),
         r##"r"
     a
     b
@@ -822,7 +949,8 @@ b
     );
 
     assert_eq!(
-        SnapshotContents::new("\n    a\n    b".to_string(), SnapshotKind::Inline).to_inline(0),
+        TextSnapshotContents::new("\n    a\n    b".to_string(), TextSnapshotKind::Inline)
+            .to_inline(0),
         r##"r"
 a
 b
@@ -830,7 +958,7 @@ b
     );
 
     assert_eq!(
-        SnapshotContents::new("\na\n\nb".to_string(), SnapshotKind::Inline).to_inline(4),
+        TextSnapshotContents::new("\na\n\nb".to_string(), TextSnapshotKind::Inline).to_inline(4),
         r##"r"
     a
 
@@ -839,12 +967,12 @@ b
     );
 
     assert_eq!(
-        SnapshotContents::new("\n    ab\n".to_string(), SnapshotKind::Inline).to_inline(0),
+        TextSnapshotContents::new("\n    ab\n".to_string(), TextSnapshotKind::Inline).to_inline(0),
         r##""ab""##
     );
 
     assert_eq!(
-        SnapshotContents::new("ab".to_string(), SnapshotKind::Inline).to_inline(0),
+        TextSnapshotContents::new("ab".to_string(), TextSnapshotKind::Inline).to_inline(0),
         r#""ab""#
     );
 }
@@ -852,12 +980,12 @@ b
 #[test]
 fn test_snapshot_contents_hashes() {
     assert_eq!(
-        SnapshotContents::new("a###b".to_string(), SnapshotKind::Inline).to_inline(0),
+        TextSnapshotContents::new("a###b".to_string(), TextSnapshotKind::Inline).to_inline(0),
         r#""a###b""#
     );
 
     assert_eq!(
-        SnapshotContents::new("a\n\\###b".to_string(), SnapshotKind::Inline).to_inline(0),
+        TextSnapshotContents::new("a\n\\###b".to_string(), TextSnapshotKind::Inline).to_inline(0),
         r#####"r"
 a
 \###b
@@ -875,7 +1003,7 @@ fn test_normalize_inline_snapshot() {
             r#"
    1
    2
-   "#
+   "#,
         ),
         r###"
 1


Test patch:
diff --git a/cargo-insta/tests/main.rs b/cargo-insta/tests/main.rs
index cc32bf6..1ea21df 100644
--- a/cargo-insta/tests/main.rs
+++ b/cargo-insta/tests/main.rs
@@ -246,6 +246,10 @@ impl TestProject {
             Some(("Original file tree", "Updated file tree")),
         )
     }
+
+    fn update_file<P: AsRef<Path>>(&self, path: P, content: String) {
+        fs::write(self.workspace_dir.join(path), content).unwrap();
+    }
 }
 
 #[test]
@@ -940,12 +944,13 @@ Hello, world!
     assert_snapshot!(test_current_insta.diff("src/snapshots/test_force_update_current__force_update.snap"), @r#"
     --- Original: src/snapshots/test_force_update_current__force_update.snap
     +++ Updated: src/snapshots/test_force_update_current__force_update.snap
-    @@ -1,8 +1,5 @@
+    @@ -1,8 +1,6 @@
     -
      ---
      source: src/lib.rs
     -expression: 
     +expression: "\"Hello, world!\""
+    +snapshot_kind: text
      ---
      Hello, world!
     -
@@ -955,12 +960,13 @@ Hello, world!
     assert_snapshot!(test_insta_1_40_0.diff("src/snapshots/test_force_update_1_40_0__force_update.snap"), @r#"
     --- Original: src/snapshots/test_force_update_1_40_0__force_update.snap
     +++ Updated: src/snapshots/test_force_update_1_40_0__force_update.snap
-    @@ -1,8 +1,5 @@
+    @@ -1,8 +1,6 @@
     -
      ---
      source: src/lib.rs
     -expression: 
     +expression: "\"Hello, world!\""
+    +snapshot_kind: text
      ---
      Hello, world!
     -
@@ -1210,6 +1216,418 @@ fn test_hashtag_escape() {
     "####);
 }
 
+/// A pending binary snapshot should have a binary file with the passed extension alongside it.
+#[test]
+fn test_binary_pending() {
+    let test_project = TestFiles::new()
+        .add_file(
+            "Cargo.toml",
+            r#"
+[package]
+name = "test_binary_pending"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+insta = { path = '$PROJECT_PATH' }
+"#
+            .to_string(),
+        )
+        .add_file(
+            "src/main.rs",
+            r#"
+#[test]
+fn test_binary_snapshot() {
+    insta::assert_binary_snapshot!(".txt", b"test".to_vec());
+}
+"#
+            .to_string(),
+        )
+        .create_project();
+
+    let output = test_project.insta_cmd().args(["test"]).output().unwrap();
+
+    assert!(!&output.status.success());
+
+    assert_snapshot!(test_project.file_tree_diff(), @r"
+    --- Original file tree
+    +++ Updated file tree
+    @@ -1,4 +1,8 @@
+     
+    +  Cargo.lock
+       Cargo.toml
+       src
+         src/main.rs
+    +    src/snapshots
+    +      src/snapshots/test_binary_pending__binary_snapshot.snap.new
+    +      src/snapshots/test_binary_pending__binary_snapshot.snap.new.txt
+    ");
+}
+
+/// An accepted binary snapshot should have a binary file with the passed extension alongside it.
+#[test]
+fn test_binary_accept() {
+    let test_project = TestFiles::new()
+        .add_file(
+            "Cargo.toml",
+            r#"
+[package]
+name = "test_binary_accept"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+insta = { path = '$PROJECT_PATH' }
+"#
+            .to_string(),
+        )
+        .add_file(
+            "src/main.rs",
+            r#"
+#[test]
+fn test_binary_snapshot() {
+    insta::assert_binary_snapshot!(".txt", b"test".to_vec());
+}
+"#
+            .to_string(),
+        )
+        .create_project();
+
+    let output = test_project
+        .insta_cmd()
+        .args(["test", "--accept"])
+        .output()
+        .unwrap();
+
+    assert!(&output.status.success());
+
+    assert_snapshot!(test_project.file_tree_diff(), @r"
+    --- Original file tree
+    +++ Updated file tree
+    @@ -1,4 +1,8 @@
+     
+    +  Cargo.lock
+       Cargo.toml
+       src
+         src/main.rs
+    +    src/snapshots
+    +      src/snapshots/test_binary_accept__binary_snapshot.snap
+    +      src/snapshots/test_binary_accept__binary_snapshot.snap.txt
+    ");
+}
+
+/// Changing the extension passed to the `assert_binary_snapshot` macro should create a new pending
+/// snapshot with a binary file with the new extension alongside it and once approved the old binary
+/// file with the old extension should be deleted.
+#[test]
+fn test_binary_change_extension() {
+    let test_project = TestFiles::new()
+        .add_file(
+            "Cargo.toml",
+            r#"
+[package]
+name = "test_binary_change_extension"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+insta = { path = '$PROJECT_PATH' }
+"#
+            .to_string(),
+        )
+        .add_file(
+            "src/main.rs",
+            r#"
+#[test]
+fn test_binary_snapshot() {
+    insta::assert_binary_snapshot!(".txt", b"test".to_vec());
+}
+"#
+            .to_string(),
+        )
+        .create_project();
+
+    let output = test_project
+        .insta_cmd()
+        .args(["test", "--accept"])
+        .output()
+        .unwrap();
+
+    assert!(&output.status.success());
+
+    test_project.update_file(
+        "src/main.rs",
+        r#"
+#[test]
+fn test_binary_snapshot() {
+    insta::assert_binary_snapshot!(".json", b"test".to_vec());
+}
+"#
+        .to_string(),
+    );
+
+    let output = test_project.insta_cmd().args(["test"]).output().unwrap();
+
+    assert!(!&output.status.success());
+
+    assert_snapshot!(test_project.file_tree_diff(), @r"
+    --- Original file tree
+    +++ Updated file tree
+    @@ -1,4 +1,10 @@
+     
+    +  Cargo.lock
+       Cargo.toml
+       src
+         src/main.rs
+    +    src/snapshots
+    +      src/snapshots/test_binary_change_extension__binary_snapshot.snap
+    +      src/snapshots/test_binary_change_extension__binary_snapshot.snap.new
+    +      src/snapshots/test_binary_change_extension__binary_snapshot.snap.new.json
+    +      src/snapshots/test_binary_change_extension__binary_snapshot.snap.txt
+    ");
+
+    let output = test_project
+        .insta_cmd()
+        .args(["test", "--accept"])
+        .output()
+        .unwrap();
+
+    assert!(&output.status.success());
+
+    assert_snapshot!(test_project.file_tree_diff(), @r"
+    --- Original file tree
+    +++ Updated file tree
+    @@ -1,4 +1,8 @@
+     
+    +  Cargo.lock
+       Cargo.toml
+       src
+         src/main.rs
+    +    src/snapshots
+    +      src/snapshots/test_binary_change_extension__binary_snapshot.snap
+    +      src/snapshots/test_binary_change_extension__binary_snapshot.snap.json
+    ");
+}
+
+/// An assert with a pending binary snapshot should have both the metadata file and the binary file
+/// deleted when the assert is removed and the tests are re-run.
+#[test]
+fn test_binary_pending_snapshot_removal() {
+    let test_project = TestFiles::new()
+        .add_file(
+            "Cargo.toml",
+            r#"
+[package]
+name = "test_binary_pending_snapshot_removal"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+insta = { path = '$PROJECT_PATH' }
+"#
+            .to_string(),
+        )
+        .add_file(
+            "src/main.rs",
+            r#"
+#[test]
+fn test_binary_snapshot() {
+    insta::assert_binary_snapshot!(".txt", b"test".to_vec());
+}
+"#
+            .to_string(),
+        )
+        .create_project();
+
+    let output = test_project.insta_cmd().args(["test"]).output().unwrap();
+
+    assert!(!&output.status.success());
+
+    test_project.update_file("src/main.rs", "".to_string());
+
+    let output = test_project.insta_cmd().args(["test"]).output().unwrap();
+
+    assert!(&output.status.success());
+
+    assert_snapshot!(test_project.file_tree_diff(), @r"
+    --- Original file tree
+    +++ Updated file tree
+    @@ -1,4 +1,6 @@
+     
+    +  Cargo.lock
+       Cargo.toml
+       src
+         src/main.rs
+    +    src/snapshots
+    ");
+}
+
+/// Replacing a text snapshot with binary one should work and simply replace the text snapshot file
+/// with the new metadata file and a new binary snapshot file alongside it.
+#[test]
+fn test_change_text_to_binary() {
+    let test_project = TestFiles::new()
+        .add_file(
+            "Cargo.toml",
+            r#"
+[package]
+name = "test_change_text_to_binary"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+insta = { path = '$PROJECT_PATH' }
+"#
+            .to_string(),
+        )
+        .add_file(
+            "src/main.rs",
+            r#"
+#[test]
+fn test() {
+    insta::assert_snapshot!("test");
+}
+"#
+            .to_string(),
+        )
+        .create_project();
+
+    let output = test_project
+        .insta_cmd()
+        .args(["test", "--accept"])
+        .output()
+        .unwrap();
+
+    assert!(&output.status.success());
+    assert_snapshot!(test_project.file_tree_diff(), @r"
+    --- Original file tree
+    +++ Updated file tree
+    @@ -1,4 +1,7 @@
+     
+    +  Cargo.lock
+       Cargo.toml
+       src
+         src/main.rs
+    +    src/snapshots
+    +      src/snapshots/test_change_text_to_binary__test.snap
+    ");
+
+    test_project.update_file(
+        "src/main.rs",
+        r#"
+#[test]
+fn test() {
+    insta::assert_binary_snapshot!(".txt", b"test".to_vec());
+}
+"#
+        .to_string(),
+    );
+
+    let output = test_project
+        .insta_cmd()
+        .args(["test", "--accept"])
+        .output()
+        .unwrap();
+
+    assert!(&output.status.success());
+    assert_snapshot!(test_project.file_tree_diff(), @r"
+    --- Original file tree
+    +++ Updated file tree
+    @@ -1,4 +1,8 @@
+     
+    +  Cargo.lock
+       Cargo.toml
+       src
+         src/main.rs
+    +    src/snapshots
+    +      src/snapshots/test_change_text_to_binary__test.snap
+    +      src/snapshots/test_change_text_to_binary__test.snap.txt
+    ");
+}
+
+/// When changing a snapshot from a binary to a text snapshot the previous binary file should be
+/// gone after having approved the the binary snapshot.
+#[test]
+fn test_change_binary_to_text() {
+    let test_project = TestFiles::new()
+        .add_file(
+            "Cargo.toml",
+            r#"
+[package]
+name = "test_change_binary_to_text"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+insta = { path = '$PROJECT_PATH' }
+"#
+            .to_string(),
+        )
+        .add_file(
+            "src/main.rs",
+            r#"
+#[test]
+fn test() {
+    insta::assert_binary_snapshot!("some_name.json", b"{}".to_vec());
+}
+"#
+            .to_string(),
+        )
+        .create_project();
+
+    let output = test_project
+        .insta_cmd()
+        .args(["test", "--accept"])
+        .output()
+        .unwrap();
+
+    assert!(&output.status.success());
+    assert_snapshot!(test_project.file_tree_diff(), @r"
+    --- Original file tree
+    +++ Updated file tree
+    @@ -1,4 +1,8 @@
+     
+    +  Cargo.lock
+       Cargo.toml
+       src
+         src/main.rs
+    +    src/snapshots
+    +      src/snapshots/test_change_binary_to_text__some_name.snap
+    +      src/snapshots/test_change_binary_to_text__some_name.snap.json
+    ");
+
+    test_project.update_file(
+        "src/main.rs",
+        r#"
+#[test]
+fn test() {
+    insta::assert_snapshot!("some_name", "test");
+}
+"#
+        .to_string(),
+    );
+
+    let output = test_project
+        .insta_cmd()
+        .args(["test", "--accept"])
+        .output()
+        .unwrap();
+
+    assert!(&output.status.success());
+    assert_snapshot!(test_project.file_tree_diff(), @r"
+    --- Original file tree
+    +++ Updated file tree
+    @@ -1,4 +1,7 @@
+     
+    +  Cargo.lock
+       Cargo.toml
+       src
+         src/main.rs
+    +    src/snapshots
+    +      src/snapshots/test_change_binary_to_text__some_name.snap
+    ");
+}
+
 // Can't get the test binary discovery to work, don't have a windows machine to
 // hand, others are welcome to fix it. (No specific reason to think that insta
 // doesn't work on windows, just that the test doesn't work.)
@@ -1450,6 +1868,7 @@ fn test_hello() {
     ---
     source: "../tests/lib.rs"
     expression: hello()
+    snapshot_kind: text
     ---
     Hello, world!
     "#);
@@ -1557,3 +1976,91 @@ Unused snapshot
     +      src/snapshots/test_unreferenced_delete__tests__snapshot.snap
     ");
 }
+
+#[test]
+fn test_binary_unreferenced_delete() {
+    let test_project = TestFiles::new()
+        .add_file(
+            "Cargo.toml",
+            r#"
+[package]
+name = "test_binary_unreferenced_delete"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+doctest = false
+
+[dependencies]
+insta = { path = '$PROJECT_PATH' }
+"#
+            .to_string(),
+        )
+        .add_file(
+            "src/lib.rs",
+            r#"
+#[cfg(test)]
+mod tests {
+    #[test]
+    fn test_snapshot() {
+        insta::assert_binary_snapshot!(".txt", b"abcd".to_vec());
+    }
+}
+"#
+            .to_string(),
+        )
+        .create_project();
+
+    // Run tests to create snapshots
+    let output = test_project
+        .insta_cmd()
+        .args(["test", "--accept"])
+        .output()
+        .unwrap();
+
+    assert!(&output.status.success());
+
+    test_project.update_file("src/lib.rs", "".to_string());
+
+    assert_snapshot!(test_project.file_tree_diff(), @r"
+    --- Original file tree
+    +++ Updated file tree
+    @@ -1,4 +1,8 @@
+     
+    +  Cargo.lock
+       Cargo.toml
+       src
+         src/lib.rs
+    +    src/snapshots
+    +      src/snapshots/test_binary_unreferenced_delete__tests__snapshot.snap
+    +      src/snapshots/test_binary_unreferenced_delete__tests__snapshot.snap.txt
+    ");
+
+    // Run cargo insta test with --unreferenced=delete
+    let output = test_project
+        .insta_cmd()
+        .args([
+            "test",
+            "--unreferenced=delete",
+            "--accept",
+            "--",
+            "--nocapture",
+        ])
+        .output()
+        .unwrap();
+
+    assert!(&output.status.success());
+
+    // We should now see the unreferenced snapshot deleted
+    assert_snapshot!(test_project.file_tree_diff(), @r"
+    --- Original file tree
+    +++ Updated file tree
+    @@ -1,4 +1,6 @@
+     
+    +  Cargo.lock
+       Cargo.toml
+       src
+         src/lib.rs
+    +    src/snapshots
+    ");
+}
diff --git a/insta/tests/snapshots/test_binary__binary_snapshot.snap b/insta/tests/snapshots/test_binary__binary_snapshot.snap
new file mode 100644
index 0000000..82cee89
--- /dev/null
+++ b/insta/tests/snapshots/test_binary__binary_snapshot.snap
@@ -0,0 +1,6 @@
+---
+source: insta/tests/test_binary.rs
+expression: "b\"test\".to_vec()"
+extension: txt
+snapshot_kind: binary
+---
diff --git a/insta/tests/snapshots/test_binary__binary_snapshot.snap.txt b/insta/tests/snapshots/test_binary__binary_snapshot.snap.txt
new file mode 100644
index 0000000..30d74d2
--- /dev/null
+++ b/insta/tests/snapshots/test_binary__binary_snapshot.snap.txt
@@ -0,0 +1,1 @@
+test
\ No newline at end of file
diff --git a/insta/tests/snapshots/test_binary__multipart_extension.snap b/insta/tests/snapshots/test_binary__multipart_extension.snap
new file mode 100644
index 0000000..970caa1
--- /dev/null
+++ b/insta/tests/snapshots/test_binary__multipart_extension.snap
@@ -0,0 +1,6 @@
+---
+source: insta/tests/test_binary.rs
+expression: "b\"test\".to_vec()"
+extension: tar.gz
+snapshot_kind: binary
+---
diff --git a/insta/tests/snapshots/test_binary__multipart_extension.snap.tar.gz b/insta/tests/snapshots/test_binary__multipart_extension.snap.tar.gz
new file mode 100644
index 0000000..30d74d2
--- /dev/null
+++ b/insta/tests/snapshots/test_binary__multipart_extension.snap.tar.gz
@@ -0,0 +1,1 @@
+test
\ No newline at end of file
diff --git a/insta/tests/snapshots/test_binary__name.snap b/insta/tests/snapshots/test_binary__name.snap
new file mode 100644
index 0000000..99207dc
--- /dev/null
+++ b/insta/tests/snapshots/test_binary__name.snap
@@ -0,0 +1,6 @@
+---
+source: insta/tests/test_binary.rs
+expression: "b\"null\".to_vec()"
+extension: json
+snapshot_kind: binary
+---
diff --git a/insta/tests/snapshots/test_binary__name.snap.json b/insta/tests/snapshots/test_binary__name.snap.json
new file mode 100644
index 0000000..ec747fa
--- /dev/null
+++ b/insta/tests/snapshots/test_binary__name.snap.json
@@ -0,0 +1,1 @@
+null
\ No newline at end of file
diff --git a/insta/tests/test_binary.rs b/insta/tests/test_binary.rs
new file mode 100644
index 0000000..c160a30
--- /dev/null
+++ b/insta/tests/test_binary.rs
@@ -0,0 +1,32 @@
+#[test]
+fn test_binary_snapshot() {
+    insta::assert_binary_snapshot!(".txt", b"test".to_vec());
+}
+
+#[test]
+#[should_panic(expected = "'.new' is not allowed as a file extension")]
+fn test_new_extension() {
+    insta::assert_binary_snapshot!(".new", b"test".to_vec());
+}
+
+#[test]
+#[should_panic(expected = "\"test\" does not match the format \"name.extension\"")]
+fn test_malformed_name_and_extension() {
+    insta::assert_binary_snapshot!("test", b"test".to_vec());
+}
+
+#[test]
+#[should_panic(expected = "file extensions starting with 'new.' are not allowed")]
+fn test_extension_starting_with_new() {
+    insta::assert_binary_snapshot!(".new.gz", b"test".to_vec());
+}
+
+#[test]
+fn test_multipart_extension() {
+    insta::assert_binary_snapshot!(".tar.gz", b"test".to_vec());
+}
+
+#[test]
+fn test_named() {
+    insta::assert_binary_snapshot!("name.json", b"null".to_vec());
+}