Repository: mitsuhiko/insta
Pull request #545 (2024-07-31 04:00:52)

Add an option to fall back to `cargo test`
This allows a project to default to `nextest` in its insta config, while falling back to `cargo test` when that isn't available.

Stacks on https://github.com/mitsuhiko/insta/pull/544

Needs an integration test, will add when https://github.com/mitsuhiko/insta/pull/537 merges


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4462392..402d64d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,22 +4,36 @@ All notable changes to insta and cargo-insta are documented here.
 
 ## 1.40.0
 
+- `cargo-insta` no longer panics when running `cargo test --accept --workspace`
+  on a workspace with a default crate. #532
+
+- MSRV for `insta` has been raised to 1.60, and for `cargo-insta` to 1.64.
+
+- Added support for compact debug snapshots (`assert_compact_debug_snapshot`). #514
+
+- Deprecate `--no-force-pass` in `cargo-insta`.  The `--check` option covers the
+  same functionality and has a clearer name.  #513
+
 - Inline snapshots now use the required number of `#`s to escape the snapshot
   value, rather than always using `###`. This allows snapshotting values which
   themselves contain `###`. If there are no existing `#` characters in the
-  snapshot value, a single `#` will be used.
+  snapshot value, a single `#` will be used.  #540
+
+- `cargo insta test` accepts multiple `--exclude` flags.  #520
+
+- `test` `runner` in insta's yaml config works.  #544
 
 - Print a warning when encountering old snapshot formats.  #503
 
+- Group the options in `cargo insta --help`, upgrade to `clap` from `structopt`.  #518
+
 - No longer suggest running `cargo insta` message when running `cargo insta test --check`.  #515
 
 - Print a clearer error message when accepting a snapshot that was removed.  #516
 
-- Fix `require-full-match` when running on inline snapshots outside of `cargo insta`.  #496
-
 - Mark `require-full-match` as experimental, given some corner-cases are currently difficult to manage.  #497
 
-- `cargo insta test` now accepts multiple `--exclude` flags.  #520
+- Add a new integration test approach for `cargo-insta` and a set of integration tests.  #537
 
 ## 1.39.0
 
@@ -27,7 +41,7 @@ All notable changes to insta and cargo-insta are documented here.
 
 - Fixed a bug that caused snapshot and module names to sometimes be inaccurate.  #483
 
-- Insta will no longer error when removing snapshots that were already removed.  #484
+- Insta will no longer error when attempting to remove snapshots that were already removed.  #484
 
 - Added support for trailing commas in inline snapshots.  #472
 
diff --git a/Cargo.toml b/Cargo.toml
index bc50bca..e025974 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,6 @@
 [workspace]
 members = ["cargo-insta"]
+resolver = "2"
 
 [workspace.metadata.dist]
 cargo-dist-version = "0.12.0"
diff --git a/cargo-insta/Cargo.toml b/cargo-insta/Cargo.toml
index 1f6384c..c1e8687 100644
--- a/cargo-insta/Cargo.toml
+++ b/cargo-insta/Cargo.toml
@@ -9,7 +9,7 @@ repository = "https://github.com/mitsuhiko/insta"
 documentation = "https://insta.rs/docs/cli/"
 keywords = ["snapshot", "testing", "jest"]
 categories = ["development-tools::cargo-plugins"]
-edition = "2018"
+edition = "2021"
 readme = "README.md"
 rust-version = "1.64.0"
 
@@ -21,13 +21,13 @@ serde = { version = "1.0.117", features = ["derive"] }
 serde_json = "1.0.59"
 proc-macro2 = { version = "1.0.60", features = ["span-locations"] }
 # Pinned because of MSRV; wait for MSRV bump or msrv-resolver
-syn = { version = "=2.0.8", features = ["full", "visit", "extra-traits"] }
+syn = { version = "2.0.8", features = ["full", "visit", "extra-traits"] }
 ignore = "0.4.17"
 uuid = { version = "1.0.0", features = ["v4"] }
 tempfile = "3.5.0"
 # Not yet supported in our MSRV of 1.60.0
 # clap = { workspace=true }
-clap = {version = "=4.1", features = ["derive", "env"]}
+clap = {version = "4.1", features = ["derive", "env"]}
 
 
 [dev-dependencies]
diff --git a/cargo-insta/src/cargo.rs b/cargo-insta/src/cargo.rs
index ade0e79..8dc7b34 100644
--- a/cargo-insta/src/cargo.rs
+++ b/cargo-insta/src/cargo.rs
@@ -1,7 +1,6 @@
-use std::error::Error;
-use std::path::{Path, PathBuf};
+use std::path::PathBuf;
 
-pub(crate) use cargo_metadata::{Metadata, Package};
+pub(crate) use cargo_metadata::Package;
 
 pub(crate) fn find_snapshot_roots(package: &Package) -> Vec<PathBuf> {
     let mut roots = Vec::new();
@@ -44,33 +43,3 @@ pub(crate) fn find_snapshot_roots(package: &Package) -> Vec<PathBuf> {
 
     reduced_roots
 }
-
-pub(crate) fn get_metadata(
-    manifest_path: Option<&Path>,
-    all: bool,
-) -> Result<Metadata, Box<dyn Error>> {
-    let mut cmd = cargo_metadata::MetadataCommand::new();
-    if let Some(manifest_path) = manifest_path {
-        cmd.manifest_path(manifest_path);
-    }
-    if all {
-        cmd.no_deps();
-    }
-    let mut metadata = cmd.exec()?;
-    let Metadata {
-        packages,
-        workspace_members,
-        resolve,
-        ..
-    } = &mut metadata;
-    match resolve
-        .as_ref()
-        .and_then(|cargo_metadata::Resolve { root, .. }| root.as_ref())
-    {
-        Some(root) => packages.retain(|Package { id, .. }| id == root),
-        None => {
-            packages.retain(|Package { id, .. }| workspace_members.contains(id));
-        }
-    }
-    Ok(metadata)
-}
diff --git a/cargo-insta/src/cli.rs b/cargo-insta/src/cli.rs
index 6b17827..799c8b9 100644
--- a/cargo-insta/src/cli.rs
+++ b/cargo-insta/src/cli.rs
@@ -1,6 +1,5 @@
 use std::borrow::{Borrow, Cow};
 use std::error::Error;
-use std::fmt::Display;
 use std::path::{Path, PathBuf};
 use std::{collections::HashSet, fmt};
 use std::{env, fs};
@@ -14,10 +13,10 @@ use insta::_cargo_insta_support::{
 use serde::Serialize;
 use uuid::Uuid;
 
-use crate::cargo::{find_snapshot_roots, get_metadata, Metadata, Package};
+use crate::cargo::{find_snapshot_roots, Package};
 use crate::container::{Operation, SnapshotContainer};
 use crate::utils::{err_msg, QuietExit};
-use crate::walk::{find_snapshots, make_deletion_walker, make_snapshot_walker, FindFlags};
+use crate::walk::{find_snapshots, make_snapshot_walker, FindFlags};
 
 use clap::{Args, Parser, Subcommand, ValueEnum};
 
@@ -211,11 +210,13 @@ struct TestCommand {
     /// Picks the test runner.
     #[arg(long, default_value = "auto")]
     test_runner: TestRunner,
+    #[arg(long)]
+    test_runner_fallback: Option<bool>,
     /// Delete unreferenced snapshots after a successful test run.
     #[arg(long, hide = true)]
     delete_unreferenced_snapshots: bool,
-    /// Disable force-passing of snapshot tests
-    #[arg(long)]
+    /// Disable force-passing of snapshot tests (deprecated)
+    #[arg(long, hide = true)]
     no_force_pass: bool,
     #[command(flatten)]
     target_args: TargetArgs,
@@ -251,7 +252,7 @@ fn query_snapshot(
     term: &Term,
     new: &Snapshot,
     old: Option<&Snapshot>,
-    pkg: Option<&Package>,
+    pkg: &Package,
     line: Option<u32>,
     i: usize,
     n: usize,
@@ -261,19 +262,14 @@ fn query_snapshot(
 ) -> Result<Operation, Box<dyn Error>> {
     loop {
         term.clear_screen()?;
-        let (pkg_name, pkg_version): (_, &dyn Display) = if let Some(pkg) = pkg {
-            (pkg.name.as_str(), &pkg.version)
-        } else {
-            ("unknown package", &"unknown version")
-        };
 
         println!(
             "{}{}{} {}@{}:",
             style("Reviewing [").bold(),
             style(format!("{}/{}", i, n)).yellow().bold(),
             style("]").bold(),
-            pkg_name,
-            pkg_version,
+            pkg.name.as_str(),
+            &pkg.version,
         );
 
         let mut printer = SnapshotPrinter::new(workspace_root, old, new);
@@ -356,7 +352,8 @@ fn handle_color(color: Option<ColorWhen>) {
 struct LocationInfo<'a> {
     tool_config: ToolConfig,
     workspace_root: PathBuf,
-    packages: Option<Vec<Package>>,
+    /// Packages to test
+    packages: Vec<Package>,
     exts: Vec<&'a str>,
     find_flags: FindFlags,
 }
@@ -368,7 +365,11 @@ fn get_find_flags(tool_config: &ToolConfig, target_args: &TargetArgs) -> FindFla
     }
 }
 
-fn handle_target_args(target_args: &TargetArgs) -> Result<LocationInfo<'_>, Box<dyn Error>> {
+fn handle_target_args<'a>(
+    target_args: &'a TargetArgs,
+    // Empty if none are selected, implying cargo default
+    packages: &'a [String],
+) -> Result<LocationInfo<'a>, Box<dyn Error>> {
     let exts: Vec<&str> = target_args.extensions.iter().map(|x| x.as_str()).collect();
 
     // if a workspace root is provided we first check if it points to a `Cargo.toml`.  If it
@@ -384,74 +385,77 @@ fn handle_target_args(target_args: &TargetArgs) -> Result<LocationInfo<'_>, Box<
             ))
         }
         (None, Some(manifest)) => (None, Some(Cow::Borrowed(manifest))),
-        (Some(root), manifest_path) => {
+        (Some(root), None) => {
             let assumed_manifest = root.join("Cargo.toml");
             if assumed_manifest.is_file() {
                 (None, Some(Cow::Owned(assumed_manifest)))
             } else {
-                (Some(root), manifest_path.map(Cow::Borrowed))
+                (Some(root), None)
             }
         }
         (None, None) => (None, None),
     };
 
+    let mut cmd = cargo_metadata::MetadataCommand::new();
+
+    // If a manifest path is provided, set it in the command
+    if let Some(manifest_path) = manifest_path {
+        cmd.manifest_path(manifest_path);
+    }
     if let Some(workspace_root) = workspace_root {
-        let tool_config = ToolConfig::from_workspace(workspace_root)?;
-        Ok(LocationInfo {
-            workspace_root: workspace_root.to_owned(),
-            packages: None,
-            exts,
-            find_flags: get_find_flags(&tool_config, target_args),
-            tool_config,
-        })
-    } else {
-        let Metadata {
-            packages,
-            workspace_root,
-            ..
-        } = get_metadata(
-            manifest_path.as_deref(),
-            target_args.all || target_args.workspace,
-        )?;
-        let workspace_root = workspace_root.as_std_path().to_path_buf();
-        let tool_config = ToolConfig::from_workspace(&workspace_root)?;
-        Ok(LocationInfo {
-            workspace_root,
-            packages: Some(packages),
-            exts,
-            find_flags: get_find_flags(&tool_config, target_args),
-            tool_config,
-        })
+        cmd.current_dir(workspace_root);
     }
+    let metadata = cmd.no_deps().exec()?;
+    let workspace_root = metadata.workspace_root.as_std_path().to_path_buf();
+    let tool_config = ToolConfig::from_workspace(&workspace_root)?;
+
+    // If `--all` is passed, or there's no root package, we include all
+    // packages. If packages are specified, we filter from all packages.
+    // Otherwise we use just the root package.
+    //
+    // (Once we're OK running on Cargo 1.71, we can replace `.root_package` with
+    // `.default_workspace_packages`.)
+    let packages = if metadata.root_package().is_none()
+        || (target_args.all || target_args.workspace)
+        || !packages.is_empty()
+    {
+        metadata
+            .workspace_packages()
+            .into_iter()
+            .filter(|p| packages.is_empty() || packages.contains(&p.name))
+            .cloned()
+            .collect()
+    } else {
+        vec![metadata.root_package().unwrap().clone()]
+    };
+
+    Ok(LocationInfo {
+        workspace_root,
+        packages,
+        exts,
+        find_flags: get_find_flags(&tool_config, target_args),
+        tool_config,
+    })
 }
 
 #[allow(clippy::type_complexity)]
 fn load_snapshot_containers<'a>(
     loc: &'a LocationInfo,
-) -> Result<
-    (
-        Vec<(SnapshotContainer, Option<&'a Package>)>,
-        HashSet<PathBuf>,
-    ),
-    Box<dyn Error>,
-> {
+) -> Result<(Vec<(SnapshotContainer, &'a Package)>, HashSet<PathBuf>), Box<dyn Error>> {
     let mut roots = HashSet::new();
     let mut snapshot_containers = vec![];
-    if let Some(ref packages) = loc.packages {
-        for package in packages.iter() {
-            for root in find_snapshot_roots(package) {
-                roots.insert(root.clone());
-                for snapshot_container in find_snapshots(&root, &loc.exts, loc.find_flags) {
-                    snapshot_containers.push((snapshot_container?, Some(package)));
-                }
+
+    debug_assert!(!loc.packages.is_empty());
+
+    for package in &loc.packages {
+        for root in find_snapshot_roots(package) {
+            roots.insert(root.clone());
+            for snapshot_container in find_snapshots(&root, &loc.exts, loc.find_flags) {
+                snapshot_containers.push((snapshot_container?, package));
             }
         }
-    } else {
-        roots.insert(loc.workspace_root.clone());
-        for snapshot_container in find_snapshots(&loc.workspace_root, &loc.exts, loc.find_flags) {
-            snapshot_containers.push((snapshot_container?, None));
-        }
     }
+
     snapshot_containers.sort_by(|a, b| a.0.snapshot_sort_key().cmp(&b.0.snapshot_sort_key()));
     Ok((snapshot_containers, roots))
 }
@@ -510,7 +514,7 @@ fn process_snapshots(
                     &term,
                     &snapshot_ref.new,
                     snapshot_ref.old.as_ref(),
-                    *package,
+                    package,
                     snapshot_ref.line,
                     num,
                     snapshot_count,
@@ -566,7 +570,7 @@ fn process_snapshots(
 }
 
 fn test_run(mut cmd: TestCommand, color: ColorWhen) -> Result<(), Box<dyn Error>> {
-    let loc = handle_target_args(&cmd.target_args)?;
+    let loc = handle_target_args(&cmd.target_args, &cmd.test_runner_options.package)?;
     match loc.tool_config.snapshot_update() {
         SnapshotUpdate::Auto => {
             if is_ci() {
@@ -616,9 +620,20 @@ fn test_run(mut cmd: TestCommand, color: ColorWhen) -> Result<(), Box<dyn Error>
         TestRunner::CargoTest => TestRunner::CargoTest,
         TestRunner::Nextest => TestRunner::Nextest,
     };
-
-    let (mut proc, snapshot_ref_file, prevents_doc_run) =
-        prepare_test_runner(test_runner, cmd.unreferenced, &cmd, color, &[], None)?;
+    // Prioritize the command line over the tool config
+    let test_runner_fallback = cmd
+        .test_runner_fallback
+        .unwrap_or(loc.tool_config.test_runner_fallback());
+
+    let (mut proc, snapshot_ref_file, prevents_doc_run) = prepare_test_runner(
+        test_runner,
+        test_runner_fallback,
+        cmd.unreferenced,
+        &cmd,
+        color,
+        &[],
+        None,
+    )?;
 
     if !cmd.keep_pending {
         process_snapshots(true, None, &loc, Some(Operation::Reject))?;
@@ -627,10 +642,15 @@ fn test_run(mut cmd: TestCommand, color: ColorWhen) -> Result<(), Box<dyn Error>
     let status = proc.status()?;
     let mut success = status.success();
 
-    // nextest currently cannot run doctests, run them with regular tests
+    // nextest currently cannot run doctests, run them with regular tests.
+    //
+    // Note that unlike `cargo test`, `cargo test --doctest` will run doctests
+    // even on crates that specify `doctests = false`. But I don't think there's
+    // a way to replicate the `cargo test` behavior.
     if matches!(cmd.test_runner, TestRunner::Nextest) && !prevents_doc_run {
         let (mut proc, _, _) = prepare_test_runner(
             TestRunner::CargoTest,
+            false,
             cmd.unreferenced,
             &cmd,
             color,
@@ -651,13 +671,8 @@ fn test_run(mut cmd: TestCommand, color: ColorWhen) -> Result<(), Box<dyn Error>
     // handle unreferenced snapshots if we were instructed to do so and the
     // tests ran successfully
     if success {
-        if let Some(ref path) = snapshot_ref_file {
-            handle_unreferenced_snapshots(
-                path.borrow(),
-                &loc,
-                cmd.unreferenced,
-                &cmd.test_runner_options.package[..],
-            )?;
+        if let Some(ref snapshot_ref_path) = snapshot_ref_file {
+            handle_unreferenced_snapshots(snapshot_ref_path.borrow(), &loc, cmd.unreferenced)?;
         }
     }
 
@@ -665,7 +680,7 @@ fn test_run(mut cmd: TestCommand, color: ColorWhen) -> Result<(), Box<dyn Error>
         process_snapshots(
             false,
             None,
-            &handle_target_args(&cmd.target_args)?,
+            &loc,
             if cmd.accept {
                 Some(Operation::Accept)
             } else {
@@ -699,11 +714,11 @@ fn test_run(mut cmd: TestCommand, color: ColorWhen) -> Result<(), Box<dyn Error>
     }
 }
 
+/// Scan for any snapshots that were not referenced by any test.
 fn handle_unreferenced_snapshots(
-    path: &Path,
+    snapshot_ref_path: &Path,
     loc: &LocationInfo<'_>,
     unreferenced: UnreferencedSnapshots,
-    packages: &[String],
 ) -> Result<(), Box<dyn Error>> {
     enum Action {
         Delete,
@@ -726,7 +741,7 @@ fn handle_unreferenced_snapshots(
     };
 
     let mut files = HashSet::new();
-    match fs::read_to_string(path) {
+    match fs::read_to_string(snapshot_ref_path) {
         Ok(s) => {
             for line in s.lines() {
                 if let Ok(path) = fs::canonicalize(line) {
@@ -744,23 +759,28 @@ fn handle_unreferenced_snapshots(
     }
 
     let mut encountered_any = false;
-    for entry in make_deletion_walker(&loc.workspace_root, loc.packages.as_deref(), packages) {
-        let rel_path = match entry {
-            Ok(ref entry) => entry.path(),
-            _ => continue,
-        };
-        if !rel_path.is_file()
-            || !rel_path
-                .file_name()
-                .map_or(false, |x| x.to_str().unwrap_or("").ends_with(".snap"))
-        {
-            continue;
-        }
 
-        if let Ok(path) = fs::canonicalize(rel_path) {
-            if files.contains(&path) {
-                continue;
-            }
+    for package in loc.packages.clone() {
+        let walker = make_snapshot_walker(
+            package.manifest_path.parent().unwrap().as_std_path(),
+            &[".snap"],
+            FindFlags {
+                include_ignored: true,
+                include_hidden: true,
+            },
+        )
+        .filter_map(|e| e.ok())
+        .filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
+        .filter(|e| {
+            e.file_name()
+                .to_str()
+                .map(|name| name.ends_with(".snap"))
+                .unwrap_or(false)
+        })
+        .filter_map(|e| e.path().canonicalize().ok())
+        .filter(|path| !files.contains(path));
+
+        for path in walker {
             if !encountered_any {
                 match action {
                     Action::Delete => {
@@ -775,14 +795,14 @@ fn handle_unreferenced_snapshots(
                 }
                 encountered_any = true;
             }
-            eprintln!("  {}", rel_path.display());
+            eprintln!("  {}", path.display());
             if matches!(action, Action::Delete) {
-                fs::remove_file(path).ok();
+                fs::remove_file(&path).ok();
             }
         }
     }
 
-    fs::remove_file(path).ok();
+    fs::remove_file(snapshot_ref_path).ok();
 
     if !encountered_any {
         eprintln!("{}: no unreferenced snapshots found", style("info").bold());
@@ -796,6 +816,7 @@ fn handle_unreferenced_snapshots(
 #[allow(clippy::type_complexity)]
 fn prepare_test_runner<'snapshot_ref>(
     test_runner: TestRunner,
+    test_runner_fallback: bool,
     unreferenced: UnreferencedSnapshots,
     cmd: &TestCommand,
     color: ColorWhen,
@@ -806,6 +827,26 @@ fn prepare_test_runner<'snapshot_ref>(
     let cargo = cargo
         .as_deref()
         .unwrap_or_else(|| std::ffi::OsStr::new("cargo"));
+    let test_runner = match test_runner {
+        TestRunner::CargoTest | TestRunner::Auto => test_runner,
+        TestRunner::Nextest => {
+            // Fall back to `cargo test` if `cargo nextest` isn't installed and
+            // `test_runner_fallback` is true (but don't run the cargo command
+            // unless that's an option)
+            if !test_runner_fallback
+                || std::process::Command::new("cargo")
+                    .arg("nextest")
+                    .arg("--version")
+                    .output()
+                    .map(|output| output.status.success())
+                    .unwrap_or(false)
+            {
+                TestRunner::Nextest
+            } else {
+                TestRunner::Auto
+            }
+        }
+    };
     let mut proc = match test_runner {
         TestRunner::CargoTest | TestRunner::Auto => {
             let mut proc = process::Command::new(cargo);
@@ -887,6 +928,11 @@ fn prepare_test_runner<'snapshot_ref>(
     }
     if !cmd.no_force_pass {
         proc.env("INSTA_FORCE_PASS", "1");
+    } else {
+        eprintln!(
+            "{}: `--no-force-pass` is deprecated. Please use --check to immediately raise an error on any non-matching snapshots.",
+            style("warning").bold().yellow()
+        );
     }
     proc.env(
         "INSTA_UPDATE",
@@ -974,7 +1020,7 @@ fn prepare_test_runner<'snapshot_ref>(
 }
 
 fn show_cmd(cmd: ShowCommand) -> Result<(), Box<dyn Error>> {
-    let loc = handle_target_args(&cmd.target_args)?;
+    let loc = handle_target_args(&cmd.target_args, &[])?;
     let snapshot = Snapshot::from_file(&cmd.path)?;
     let mut printer = SnapshotPrinter::new(&loc.workspace_root, None, &snapshot);
     printer.set_snapshot_file(Some(&cmd.path));
@@ -1000,7 +1046,7 @@ fn pending_snapshots_cmd(cmd: PendingSnapshotsCommand) -> Result<(), Box<dyn Err
         },
     }
 
-    let loc = handle_target_args(&cmd.target_args)?;
+    let loc = handle_target_args(&cmd.target_args, &[])?;
     let (mut snapshot_containers, _) = load_snapshot_containers(&loc)?;
 
     for (snapshot_container, _package) in snapshot_containers.iter_mut() {
@@ -1033,7 +1079,7 @@ fn pending_snapshots_cmd(cmd: PendingSnapshotsCommand) -> Result<(), Box<dyn Err
 
 fn show_undiscovered_hint(
     find_flags: FindFlags,
-    snapshot_containers: &[(SnapshotContainer, Option<&Package>)],
+    snapshot_containers: &[(SnapshotContainer, &Package)],
     roots: &HashSet<PathBuf>,
     extensions: &[&str],
 ) {
@@ -1110,7 +1156,7 @@ pub(crate) fn run() -> Result<(), Box<dyn Error>> {
             process_snapshots(
                 cmd.quiet,
                 cmd.snapshot_filter.as_deref(),
-                &handle_target_args(&cmd.target_args)?,
+                &handle_target_args(&cmd.target_args, &[])?,
                 match opts.command {
                     Command::Review(_) => None,
                     Command::Accept(_) => Some(Operation::Accept),
diff --git a/cargo-insta/src/container.rs b/cargo-insta/src/container.rs
index f60fb1b..172bcc5 100644
--- a/cargo-insta/src/container.rs
+++ b/cargo-insta/src/container.rs
@@ -209,6 +209,7 @@ impl SnapshotContainer {
             }
         } else {
             // should only be one or this is weird
+            debug_assert!(self.snapshots.len() == 1);
             for snapshot in self.snapshots.iter() {
                 match snapshot.op {
                     Operation::Accept => {
diff --git a/cargo-insta/src/walk.rs b/cargo-insta/src/walk.rs
index 2c96e83..be9dc46 100644
--- a/cargo-insta/src/walk.rs
+++ b/cargo-insta/src/walk.rs
@@ -1,4 +1,3 @@
-use std::collections::HashSet;
 use std::error::Error;
 use std::ffi::OsStr;
 use std::path::Path;
@@ -6,7 +5,6 @@ use std::path::Path;
 use ignore::overrides::OverrideBuilder;
 use ignore::{DirEntry, Walk, WalkBuilder};
 
-use crate::cargo::Package;
 use crate::container::{SnapshotContainer, SnapshotContainerKind};
 
 #[derive(Debug, Copy, Clone)]
@@ -25,11 +23,11 @@ fn is_hidden(entry: &DirEntry) -> bool {
 
 /// Finds all snapshots
 pub(crate) fn find_snapshots<'a>(
-    root: &Path,
+    package_root: &Path,
     extensions: &'a [&'a str],
     flags: FindFlags,
 ) -> impl Iterator<Item = Result<SnapshotContainer, Box<dyn Error>>> + 'a {
-    make_snapshot_walker(root, extensions, flags)
+    make_snapshot_walker(package_root, extensions, flags)
         .filter_map(|e| e.ok())
         .filter_map(move |e| {
             let fname = e.file_name().to_string_lossy();
@@ -55,87 +53,52 @@ pub(crate) fn find_snapshots<'a>(
         })
 }
 
-/// Creates a walker for snapshots.
-pub(crate) fn make_snapshot_walker(path: &Path, extensions: &[&str], flags: FindFlags) -> Walk {
-    let mut builder = WalkBuilder::new(path);
+/// Creates a walker for snapshots & pending snapshots within a package.
+pub(crate) fn make_snapshot_walker(
+    package_root: &Path,
+    extensions: &[&str],
+    flags: FindFlags,
+) -> Walk {
+    let mut builder = WalkBuilder::new(package_root);
     builder.standard_filters(!flags.include_ignored);
     if flags.include_hidden {
         builder.hidden(false);
     } else {
+        // We add a custom hidden filter to avoid skipping over `.pending-snap` files
         builder.filter_entry(|e| e.file_type().map_or(false, |x| x.is_file()) || !is_hidden(e));
     }
 
-    let mut override_builder = OverrideBuilder::new(path);
+    let mut override_builder = OverrideBuilder::new(package_root);
     override_builder
         .add(".*.pending-snap")
         .unwrap()
         .add("*.snap.new")
         .unwrap();
-
     for ext in extensions {
         override_builder.add(&format!("*.{}.new", ext)).unwrap();
     }
-
     builder.overrides(override_builder.build().unwrap());
-    builder.build()
-}
 
-/// A walker that is used by the snapshot deletion code.
-///
-/// This really should be using the same logic as the main snapshot walker but today is is not.
-pub(crate) fn make_deletion_walker(
-    workspace_root: &Path,
-    known_packages: Option<&[Package]>,
-    selected_packages: &[String],
-) -> Walk {
-    let roots: HashSet<_> = if let Some(packages) = known_packages {
-        packages
-            .iter()
-            .filter_map(|x| {
-                // filter out packages we did not ask for.
-                if !selected_packages.is_empty() && !selected_packages.contains(&x.name) {
-                    return None;
-                }
-                x.manifest_path.parent().unwrap().canonicalize().ok()
-            })
-            .collect()
-    } else {
-        Some(workspace_root.to_path_buf()).into_iter().collect()
-    };
+    let root_path = package_root.to_path_buf();
 
-    WalkBuilder::new(workspace_root)
-        .filter_entry(move |entry| {
-            // we only filter down for directories
-            if !entry.file_type().map_or(false, |x| x.is_dir()) {
-                return true;
-            }
-
-            let canonicalized = match entry.path().canonicalize() {
-                Ok(path) => path,
-                Err(_) => return true,
-            };
-
-            // We always want to skip target even if it was not excluded by
-            // ignore files.
-            if entry.path().file_name() == Some(OsStr::new("target"))
-                && roots.contains(canonicalized.parent().unwrap())
-            {
+    // Add a custom filter to skip interior crates; otherwise we get duplicate
+    // snapshots (https://github.com/mitsuhiko/insta/issues/396)
+    builder.filter_entry(move |entry| {
+        if entry.file_type().map_or(false, |ft| ft.is_dir()) {
+            let cargo_toml_path = entry.path().join("Cargo.toml");
+            if cargo_toml_path.exists() && entry.path() != root_path {
+                // Skip this directory if it contains a Cargo.toml and is not the root
                 return false;
             }
+        }
+        // We always want to skip target even if it was not excluded by
+        // ignore files.
+        if entry.path().file_name() == Some(OsStr::new("target")) {
+            return false;
+        }
 
-            // do not enter crates which are not in the list of known roots
-            // of the workspace.
-            if !roots.contains(&canonicalized)
-                && entry
-                    .path()
-                    .join("Cargo.toml")
-                    .metadata()
-                    .map_or(false, |x| x.is_file())
-            {
-                return false;
-            }
+        true
+    });
 
-            true
-        })
-        .build()
+    builder.build()
 }
diff --git a/insta/Cargo.toml b/insta/Cargo.toml
index 5d7ae7d..0a22c2f 100644
--- a/insta/Cargo.toml
+++ b/insta/Cargo.toml
@@ -4,7 +4,7 @@ version = "1.39.0"
 license = "Apache-2.0"
 authors = ["Armin Ronacher <armin.ronacher@active-4.com>"]
 description = "A snapshot testing library for Rust"
-edition = "2018"
+edition = "2021"
 rust-version = "1.60.0"
 homepage = "https://insta.rs/"
 repository = "https://github.com/mitsuhiko/insta"
@@ -48,7 +48,7 @@ yaml = ["serde"]
 _cargo_insta_internal = ["clap"]
 
 [dependencies]
-dep_csv = { package = "csv", version = "=1.1.6", optional = true }
+dep_csv = { package = "csv", version = "1.1.6", optional = true }
 console = { version = "0.15.4", optional = true, default-features = false }
 pest = { version = "2.1.3", optional = true }
 pest_derive = { version = "2.1.0", optional = true }
@@ -63,7 +63,7 @@ linked-hash-map = "0.5.6"
 lazy_static = "1.4.0"
 # Not yet supported in our MSRV of 1.60.0
 # clap = { workspace=true, optional = true }
-clap = {version = "=4.1", features = ["derive", "env"], optional = true}
+clap = {version = "4.1", features = ["derive", "env"], optional = true}
 
 [dev-dependencies]
 rustc_version = "0.4.0"
diff --git a/insta/src/env.rs b/insta/src/env.rs
index 1de2a1c..9082b12 100644
--- a/insta/src/env.rs
+++ b/insta/src/env.rs
@@ -104,6 +104,8 @@ pub struct ToolConfig {
     #[cfg(feature = "glob")]
     glob_fail_fast: bool,
     #[cfg(feature = "_cargo_insta_internal")]
+    test_runner_fallback: bool,
+    #[cfg(feature = "_cargo_insta_internal")]
     test_runner: TestRunner,
     #[cfg(feature = "_cargo_insta_internal")]
     test_unreferenced: UnreferencedSnapshots,
@@ -235,6 +237,15 @@ impl ToolConfig {
                 .map_err(|_| Error::Env("INSTA_TEST_RUNNER"))?
             },
             #[cfg(feature = "_cargo_insta_internal")]
+            test_runner_fallback: match env::var("INSTA_TEST_RUNNER_FALLBACK").as_deref() {
+                Err(_) | Ok("") => resolve(&cfg, &["test", "runner_fallback"])
+                    .and_then(|x| x.as_bool())
+                    .unwrap_or(false),
+                Ok("1") => true,
+                Ok("0") => false,
+                _ => return Err(Error::Env("INSTA_RUNNER_FALLBACK")),
+            },
+            #[cfg(feature = "_cargo_insta_internal")]
             test_unreferenced: {
                 resolve(&cfg, &["test", "unreferenced"])
                     .and_then(|x| x.as_str())
@@ -265,6 +276,8 @@ impl ToolConfig {
         })
     }
 
+    // TODO: Do we want all these methods, vs. just allowing access to the fields?
+
     /// Is insta told to force update snapshots?
     pub fn force_update_snapshots(&self) -> bool {
         self.force_update_snapshots
@@ -304,6 +317,11 @@ impl ToolConfig {
         self.test_runner
     }
 
+    /// Whether to fallback to `cargo test` if the test runner isn't available
+    pub fn test_runner_fallback(&self) -> bool {
+        self.test_runner_fallback
+    }
+
     pub fn test_unreferenced(&self) -> UnreferencedSnapshots {
         self.test_unreferenced
     }
diff --git a/insta/src/lib.rs b/insta/src/lib.rs
index e23b80f..a146e70 100644
--- a/insta/src/lib.rs
+++ b/insta/src/lib.rs
@@ -224,6 +224,9 @@
 //! test:
 //!   # also set by INSTA_TEST_RUNNER
 //!   runner: "auto" | "cargo-test" | "nextest"
+//!   # whether to fallback to `cargo-test` if `nextest` is not available,
+//!   # also set by INSTA_TEST_RUNNER_FALLBACK, default false
+//!   test_runner_fallback: true/false
 //!   # automatically assume --review was passed to cargo insta test
 //!   auto_review: true/false
 //!   # automatically assume --accept-unseen was passed to cargo insta test
diff --git a/insta/src/snapshot.rs b/insta/src/snapshot.rs
index 089a110..eccbda4 100644
--- a/insta/src/snapshot.rs
+++ b/insta/src/snapshot.rs
@@ -520,9 +520,7 @@ impl SnapshotContents {
 
     /// Returns the snapshot contents as string with surrounding whitespace removed.
     pub fn as_str(&self) -> &str {
-        self.0
-            .trim_start_matches(|x| x == '\r' || x == '\n')
-            .trim_end()
+        self.0.trim_start_matches(['\r', '\n']).trim_end()
     }
 
     pub fn to_inline(&self, indentation: usize) -> String {


Test patch:
diff --git a/cargo-insta/tests/main.rs b/cargo-insta/tests/main.rs
index 2d6e377..a8c7e2f 100644
--- a/cargo-insta/tests/main.rs
+++ b/cargo-insta/tests/main.rs
@@ -472,8 +472,6 @@ fn test_root() {
         )
 }
 
-// Currently panics
-#[should_panic]
 /// Check that in a workspace with a default root crate, running `cargo insta
 /// test --workspace` will update snapsnots in both the root crate and the
 /// member crate.