Repository: sourcefrog/cargo-mutants (rust)

Issue:
Cargo mutants is not executed in workspace members
# Description
`cargo mutants` command does not generate mutants for workspace members on a workspace project. Let's say I have a workspace project which contains 2 binary and 1 lib cargo crates. When I execute the `cargo mutants` on the root, there are no mutants generated, so the workspace member crates are not included in the generation of the mutants.

I prepared a GitHub repo for reproducing the problem:

## Reproduction

### Prerequisite
Clone the following workspace test project [test-repo-for-cargo-mutants](https://github.com/dmoka/test-repo-for-cargo-mutants)

### Steps:
- Go to the root folder of the cloned repo
- Execute `cargo mutants`

### Expected behavior:
Mutants are generated for all the projects in the workspace. 

Note: I would expect this to happen because when for example I execute `cargo test`, then all the tests of workspace member crates are executed. The same for `cargo build`.

### Actual behavior:
No mutants are generated


==PS==
Anyway, awesome job with this crate, thank you! 

Pull request description:
Support Cargo workspaces
Fixes #45 

Patch:
diff --git a/DESIGN.md b/DESIGN.md
index 86d6dad..c73c9e2 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -48,6 +48,24 @@ After copying the tree, cargo-mutants scans the top-level `Cargo.toml` and any
 rewritten to be absolute, so that they still work when cargo is run in the
 scratch directory.
 
+## Enumerating the source tree
+
+A source tree may be a single crate or a Cargo workspace. There are several levels of nesting:
+
+* A workspace contains one or more packages.
+* A package contains one or more targets.
+* Each target names one (or possibly-more) top level source files, whose directories are walked to find more source files.
+* Each source file contains some functions.
+* For each function we generate some mutants.
+
+The name of the containing package is passed through to the `SourceFile` and the `Mutant` objects.
+
+For source tree and baseline builds and tests, we pass Cargo `--workspace` to build and test everything. For mutant builds and tests, we pass `--package` to build and test only the package containing the mutant, on the assumption that each mutant should be caught by its own package's tests.
+
+Currently source files are discovered by finding any `bin` or `lib` targets in the package, then taking every `*.rs` file in the same directory as their top-level source file. (This is a bit approximate and will pick up files that might not actually be referenced by a `mod` statement, so may change in the future.)
+
+We may later mutate at a granularity smaller than a single function, for example by cutting out an `if` statement or a loop, but that is not yet implemented. (<https://github.com/sourcefrog/cargo-mutants/issues/73>)
+
 ## Handling timeouts
 
 Mutations can cause a program to go into an infinite (or just very long) loop:
@@ -60,15 +78,15 @@ to be safe and easy to use on arbitrary trees that might have bugs.
 
 We want to handle timeouts internally for a few reasons, including:
 
-- If one mutation hangs we still want to go on and try others. (So it's not so
+* If one mutation hangs we still want to go on and try others. (So it's not so
   good if the `cargo mutants` process is killed by the user or a CI timeout.)
 
-- The fact that the mutation hung is a potentially interesting signal about the
+* The fact that the mutation hung is a potentially interesting signal about the
   program to report. (Possibly the user will just have to mark
   `should_terminate` as skipped, but at least they can do that once and then
   have other builds go faster.)
 
-- For either CI or interactive use it's better if `cargo mutants` finishes in a
+* For either CI or interactive use it's better if `cargo mutants` finishes in a
   bounded time.
 
 (We are primarily concerned here with timeouts on tests; let's assume that
diff --git a/NEWS.md b/NEWS.md
index 19893bf..802c69b 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -2,6 +2,8 @@
 
 ## UNRELEASED
 
+- New: Supports workspaces containing multiple packages. Mutants are generated for all relevant targets in all packages, and mutants are subject to the tests of their own package.  `cargo mutants --list-files --json` and `cargo mutants --list --json` now includes package names for each file or mutant.
+
 - Improved: Generate mutations in `cdylib`, `rlib`, and ever other `*lib` target. For example, this correctly exercises Wasm projects.
 
 ## 0.2.11
diff --git a/README.md b/README.md
index 7ff5ae4..f3d7b50 100644
--- a/README.md
+++ b/README.md
@@ -226,6 +226,12 @@ Rust doctests are pretty slow, so if you're using them only as testable
 documentation and not to assert correctness of the code, you can skip them with
 `cargo mutants -- --all-targets`.
 
+### Workspace and package support
+
+cargo-mutants now supports testing Cargo workspaces that contain multiple packages.
+
+All source files in all packages in the workspace are tested. For each mutant, only the containing packages tests are run.
+
 ### Hard-to-test cases
 
 Some functions don't cause a test suite failure if emptied, but also cannot be
@@ -275,7 +281,7 @@ jobs:
 
 ## How to help
 
-Experience reports in GitHub Discussions or Bugs are very welcome:
+Experience reports in [GitHub Discussions](https://github.com/sourcefrog/cargo-mutants/discussions) or issues are very welcome:
 
 - Did it find a bug or important coverage gap?
 - Did it fail to build and test your tree? (Some cases that aren't supported yet
@@ -284,6 +290,8 @@ Experience reports in GitHub Discussions or Bugs are very welcome:
 It's especially helpful if you can either point to an open source tree that will
 reproduce the problem (or success) or at least describe how to reproduce it.
 
+If you are interested in contributing a patch, please read [CONTRIBUTING.md](CONTRIBUTING.md).
+
 ## Goals
 
 **The goal of cargo-mutants is to be _easy_ to run on any Rust source tree, and
@@ -448,8 +456,6 @@ in a restricted or disposable environment.
 cargo-mutants behavior, output formats, command-line syntax, json output
 formats, etc, may change from one release to the next.
 
-cargo-mutants does not yet understand cargo workspaces, and it will only test the root package. <https://github.com/sourcefrog/cargo-mutants/issues/45>
-
 cargo-mutants sees the AST of the tree but doesn't fully "understand" the types.
 Possibly it could learn to get type information from the compiler (or
 rust-analyzer?), which would help it generate more interesting viable mutants,
diff --git a/cargo-mutants.wiki b/cargo-mutants.wiki
new file mode 160000
index 0000000..8bdd0bf
--- /dev/null
+++ b/cargo-mutants.wiki
@@ -0,0 +1,1 @@
+Subproject commit 8bdd0bf4977a98f749c21e0404de7b0a4096b12f
diff --git a/src/build_dir.rs b/src/build_dir.rs
index 698c6ae..90f08ff 100644
--- a/src/build_dir.rs
+++ b/src/build_dir.rs
@@ -37,8 +37,9 @@ pub struct BuildDir {
 impl BuildDir {
     /// Make a new build dir, copying from a source directory.
     pub fn new(source: &SourceTree, options: &Options) -> Result<BuildDir> {
+        let name_tail = source.path().file_name().unwrap_or("");
         let temp_dir = tempfile::Builder::new()
-            .prefix(&format!("cargo-mutants-{}-", source.root_package_name()?))
+            .prefix(&format!("cargo-mutants-{}-", name_tail))
             .suffix(".tmp")
             .tempdir()
             .context("create temp dir")?;
diff --git a/src/cargo.rs b/src/cargo.rs
index aad862e..070076e 100644
--- a/src/cargo.rs
+++ b/src/cargo.rs
@@ -121,11 +121,17 @@ fn cargo_bin() -> String {
 
 /// Make up the argv for a cargo check/build/test invocation, not including
 /// the cargo binary itself.
-pub fn cargo_args(phase: Phase, options: &Options) -> Vec<String> {
+pub fn cargo_args(package_name: Option<&str>, phase: Phase, options: &Options) -> Vec<String> {
     let mut cargo_args = vec![phase.name().to_string()];
     if phase == Phase::Check || phase == Phase::Build {
         cargo_args.push("--tests".to_string());
     }
+    if let Some(package_name) = package_name {
+        cargo_args.push("--package".to_owned());
+        cargo_args.push(package_name.to_owned());
+    } else {
+        cargo_args.push("--workspace".to_string());
+    }
     cargo_args.extend(options.additional_cargo_args.iter().cloned());
     if phase == Phase::Test {
         cargo_args.extend(options.additional_cargo_test_args.iter().cloned());
@@ -228,28 +234,43 @@ mod test {
     use pretty_assertions::assert_eq;
 
     use super::cargo_args;
-    use crate::options::Options;
-    use crate::Phase;
+    use crate::{Options, Phase};
 
     #[test]
-    fn generate_cargo_args_for_default_options() {
+    fn generate_cargo_args_for_baseline_with_default_options() {
         let options = Options::default();
-        assert_eq!(cargo_args(Phase::Check, &options), vec!["check", "--tests"]);
-        assert_eq!(cargo_args(Phase::Build, &options), vec!["build", "--tests"]);
-        assert_eq!(cargo_args(Phase::Test, &options), vec!["test"]);
+        assert_eq!(
+            cargo_args(None, Phase::Check, &options),
+            vec!["check", "--tests", "--workspace"]
+        );
+        assert_eq!(
+            cargo_args(None, Phase::Build, &options),
+            vec!["build", "--tests", "--workspace"]
+        );
+        assert_eq!(
+            cargo_args(None, Phase::Test, &options),
+            vec!["test", "--workspace"]
+        );
     }
 
     #[test]
-    fn generate_cargo_args_with_additional_cargo_test_args() {
+    fn generate_cargo_args_with_additional_cargo_test_args_and_package_name() {
         let mut options = Options::default();
+        let package_name = "cargo-mutants-testdata-something";
         options
             .additional_cargo_test_args
             .extend(["--lib", "--no-fail-fast"].iter().map(|s| s.to_string()));
-        assert_eq!(cargo_args(Phase::Check, &options), vec!["check", "--tests"]);
-        assert_eq!(cargo_args(Phase::Build, &options), vec!["build", "--tests"]);
         assert_eq!(
-            cargo_args(Phase::Test, &options),
-            vec!["test", "--lib", "--no-fail-fast"]
+            cargo_args(Some(package_name), Phase::Check, &options),
+            vec!["check", "--tests", "--package", package_name]
+        );
+        assert_eq!(
+            cargo_args(Some(package_name), Phase::Build, &options),
+            vec!["build", "--tests", "--package", package_name]
+        );
+        assert_eq!(
+            cargo_args(Some(package_name), Phase::Test, &options),
+            vec!["test", "--package", package_name, "--lib", "--no-fail-fast"]
         );
     }
 
@@ -263,16 +284,22 @@ mod test {
             .additional_cargo_args
             .extend(["--release".to_owned()]);
         assert_eq!(
-            cargo_args(Phase::Check, &options),
-            vec!["check", "--tests", "--release"]
+            cargo_args(None, Phase::Check, &options),
+            vec!["check", "--tests", "--workspace", "--release"]
         );
         assert_eq!(
-            cargo_args(Phase::Build, &options),
-            vec!["build", "--tests", "--release"]
+            cargo_args(None, Phase::Build, &options),
+            vec!["build", "--tests", "--workspace", "--release"]
         );
         assert_eq!(
-            cargo_args(Phase::Test, &options),
-            vec!["test", "--release", "--lib", "--no-fail-fast"]
+            cargo_args(None, Phase::Test, &options),
+            vec![
+                "test",
+                "--workspace",
+                "--release",
+                "--lib",
+                "--no-fail-fast"
+            ]
         );
     }
 }
diff --git a/src/lab.rs b/src/lab.rs
index 8fb8499..ed74ad9 100644
--- a/src/lab.rs
+++ b/src/lab.rs
@@ -54,6 +54,15 @@ impl Scenario {
             Scenario::Mutant(mutant) => mutant.log_file_name_base(),
         }
     }
+
+    /// Return the package name that should be tested for this scenario,
+    /// or None to test every package.
+    pub fn package_name(&self) -> Option<&str> {
+        match self {
+            Scenario::Mutant(mutant) => Some(mutant.package_name()),
+            _ => None,
+        }
+    }
 }
 
 /// Run all possible mutation experiments.
@@ -218,7 +227,7 @@ fn run_cargo_phases(
     for &phase in phases {
         let phase_start = Instant::now();
         console.scenario_phase_started(phase);
-        let cargo_args = cargo_args(phase, options);
+        let cargo_args = cargo_args(scenario.package_name(), phase, options);
         let timeout = match phase {
             Phase::Test => options.test_timeout(),
             _ => Duration::MAX,
diff --git a/src/main.rs b/src/main.rs
index 9d00cfa..316d2ec 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -29,6 +29,8 @@ use anyhow::Result;
 use argh::FromArgs;
 use camino::Utf8PathBuf;
 use path_slash::PathExt;
+use serde_json::json;
+use serde_json::Value;
 
 // Imports of public names from this crate.
 use crate::build_dir::BuildDir;
@@ -156,16 +158,12 @@ fn main() -> Result<()> {
     if args.version {
         println!("{} {}", NAME, VERSION);
     } else if args.list_files {
-        let files: Vec<String> = source_tree
-            .source_paths(&options)?
-            .into_iter()
-            .map(|trp| trp.to_string())
-            .collect();
         if args.json {
-            serde_json::to_writer_pretty(io::BufWriter::new(io::stdout()), &files)?;
+            list_files_as_json(&source_tree, &options)?;
         } else {
-            for f in files {
-                println!("{}", f);
+            let source_files = source_tree.source_files(&options)?;
+            for f in source_files {
+                println!("{}", f.tree_relative_slashes());
             }
         }
     } else if args.list {
@@ -185,3 +183,21 @@ fn main() -> Result<()> {
     }
     Ok(())
 }
+
+fn list_files_as_json(source_tree: &SourceTree, options: &Options) -> Result<()> {
+    let list = Value::Array(
+        source_tree
+            .source_files(options)?
+            .iter()
+            .map(|source_file| {
+                json!({
+                    // to_string so that we get it with slashes.
+                    "path": source_file.tree_relative_path.to_string(),
+                    "package": source_file.package_name.as_ref(),
+                })
+            })
+            .collect(),
+    );
+    serde_json::to_writer_pretty(io::BufWriter::new(io::stdout()), &list)?;
+    Ok(())
+}
diff --git a/src/mutate.rs b/src/mutate.rs
index 113ed86..8b1992c 100644
--- a/src/mutate.rs
+++ b/src/mutate.rs
@@ -147,6 +147,11 @@ impl Mutant {
         &self.function_name
     }
 
+    /// Return the cargo package name.
+    pub fn package_name(&self) -> &str {
+        &self.source_file.package_name
+    }
+
     /// Return a unified diff for the mutant.
     pub fn diff(&self) -> String {
         let old_label = self.source_file.tree_relative_slashes();
@@ -199,6 +204,7 @@ impl fmt::Debug for Mutant {
             // more concise display of spans
             .field("start", &(self.span.start.line, self.span.start.column))
             .field("end", &(self.span.end.line, self.span.end.column))
+            .field("package_name", &self.package_name())
             .finish()
     }
 }
@@ -220,7 +226,8 @@ impl Serialize for Mutant {
         S: Serializer,
     {
         // custom serialize to omit inessential info
-        let mut ss = serializer.serialize_struct("Mutation", 4)?;
+        let mut ss = serializer.serialize_struct("Mutation", 6)?;
+        ss.serialize_field("package", &self.package_name())?;
         ss.serialize_field("file", &self.source_file.tree_relative_slashes())?;
         ss.serialize_field("line", &self.span.start.line)?;
         ss.serialize_field("function", &self.function_name.as_ref())?;
@@ -232,6 +239,8 @@ impl Serialize for Mutant {
 
 #[cfg(test)]
 mod test {
+    use std::rc::Rc;
+
     use camino::Utf8Path;
     use itertools::Itertools;
     use pretty_assertions::assert_eq;
@@ -243,13 +252,14 @@ mod test {
         let source_file = SourceFile::new(
             Utf8Path::new("testdata/tree/factorial"),
             "src/bin/factorial.rs".parse().unwrap(),
+            Rc::new("cargo-mutants-testdata-factorial".to_string()),
         )
         .unwrap();
         let muts = discover_mutants(source_file.into()).unwrap();
         assert_eq!(muts.len(), 2);
         assert_eq!(
             format!("{:?}", muts[0]),
-            r#"Mutant { op: Unit, function_name: "main", return_type: "", start: (1, 11), end: (5, 2) }"#
+            r#"Mutant { op: Unit, function_name: "main", return_type: "", start: (1, 11), end: (5, 2), package_name: "cargo-mutants-testdata-factorial" }"#
         );
         assert_eq!(
             muts[0].to_string(),
@@ -257,7 +267,7 @@ mod test {
         );
         assert_eq!(
             format!("{:?}", muts[1]),
-            r#"Mutant { op: Default, function_name: "factorial", return_type: "-> u32", start: (7, 29), end: (13, 2) }"#
+            r#"Mutant { op: Default, function_name: "factorial", return_type: "-> u32", start: (7, 29), end: (13, 2), package_name: "cargo-mutants-testdata-factorial" }"#
         );
         assert_eq!(
             muts[1].to_string(),
@@ -270,6 +280,7 @@ mod test {
         let source_file = SourceFile::new(
             Utf8Path::new("testdata/tree/hang_avoided_by_attr"),
             "src/lib.rs".parse().unwrap(),
+            Rc::new("cargo-mutants-testdata-hang-avoided".to_string()),
         )
         .unwrap();
         let mutants = discover_mutants(source_file.into()).unwrap();
@@ -285,6 +296,7 @@ mod test {
         let source_file = SourceFile::new(
             Utf8Path::new("testdata/tree/factorial"),
             "src/bin/factorial.rs".parse().unwrap(),
+            Rc::new("cargo-mutants-testdata-factorial".to_string()),
         )
         .unwrap();
         let mutants = discover_mutants(source_file.into()).unwrap();
diff --git a/src/path.rs b/src/path.rs
index c69a956..40ed895 100644
--- a/src/path.rs
+++ b/src/path.rs
@@ -8,6 +8,7 @@ use std::path::{Path, PathBuf};
 use std::str::FromStr;
 
 use camino::{Utf8Path, Utf8PathBuf};
+use serde::Serialize;
 
 /// Measures how far above its starting point a path ascends.
 ///
@@ -54,7 +55,7 @@ impl Utf8PathSlashes for Utf8Path {
 }
 
 /// A path relative to the top of the source tree.
-#[derive(Debug, PartialEq, Eq, Clone, PartialOrd, Ord)]
+#[derive(Debug, PartialEq, Eq, Clone, PartialOrd, Ord, Serialize)]
 pub struct TreeRelativePathBuf(Utf8PathBuf);
 
 impl fmt::Display for TreeRelativePathBuf {
@@ -75,6 +76,21 @@ impl TreeRelativePathBuf {
         TreeRelativePathBuf(path)
     }
 
+    /// Make an empty tree-relative path, identifying the root.
+    #[allow(dead_code)]
+    pub fn empty() -> Self {
+        TreeRelativePathBuf(Utf8PathBuf::new())
+    }
+
+    #[allow(dead_code)]
+    pub fn from_absolute(path: &Utf8Path, tree_root: &Utf8Path) -> Self {
+        TreeRelativePathBuf(
+            path.strip_prefix(tree_root)
+                .expect("path is within tree root")
+                .to_owned(),
+        )
+    }
+
     pub fn within(&self, tree_path: &Utf8Path) -> Utf8PathBuf {
         tree_path.join(&self.0)
     }
@@ -91,6 +107,12 @@ impl TreeRelativePathBuf {
     }
 }
 
+impl From<&Utf8Path> for TreeRelativePathBuf {
+    fn from(path_buf: &Utf8Path) -> Self {
+        TreeRelativePathBuf::new(path_buf.to_owned())
+    }
+}
+
 impl From<Utf8PathBuf> for TreeRelativePathBuf {
     fn from(path_buf: Utf8PathBuf) -> Self {
         TreeRelativePathBuf::new(path_buf)
diff --git a/src/source.rs b/src/source.rs
index 4907f0d..401c1aa 100644
--- a/src/source.rs
+++ b/src/source.rs
@@ -5,11 +5,10 @@
 use std::collections::BTreeSet;
 use std::rc::Rc;
 
-use anyhow::{anyhow, Context, Result};
+use anyhow::{Context, Result};
 use camino::{Utf8Path, Utf8PathBuf};
 use globset::GlobSet;
-
-use tracing::debug;
+use tracing::{debug, info, warn};
 
 use crate::path::TreeRelativePathBuf;
 use crate::*;
@@ -24,10 +23,13 @@ use crate::*;
 #[derive(Clone, PartialEq, Eq)]
 pub struct SourceFile {
     /// Path relative to the root of the tree.
-    tree_relative_path: TreeRelativePathBuf,
+    pub tree_relative_path: TreeRelativePathBuf,
 
     /// Full copy of the source.
     pub code: Rc<String>,
+
+    /// Package within the workspace.
+    pub package_name: Rc<String>,
 }
 
 impl SourceFile {
@@ -37,6 +39,7 @@ impl SourceFile {
     pub fn new(
         tree_path: &Utf8Path,
         tree_relative_path: TreeRelativePathBuf,
+        package_name: Rc<String>,
     ) -> Result<SourceFile> {
         let full_path = tree_relative_path.within(tree_path);
         let code = std::fs::read_to_string(&full_path)
@@ -45,6 +48,7 @@ impl SourceFile {
         Ok(SourceFile {
             tree_relative_path,
             code: Rc::new(code),
+            package_name,
         })
     }
 
@@ -65,6 +69,7 @@ impl SourceFile {
 /// build dirs.
 #[derive(Debug)]
 pub struct SourceTree {
+    /// Root of the source tree, absolute or relative to the cargo-mutants cwd.
     root: Utf8PathBuf,
     metadata: cargo_metadata::Metadata,
 }
@@ -72,22 +77,25 @@ pub struct SourceTree {
 impl SourceTree {
     /// Open a source tree.
     ///
-    /// This eagerly loads cargo metadata from the enclosed `Cargo.toml`, so the
-    /// tree must be minimally valid Rust.
+    /// This eagerly loads cargo metadata from the enclosed `Cargo.toml`.
     ///
-    /// `path` may be any path pointing within the tree, including a relative path.
-    /// The root of the tree is discovered.
+    /// `path` may be any path pointing within the tree, including a relative
+    /// path.
+    ///
+    /// The root of the tree is discovered by asking Cargo to walk up and find
+    /// the enclosing workspace.
     pub fn new(path: &Utf8Path) -> Result<SourceTree> {
         let cargo_toml_path = cargo::locate_project(path)?;
+        info!("cargo_toml_path = {cargo_toml_path}");
         let root = cargo_toml_path
             .parent()
-            .expect("Cargo.toml path has no parent")
+            .expect("Cargo.toml path has no directory?")
             .to_owned();
         let metadata = cargo_metadata::MetadataCommand::new()
             .manifest_path(&cargo_toml_path)
             .exec()
             .context("run cargo metadata")?;
-        Ok(SourceTree { root, metadata })
+        Ok(SourceTree { metadata, root })
     }
 
     /// Return all the mutations that could possibly be applied to this tree.
@@ -100,51 +108,44 @@ impl SourceTree {
         Ok(r)
     }
 
-    /// Return an iterator of `src/**/*.rs` paths relative to the root.
-    pub fn source_paths(
-        &self,
-        options: &Options,
-    ) -> Result<impl IntoIterator<Item = TreeRelativePathBuf>> {
-        let top_sources = cargo_metadata_sources(&self.metadata)?;
-        indirect_sources(
-            &self.root,
-            top_sources,
-            &options.examine_globset,
-            &options.exclude_globset,
-        )
-    }
-
-    /// Return an iterator of [SourceFile] object, eagerly loading their content.
-    pub fn source_files(&self, options: &Options) -> Result<impl Iterator<Item = SourceFile> + '_> {
-        // TODO: Maybe don't eagerly read them here...?
-        let source_paths = self.source_paths(options)?;
-        let root = self.root.clone();
-        Ok(source_paths.into_iter().filter_map(move |trp| {
-            SourceFile::new(&root, trp.clone())
-                .map_err(|err| {
-                    eprintln!("error reading source {}: {}", trp, err);
-                })
-                .ok()
-        }))
+    /// Return an iterator of [SourceFile] objects representing all source files
+    /// in all packages in the tree, eagerly loading their content.
+    pub fn source_files(&self, options: &Options) -> Result<Vec<SourceFile>> {
+        let mut r = Vec::new();
+        for package_metadata in &self.metadata.workspace_packages() {
+            debug!("walk package {:?}", package_metadata.manifest_path);
+            let top_sources = direct_package_sources(&self.root, package_metadata)?;
+            let source_paths = indirect_source_paths(
+                &self.root,
+                top_sources,
+                &options.examine_globset,
+                &options.exclude_globset,
+            )?;
+            let package_name = Rc::new(package_metadata.name.to_string());
+            for source_path in source_paths {
+                check_interrupted()?;
+                r.push(SourceFile::new(
+                    &self.root,
+                    source_path,
+                    Rc::clone(&package_name),
+                )?);
+            }
+        }
+        Ok(r)
     }
 
     /// Return the path (possibly relative) to the root of the source tree.
     pub fn path(&self) -> &Utf8Path {
         &self.root
     }
-
-    /// Return the name of the root crate, as an identifier for this tree.
-    pub fn root_package_name(&self) -> Result<&str> {
-        Ok(self
-            .metadata
-            .root_package()
-            .ok_or_else(|| anyhow!("directory has no root package"))?
-            .name
-            .as_str())
-    }
 }
 
-fn indirect_sources(
+/// Find all the `.rs` files, by starting from the sources identified by the manifest
+/// and walking down.
+///
+/// This just walks the directory tree rather than following `mod` statements (for now)
+/// so it may pick up some files that are not actually linked in.
+fn indirect_source_paths(
     root_dir: &Utf8Path,
     top_sources: impl IntoIterator<Item = TreeRelativePathBuf>,
     examine_globset: &Option<GlobSet>,
@@ -156,62 +157,66 @@ fn indirect_sources(
         for p in walkdir::WalkDir::new(top_dir.within(root_dir))
             .sort_by_file_name()
             .into_iter()
-            .filter_map(|r| {
-                r.map_err(|err| eprintln!("error walking source tree: {:?}", err))
-                    .ok()
-            })
-            .filter(|entry| entry.file_type().is_file())
-            .map(|entry| entry.into_path())
-            .filter(|path| {
-                path.extension()
-                    .map_or(false, |p| p.eq_ignore_ascii_case("rs"))
-            })
-            .map(move |full_path| {
-                full_path
-                    .strip_prefix(&root_dir)
-                    .expect("strip prefix")
-                    .to_owned()
-            })
-            .filter(|rel_path| {
-                examine_globset
-                    .as_ref()
-                    .map_or(true, |gs| gs.is_match(rel_path))
-            })
-            .filter(|rel_path| {
-                exclude_globset
-                    .as_ref()
-                    .map_or(true, |gs| !gs.is_match(rel_path))
-            })
         {
-            files.insert(p.into());
+            let p = p.with_context(|| "error walking source tree {top_dir}")?;
+            if !p.file_type().is_file() {
+                continue;
+            }
+            let path = p.into_path();
+            if !path
+                .extension()
+                .map_or(false, |p| p.eq_ignore_ascii_case("rs"))
+            {
+                continue;
+            }
+            let relative_path = path
+                .strip_prefix(&root_dir)
+                .expect("strip prefix")
+                .to_owned();
+            if let Some(examine_globset) = examine_globset {
+                if !examine_globset.is_match(&relative_path) {
+                    continue;
+                }
+            }
+            if let Some(exclude_globset) = exclude_globset {
+                if exclude_globset.is_match(&relative_path) {
+                    continue;
+                }
+            }
+            files.insert(relative_path.into());
         }
     }
     Ok(files)
 }
 
-/// Given a path to a cargo manifest, find all the directly-referenced source files.
-fn cargo_metadata_sources(
-    metadata: &cargo_metadata::Metadata,
-) -> Result<BTreeSet<TreeRelativePathBuf>> {
-    let mut found = BTreeSet::new();
-    if let Some(pkg) = metadata.root_package() {
-        let pkg_dir = pkg.manifest_path.parent().unwrap();
-        for target in &pkg.targets {
-            if should_mutate_target(target) {
-                if let Ok(relpath) = target.src_path.strip_prefix(&pkg_dir) {
-                    let relpath = TreeRelativePathBuf::new(relpath.into());
-                    found.insert(relpath);
-                } else {
-                    eprintln!("{:?} is not in {:?}", target.src_path, pkg_dir);
-                }
+/// Find all the files that are named in the `path` of targets in a Cargo manifest that should be tested.
+///
+/// These are the starting points for discovering source files.
+fn direct_package_sources(
+    workspace_root: &Utf8Path,
+    package_metadata: &cargo_metadata::Package,
+) -> Result<Vec<TreeRelativePathBuf>> {
+    let mut found = Vec::new();
+    let pkg_dir = package_metadata.manifest_path.parent().unwrap();
+    for target in &package_metadata.targets {
+        if should_mutate_target(target) {
+            if let Ok(relpath) = target.src_path.strip_prefix(&workspace_root) {
+                let relpath = TreeRelativePathBuf::new(relpath.into());
+                found.push(relpath);
             } else {
-                debug!(
-                    "skipping target {:?} of kinds {:?}",
-                    target.name, target.kind
-                );
+                let message = format!("{:?} is not in {:?}", target.src_path, pkg_dir);
+                eprintln!("{}", message);
+                warn!("{}", message);
             }
+        } else {
+            debug!(
+                "skipping target {:?} of kinds {:?}",
+                target.name, target.kind
+            );
         }
     }
+    found.sort();
+    found.dedup();
     Ok(found)
 }
 
@@ -237,8 +242,7 @@ mod test {
         let source_paths = SourceTree::new(Utf8Path::new("testdata/tree/factorial"))
             .unwrap()
             .source_files(&Options::default())
-            .unwrap()
-            .collect::<Vec<SourceFile>>();
+            .unwrap();
         assert_eq!(source_paths.len(), 1);
         assert_eq!(
             source_paths[0].tree_relative_path().to_string(),
@@ -273,13 +277,12 @@ mod test {
             .write_all(b"fn main() {\r\n    640 << 10;\r\n}\r\n")
             .unwrap();
 
-        let source_file = SourceFile::new(temp_dir_path, file_name.parse().unwrap()).unwrap();
+        let source_file = SourceFile::new(
+            temp_dir_path,
+            file_name.parse().unwrap(),
+            Rc::new("imaginary-package".to_owned()),
+        )
+        .unwrap();
         assert_eq!(*source_file.code, "fn main() {\n    640 << 10;\n}\n");
     }
-
-    #[test]
-    fn source_root_package_name_of_cargo_mutants_itself() {
-        let source_tree = SourceTree::new(".".into()).unwrap();
-        assert_eq!(source_tree.root_package_name().unwrap(), "cargo-mutants");
-    }
 }


Test patch:
diff --git a/testdata/tree/workspace/Cargo.toml b/testdata/tree/workspace/Cargo.toml
new file mode 100644
index 0000000..a9fc0fe
--- /dev/null
+++ b/testdata/tree/workspace/Cargo.toml
@@ -0,0 +1,5 @@
+# This workspace has no root package, which is an interesting edge case to test:
+# older cargo-mutants assumed there was a root package and only tested it.
+
+[workspace]
+members = ["main", "main2", "utils"]
diff --git a/testdata/tree/workspace/README.md b/testdata/tree/workspace/README.md
new file mode 100644
index 0000000..e9b99a4
--- /dev/null
+++ b/testdata/tree/workspace/README.md
@@ -0,0 +1,1 @@
+# Test project for cargo mutants issue
diff --git a/testdata/tree/workspace/main/Cargo.toml b/testdata/tree/workspace/main/Cargo.toml
new file mode 100644
index 0000000..6007368
--- /dev/null
+++ b/testdata/tree/workspace/main/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "main"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+cargo_mutants_testdata_workspace_utils = { path = "../utils", default-features = false }
+mutants = "0.0.3"
diff --git a/testdata/tree/workspace/main/src/main.rs b/testdata/tree/workspace/main/src/main.rs
new file mode 100644
index 0000000..d115b75
--- /dev/null
+++ b/testdata/tree/workspace/main/src/main.rs
@@ -0,0 +1,22 @@
+use cargo_mutants_testdata_workspace_utils::triple;
+
+#[mutants::skip]
+fn main() {
+    for i in 1..=6 {
+        println!("{}! = {}", i, factorial(i));
+    }
+    println!("3 * 3 = {}", triple(3));
+}
+
+fn factorial(n: u32) -> u32 {
+    let mut a = 1;
+    for i in 2..=n {
+        a *= i;
+    }
+    a
+}
+
+#[test]
+fn factorial_5() {
+    assert_eq!(factorial(5), 120);
+}
diff --git a/testdata/tree/workspace/main2/Cargo.toml b/testdata/tree/workspace/main2/Cargo.toml
new file mode 100644
index 0000000..e566964
--- /dev/null
+++ b/testdata/tree/workspace/main2/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "main2"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+cargo_mutants_testdata_workspace_utils = { path = "../utils", default-features = false }
+mutants = "0.0.3"
diff --git a/testdata/tree/workspace/main2/src/main.rs b/testdata/tree/workspace/main2/src/main.rs
new file mode 100644
index 0000000..3b1ec64
--- /dev/null
+++ b/testdata/tree/workspace/main2/src/main.rs
@@ -0,0 +1,20 @@
+use cargo_mutants_testdata_workspace_utils::triple;
+
+#[mutants::skip]
+fn main() {
+    println!("Print works from main2 binary");
+    println!("3 * 3 = {}", triple_3());
+}
+
+fn triple_3() -> i32 {
+    triple(3)
+}
+
+mod test {
+    use super::*;
+
+    #[test]
+    fn triple_3_test() {
+        assert_eq!(triple_3(), 9);
+    }
+}
diff --git a/testdata/tree/workspace/utils/Cargo.toml b/testdata/tree/workspace/utils/Cargo.toml
new file mode 100644
index 0000000..06974fa
--- /dev/null
+++ b/testdata/tree/workspace/utils/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "cargo_mutants_testdata_workspace_utils"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
diff --git a/testdata/tree/workspace/utils/src/lib.rs b/testdata/tree/workspace/utils/src/lib.rs
new file mode 100644
index 0000000..0b28d23
--- /dev/null
+++ b/testdata/tree/workspace/utils/src/lib.rs
@@ -0,0 +1,8 @@
+pub fn triple(a: i32) -> i32 {
+    a * 3
+}
+
+#[test]
+fn test_triple() {
+    assert_eq!(triple(3), 9);
+}
diff --git a/tests/cli.rs b/tests/cli.rs
index fb44744..383ec3e 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -373,6 +373,26 @@ fn list_files_json_well_tested() {
         .assert_insta("list_files_json_well_tested");
 }
 
+#[test]
+fn list_files_json_workspace() {
+    // Demonstrates that we get package names in the json listing.
+    run()
+        .args(["mutants", "--list-files", "--json"])
+        .current_dir("testdata/tree/workspace")
+        .assert_insta("list_files_json_workspace");
+}
+
+#[test]
+fn workspace_tree_is_well_tested() {
+    let tmp_src_dir = copy_of_testdata("workspace");
+    run()
+        .args(["mutants", "-d"])
+        .arg(tmp_src_dir.path())
+        .assert()
+        .success();
+    // TODO: Check that --package arguments were passed, maybe by looking in the `outcomes.json` file.
+}
+
 #[test]
 fn copy_testdata_doesnt_include_build_artifacts() {
     // If there is a target or mutants.out in the source directory, we don't want it in the copy,
@@ -614,11 +634,11 @@ fn small_well_tested_mutants_with_cargo_arg_release() {
     println!("{}", baseline_log_path.display());
     let log_content = fs::read_to_string(&baseline_log_path).unwrap();
     println!("{}", log_content);
-    regex::Regex::new(r"cargo.* build --tests --release")
+    regex::Regex::new(r"cargo.* build --tests --workspace --release")
         .unwrap()
         .captures(&log_content)
         .unwrap();
-    regex::Regex::new(r"cargo.* test --release")
+    regex::Regex::new(r"cargo.* test --workspace --release")
         .unwrap()
         .captures(&log_content)
         .unwrap();
diff --git a/tests/snapshots/cli__list_files_json_well_tested.snap b/tests/snapshots/cli__list_files_json_well_tested.snap
index b2966ea..776c2e9 100644
--- a/tests/snapshots/cli__list_files_json_well_tested.snap
+++ b/tests/snapshots/cli__list_files_json_well_tested.snap
@@ -3,13 +3,40 @@ source: tests/cli.rs
 expression: "String::from_utf8_lossy(&output.stdout)"
 ---
 [
-  "src/empty_fns.rs",
-  "src/inside_mod.rs",
-  "src/item_mod.rs",
-  "src/lib.rs",
-  "src/methods.rs",
-  "src/nested_function.rs",
-  "src/result.rs",
-  "src/simple_fns.rs",
-  "src/struct_with_lifetime.rs"
+  {
+    "package": "cargo-mutants-testdata-well-tested",
+    "path": "src/empty_fns.rs"
+  },
+  {
+    "package": "cargo-mutants-testdata-well-tested",
+    "path": "src/inside_mod.rs"
+  },
+  {
+    "package": "cargo-mutants-testdata-well-tested",
+    "path": "src/item_mod.rs"
+  },
+  {
+    "package": "cargo-mutants-testdata-well-tested",
+    "path": "src/lib.rs"
+  },
+  {
+    "package": "cargo-mutants-testdata-well-tested",
+    "path": "src/methods.rs"
+  },
+  {
+    "package": "cargo-mutants-testdata-well-tested",
+    "path": "src/nested_function.rs"
+  },
+  {
+    "package": "cargo-mutants-testdata-well-tested",
+    "path": "src/result.rs"
+  },
+  {
+    "package": "cargo-mutants-testdata-well-tested",
+    "path": "src/simple_fns.rs"
+  },
+  {
+    "package": "cargo-mutants-testdata-well-tested",
+    "path": "src/struct_with_lifetime.rs"
+  }
 ]
diff --git a/tests/snapshots/cli__list_files_json_workspace.snap b/tests/snapshots/cli__list_files_json_workspace.snap
new file mode 100644
index 0000000..a14b89e
--- /dev/null
+++ b/tests/snapshots/cli__list_files_json_workspace.snap
@@ -0,0 +1,18 @@
+---
+source: tests/cli.rs
+expression: "String::from_utf8_lossy(&output.stdout)"
+---
+[
+  {
+    "package": "cargo_mutants_testdata_workspace_utils",
+    "path": "utils/src/lib.rs"
+  },
+  {
+    "package": "main",
+    "path": "main/src/main.rs"
+  },
+  {
+    "package": "main2",
+    "path": "main2/src/main.rs"
+  }
+]
diff --git a/tests/snapshots/cli__list_mutants_in_all_trees_as_json.snap b/tests/snapshots/cli__list_mutants_in_all_trees_as_json.snap
index e712176..2f2ba8a 100644
--- a/tests/snapshots/cli__list_mutants_in_all_trees_as_json.snap
+++ b/tests/snapshots/cli__list_mutants_in_all_trees_as_json.snap
@@ -7,6 +7,7 @@ expression: buf
 ```json
 [
   {
+    "package": "mutants-testdata-already-failing-doctests",
     "file": "src/lib.rs",
     "line": 9,
     "function": "takes_one_arg",
@@ -21,6 +22,7 @@ expression: buf
 ```json
 [
   {
+    "package": "mutants-testdata-already-failing-tests",
     "file": "src/lib.rs",
     "line": 1,
     "function": "factorial",
@@ -35,6 +37,7 @@ expression: buf
 ```json
 [
   {
+    "package": "cargo-mutants-testdata-already-hangs",
     "file": "src/lib.rs",
     "line": 8,
     "function": "infinite_loop",
@@ -49,6 +52,7 @@ expression: buf
 ```json
 [
   {
+    "package": "cargo-mutants-testdata-cdylib",
     "file": "src/entry.rs",
     "line": 1,
     "function": "factorial",
@@ -75,6 +79,7 @@ expression: buf
 ```json
 [
   {
+    "package": "cargo-mutants-testdata-dependency",
     "file": "src/lib.rs",
     "line": 1,
     "function": "factorial",
@@ -95,6 +100,7 @@ expression: buf
 ```json
 [
   {
+    "package": "cargo-mutants-testdata-factorial",
     "file": "src/bin/factorial.rs",
     "line": 1,
     "function": "main",
@@ -102,6 +108,7 @@ expression: buf
     "replacement": "()"
   },
   {
+    "package": "cargo-mutants-testdata-factorial",
     "file": "src/bin/factorial.rs",
     "line": 7,
     "function": "factorial",
@@ -116,6 +123,7 @@ expression: buf
 ```json
 [
   {
+    "package": "cargo-mutants-testdata-hang-avoided-by-attr",
     "file": "src/lib.rs",
     "line": 12,
     "function": "controlled_loop",
@@ -130,6 +138,7 @@ expression: buf
 ```json
 [
   {
+    "package": "cargo-mutants-testdata-hang-when-mutated",
     "file": "src/lib.rs",
     "line": 7,
     "function": "should_stop",
@@ -137,6 +146,7 @@ expression: buf
     "replacement": "true"
   },
   {
+    "package": "cargo-mutants-testdata-hang-when-mutated",
     "file": "src/lib.rs",
     "line": 7,
     "function": "should_stop",
@@ -144,6 +154,7 @@ expression: buf
     "replacement": "false"
   },
   {
+    "package": "cargo-mutants-testdata-hang-when-mutated",
     "file": "src/lib.rs",
     "line": 11,
     "function": "controlled_loop",
@@ -158,6 +169,7 @@ expression: buf
 ```json
 [
   {
+    "package": "nightly_only",
     "file": "src/main.rs",
     "line": 2,
     "function": "main",
@@ -172,6 +184,7 @@ expression: buf
 ```json
 [
   {
+    "package": "cargo-mutants-testdata-override-dependency",
     "file": "src/lib.rs",
     "line": 6,
     "function": "is_even",
@@ -179,6 +192,7 @@ expression: buf
     "replacement": "true"
   },
   {
+    "package": "cargo-mutants-testdata-override-dependency",
     "file": "src/lib.rs",
     "line": 6,
     "function": "is_even",
@@ -193,6 +207,7 @@ expression: buf
 ```json
 [
   {
+    "package": "cargo-mutants-testdata-patch-dependency",
     "file": "src/lib.rs",
     "line": 6,
     "function": "is_even",
@@ -200,6 +215,7 @@ expression: buf
     "replacement": "true"
   },
   {
+    "package": "cargo-mutants-testdata-patch-dependency",
     "file": "src/lib.rs",
     "line": 6,
     "function": "is_even",
@@ -214,6 +230,7 @@ expression: buf
 ```json
 [
   {
+    "package": "cargo-mutants-testdata-relative-dependency",
     "file": "src/lib.rs",
     "line": 5,
     "function": "double_factorial",
@@ -228,6 +245,7 @@ expression: buf
 ```json
 [
   {
+    "package": "cargo-mutants-testdata-replace-dependency",
     "file": "src/lib.rs",
     "line": 6,
     "function": "is_even",
@@ -235,6 +253,7 @@ expression: buf
     "replacement": "true"
   },
   {
+    "package": "cargo-mutants-testdata-replace-dependency",
     "file": "src/lib.rs",
     "line": 6,
     "function": "is_even",
@@ -249,6 +268,7 @@ expression: buf
 ```json
 [
   {
+    "package": "cargo-mutants-testdata-small-well-tested",
     "file": "src/lib.rs",
     "line": 4,
     "function": "factorial",
@@ -263,6 +283,7 @@ expression: buf
 ```json
 [
   {
+    "package": "cargo-mutants-testdata-strict-warnings",
     "file": "src/lib.rs",
     "line": 5,
     "function": "some_fn",
@@ -277,6 +298,7 @@ expression: buf
 ```json
 [
   {
+    "package": "mutants-testdata-typecheck-fails",
     "file": "src/lib.rs",
     "line": 5,
     "function": "try_value_coercion",
@@ -284,6 +306,7 @@ expression: buf
     "replacement": "\"\".into()"
   },
   {
+    "package": "mutants-testdata-typecheck-fails",
     "file": "src/lib.rs",
     "line": 5,
     "function": "try_value_coercion",
@@ -298,6 +321,7 @@ expression: buf
 ```json
 [
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/inside_mod.rs",
     "line": 3,
     "function": "outer::inner::name",
@@ -305,6 +329,7 @@ expression: buf
     "replacement": "Default::default()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/methods.rs",
     "line": 16,
     "function": "Foo::double",
@@ -312,6 +337,7 @@ expression: buf
     "replacement": "()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/methods.rs",
     "line": 22,
     "function": "<impl Display for Foo>::fmt",
@@ -319,6 +345,7 @@ expression: buf
     "replacement": "Ok(Default::default())"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/methods.rs",
     "line": 28,
     "function": "<impl Debug for &Foo>::fmt",
@@ -326,6 +353,7 @@ expression: buf
     "replacement": "Ok(Default::default())"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/nested_function.rs",
     "line": 1,
     "function": "has_nested",
@@ -333,6 +361,7 @@ expression: buf
     "replacement": "Default::default()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/nested_function.rs",
     "line": 2,
     "function": "has_nested::inner",
@@ -340,6 +369,7 @@ expression: buf
     "replacement": "Default::default()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/result.rs",
     "line": 5,
     "function": "simple_result",
@@ -347,6 +377,7 @@ expression: buf
     "replacement": "Ok(Default::default())"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/result.rs",
     "line": 9,
     "function": "error_if_negative",
@@ -354,6 +385,7 @@ expression: buf
     "replacement": "Ok(Default::default())"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/simple_fns.rs",
     "line": 7,
     "function": "returns_unit",
@@ -361,6 +393,7 @@ expression: buf
     "replacement": "()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/simple_fns.rs",
     "line": 12,
     "function": "returns_42u32",
@@ -368,6 +401,7 @@ expression: buf
     "replacement": "Default::default()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/simple_fns.rs",
     "line": 17,
     "function": "divisible_by_three",
@@ -375,6 +409,7 @@ expression: buf
     "replacement": "true"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/simple_fns.rs",
     "line": 17,
     "function": "divisible_by_three",
@@ -382,6 +417,7 @@ expression: buf
     "replacement": "false"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/simple_fns.rs",
     "line": 26,
     "function": "double_string",
@@ -389,6 +425,7 @@ expression: buf
     "replacement": "\"\".into()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/simple_fns.rs",
     "line": 26,
     "function": "double_string",
@@ -396,6 +433,7 @@ expression: buf
     "replacement": "\"xyzzy\".into()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/struct_with_lifetime.rs",
     "line": 14,
     "function": "Lex<'buf>::buf_len",
@@ -410,6 +448,7 @@ expression: buf
 ```json
 [
   {
+    "package": "cargo-mutants-testdata-with-child-directories",
     "file": "src/methods.rs",
     "line": 1,
     "function": "double",
@@ -417,6 +456,7 @@ expression: buf
     "replacement": "Default::default()"
   },
   {
+    "package": "cargo-mutants-testdata-with-child-directories",
     "file": "src/module/module_methods.rs",
     "line": 1,
     "function": "double",
@@ -424,6 +464,7 @@ expression: buf
     "replacement": "Default::default()"
   },
   {
+    "package": "cargo-mutants-testdata-with-child-directories",
     "file": "src/module/utils/inside_mod.rs",
     "line": 3,
     "function": "outer::inner::name",
@@ -431,6 +472,7 @@ expression: buf
     "replacement": "Default::default()"
   },
   {
+    "package": "cargo-mutants-testdata-with-child-directories",
     "file": "src/module/utils/nested_function.rs",
     "line": 1,
     "function": "has_nested",
@@ -438,6 +480,7 @@ expression: buf
     "replacement": "Default::default()"
   },
   {
+    "package": "cargo-mutants-testdata-with-child-directories",
     "file": "src/module/utils/nested_function.rs",
     "line": 2,
     "function": "has_nested::inner",
@@ -445,6 +488,7 @@ expression: buf
     "replacement": "Default::default()"
   },
   {
+    "package": "cargo-mutants-testdata-with-child-directories",
     "file": "src/module/utils/sub_utils/subutils_nested_function.rs",
     "line": 1,
     "function": "has_nested",
@@ -452,6 +496,7 @@ expression: buf
     "replacement": "Default::default()"
   },
   {
+    "package": "cargo-mutants-testdata-with-child-directories",
     "file": "src/module/utils/sub_utils/subutils_nested_function.rs",
     "line": 2,
     "function": "has_nested::inner",
@@ -461,4 +506,35 @@ expression: buf
 ]
 ```
 
+## testdata/tree/workspace
+
+```json
+[
+  {
+    "package": "cargo_mutants_testdata_workspace_utils",
+    "file": "utils/src/lib.rs",
+    "line": 1,
+    "function": "triple",
+    "return_type": "-> i32",
+    "replacement": "Default::default()"
+  },
+  {
+    "package": "main",
+    "file": "main/src/main.rs",
+    "line": 11,
+    "function": "factorial",
+    "return_type": "-> u32",
+    "replacement": "Default::default()"
+  },
+  {
+    "package": "main2",
+    "file": "main2/src/main.rs",
+    "line": 9,
+    "function": "triple_3",
+    "return_type": "-> i32",
+    "replacement": "Default::default()"
+  }
+]
+```
+
 
diff --git a/tests/snapshots/cli__list_mutants_in_all_trees_as_text.snap b/tests/snapshots/cli__list_mutants_in_all_trees_as_text.snap
index dbe8a90..da0f0ae 100644
--- a/tests/snapshots/cli__list_mutants_in_all_trees_as_text.snap
+++ b/tests/snapshots/cli__list_mutants_in_all_trees_as_text.snap
@@ -152,4 +152,12 @@ src/module/utils/sub_utils/subutils_nested_function.rs:1: replace has_nested ->
 src/module/utils/sub_utils/subutils_nested_function.rs:2: replace has_nested::inner -> u32 with Default::default()
 ```
 
+## testdata/tree/workspace
+
+```
+utils/src/lib.rs:1: replace triple -> i32 with Default::default()
+main/src/main.rs:11: replace factorial -> u32 with Default::default()
+main2/src/main.rs:9: replace triple_3 -> i32 with Default::default()
+```
+
 
diff --git a/tests/snapshots/cli__list_mutants_in_factorial_json.snap b/tests/snapshots/cli__list_mutants_in_factorial_json.snap
index c43bbb0..6f65867 100644
--- a/tests/snapshots/cli__list_mutants_in_factorial_json.snap
+++ b/tests/snapshots/cli__list_mutants_in_factorial_json.snap
@@ -4,6 +4,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
 ---
 [
   {
+    "package": "cargo-mutants-testdata-factorial",
     "file": "src/bin/factorial.rs",
     "line": 1,
     "function": "main",
@@ -11,6 +12,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
     "replacement": "()"
   },
   {
+    "package": "cargo-mutants-testdata-factorial",
     "file": "src/bin/factorial.rs",
     "line": 7,
     "function": "factorial",
diff --git a/tests/snapshots/cli__list_mutants_json_well_tested.snap b/tests/snapshots/cli__list_mutants_json_well_tested.snap
index cbf0e68..d07520a 100644
--- a/tests/snapshots/cli__list_mutants_json_well_tested.snap
+++ b/tests/snapshots/cli__list_mutants_json_well_tested.snap
@@ -4,6 +4,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
 ---
 [
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/inside_mod.rs",
     "line": 3,
     "function": "outer::inner::name",
@@ -11,6 +12,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
     "replacement": "Default::default()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/methods.rs",
     "line": 16,
     "function": "Foo::double",
@@ -18,6 +20,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
     "replacement": "()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/methods.rs",
     "line": 22,
     "function": "<impl Display for Foo>::fmt",
@@ -25,6 +28,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
     "replacement": "Ok(Default::default())"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/methods.rs",
     "line": 28,
     "function": "<impl Debug for &Foo>::fmt",
@@ -32,6 +36,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
     "replacement": "Ok(Default::default())"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/nested_function.rs",
     "line": 1,
     "function": "has_nested",
@@ -39,6 +44,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
     "replacement": "Default::default()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/nested_function.rs",
     "line": 2,
     "function": "has_nested::inner",
@@ -46,6 +52,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
     "replacement": "Default::default()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/result.rs",
     "line": 5,
     "function": "simple_result",
@@ -53,6 +60,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
     "replacement": "Ok(Default::default())"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/result.rs",
     "line": 9,
     "function": "error_if_negative",
@@ -60,6 +68,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
     "replacement": "Ok(Default::default())"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/simple_fns.rs",
     "line": 7,
     "function": "returns_unit",
@@ -67,6 +76,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
     "replacement": "()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/simple_fns.rs",
     "line": 12,
     "function": "returns_42u32",
@@ -74,6 +84,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
     "replacement": "Default::default()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/simple_fns.rs",
     "line": 17,
     "function": "divisible_by_three",
@@ -81,6 +92,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
     "replacement": "true"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/simple_fns.rs",
     "line": 17,
     "function": "divisible_by_three",
@@ -88,6 +100,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
     "replacement": "false"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/simple_fns.rs",
     "line": 26,
     "function": "double_string",
@@ -95,6 +108,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
     "replacement": "\"\".into()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/simple_fns.rs",
     "line": 26,
     "function": "double_string",
@@ -102,6 +116,7 @@ expression: "String::from_utf8_lossy(&output.stdout)"
     "replacement": "\"xyzzy\".into()"
   },
   {
+    "package": "cargo-mutants-testdata-well-tested",
     "file": "src/struct_with_lifetime.rs",
     "line": 14,
     "function": "Lex<'buf>::buf_len",
diff --git a/tests/snapshots/cli__mutants.json.snap b/tests/snapshots/cli__mutants.json.snap
index 6b47c92..7fbefa8 100644
--- a/tests/snapshots/cli__mutants.json.snap
+++ b/tests/snapshots/cli__mutants.json.snap
@@ -4,6 +4,7 @@ expression: mutants_json
 ---
 [
   {
+    "package": "cargo-mutants-testdata-factorial",
     "file": "src/bin/factorial.rs",
     "line": 1,
     "function": "main",
@@ -11,6 +12,7 @@ expression: mutants_json
     "replacement": "()"
   },
   {
+    "package": "cargo-mutants-testdata-factorial",
     "file": "src/bin/factorial.rs",
     "line": 7,
     "function": "factorial",