From 146b5dd5a9800deb939e6122acf5db3544cc4573 Mon Sep 17 00:00:00 2001 From: Martin Pool Date: Tue, 7 Nov 2023 13:46:58 -0800 Subject: [PATCH] Add Workspace and PackageFilter Moving towards being able to auto-select packages from workspace subdir --- src/build_dir.rs | 4 +- src/cargo.rs | 222 +---------- src/lab.rs | 2 +- src/main.rs | 17 +- src/mutate.rs | 35 +- src/output.rs | 11 +- src/package.rs | 23 ++ ..._expected_mutants_for_own_source_tree.snap | 40 +- src/source.rs | 12 +- src/visit.rs | 22 +- src/workspace.rs | 357 ++++++++++++++++++ 11 files changed, 468 insertions(+), 277 deletions(-) create mode 100644 src/package.rs create mode 100644 src/workspace.rs diff --git a/src/build_dir.rs b/src/build_dir.rs index a2338966..db7d2e4c 100644 --- a/src/build_dir.rs +++ b/src/build_dir.rs @@ -152,8 +152,8 @@ mod test { #[test] fn build_dir_debug_form() { let options = Options::default(); - let root = cargo::find_workspace("testdata/tree/factorial".into()).unwrap(); - let build_dir = BuildDir::new(&root, &options, &Console::new()).unwrap(); + let workspace = Workspace::open(Utf8Path::new("testdata/tree/factorial")).unwrap(); + let build_dir = BuildDir::new(&workspace.dir, &options, &Console::new()).unwrap(); let debug_form = format!("{build_dir:?}"); assert!( Regex::new(r#"^BuildDir \{ path: "[^"]*[/\\]cargo-mutants-factorial[^"]*" \}$"#) diff --git a/src/cargo.rs b/src/cargo.rs index 02bf6a8d..cb959a7a 100644 --- a/src/cargo.rs +++ b/src/cargo.rs @@ -8,6 +8,7 @@ use std::time::{Duration, Instant}; use anyhow::{anyhow, ensure, Context, Result}; use camino::{Utf8Path, Utf8PathBuf}; +use cargo_metadata::Metadata; use itertools::Itertools; use serde_json::Value; use tracing::debug_span; @@ -15,8 +16,8 @@ use tracing::debug_span; use tracing::{debug, error, info, span, trace, warn, Level}; use crate::outcome::PhaseResult; +use crate::package::Package; use crate::process::{get_command_output, Process}; -use crate::source::Package; use crate::*; /// Run cargo build, check, or test. @@ -50,105 +51,20 @@ pub fn run_cargo( }) } -/// Return the path of the workspace directory enclosing a given directory. -pub fn find_workspace(path: &Utf8Path) -> Result { - ensure!(path.is_dir(), "{path:?} is not a directory"); - let cargo_bin = cargo_bin(); // needed for lifetime - let argv: Vec<&str> = vec![&cargo_bin, "locate-project", "--workspace"]; - let stdout = get_command_output(&argv, path) - .with_context(|| format!("run cargo locate-project in {path:?}"))?; - let val: Value = serde_json::from_str(&stdout).context("parse cargo locate-project output")?; - let cargo_toml_path: Utf8PathBuf = val["root"] - .as_str() - .with_context(|| format!("cargo locate-project output has no root: {stdout:?}"))? - .to_owned() - .into(); - debug!(?cargo_toml_path, "Found workspace root manifest"); - ensure!( - cargo_toml_path.is_file(), - "cargo locate-project root {cargo_toml_path:?} is not a file" - ); - let root = cargo_toml_path - .parent() - .ok_or_else(|| anyhow!("cargo locate-project root {cargo_toml_path:?} has no parent"))? - .to_owned(); - ensure!( - root.is_dir(), - "apparent project root directory {root:?} is not a directory" - ); - Ok(root) -} - -/// Find the root files for each relevant package in the source tree. -/// -/// A source tree might include multiple packages (e.g. in a Cargo workspace), -/// and each package might have multiple targets (e.g. a bin and lib). Test targets -/// are excluded here: we run them, but we don't mutate them. -/// -/// Each target has one root file, typically but not necessarily called `src/lib.rs` -/// or `src/main.rs`. This function returns a list of all those files. -/// -/// After this, there is one more level of discovery, by walking those root files -/// to find `mod` statements, and then recursively walking those files to find -/// all source files. -/// -/// Packages are only included if their name is in `include_packages`. -pub fn top_source_files( - workspace_dir: &Utf8Path, - include_packages: &[String], -) -> Result>> { +pub fn run_cargo_metadata(workspace_dir: &Utf8Path) -> Result { let cargo_toml_path = workspace_dir.join("Cargo.toml"); - debug!(?cargo_toml_path, ?workspace_dir, "Find root files"); + debug!(?cargo_toml_path, ?workspace_dir, "run cargo metadata"); check_interrupted()?; let metadata = cargo_metadata::MetadataCommand::new() .manifest_path(&cargo_toml_path) .exec() .context("run cargo metadata")?; - - let mut r = Vec::new(); - // cargo-metadata output is not obviously ordered so make it deterministic. - for package_metadata in metadata - .workspace_packages() - .iter() - .filter(|p| include_packages.is_empty() || include_packages.contains(&p.name)) - .sorted_by_key(|p| &p.name) - { - check_interrupted()?; - let _span = debug_span!("package", name = %package_metadata.name).entered(); - let manifest_path = &package_metadata.manifest_path; - debug!(%manifest_path, "walk package"); - let relative_manifest_path = manifest_path - .strip_prefix(workspace_dir) - .map_err(|_| { - anyhow!( - "manifest path {manifest_path:?} for package {name:?} is not within the detected source root path {workspace_dir:?}", - name = package_metadata.name - ) - })? - .to_owned(); - let package = Arc::new(Package { - name: package_metadata.name.clone(), - relative_manifest_path, - }); - for source_path in direct_package_sources(workspace_dir, package_metadata)? { - check_interrupted()?; - r.push(Arc::new(SourceFile::new( - workspace_dir, - source_path, - &package, - )?)); - } - } - for p in include_packages { - if !r.iter().any(|sf| sf.package.name == *p) { - warn!("package {p} not found in source tree"); - } - } - Ok(r) + check_interrupted()?; + Ok(metadata) } /// Return the name of the cargo binary. -fn cargo_bin() -> String { +pub fn cargo_bin() -> String { // When run as a Cargo subcommand, which is the usual/intended case, // $CARGO tells us the right way to call back into it, so that we get // the matching toolchain etc. @@ -222,46 +138,6 @@ fn rustflags() -> String { rustflags.join("\x1f") } -/// 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> { - 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) - .map(ToOwned::to_owned) - { - debug!( - "found mutation target {} of kind {:?}", - relpath, target.kind - ); - found.push(relpath); - } else { - warn!("{:?} is not in {:?}", target.src_path, pkg_dir); - } - } else { - debug!( - "skipping target {:?} of kinds {:?}", - target.name, target.kind - ); - } - } - found.sort(); - found.dedup(); - Ok(found) -} - -fn should_mutate_target(target: &cargo_metadata::Target) -> bool { - target.kind.iter().any(|k| k.ends_with("lib") || k == "bin") -} - #[cfg(test)] mod test { use std::ffi::OsStr; @@ -364,88 +240,4 @@ mod test { ] ); } - - #[test] - fn error_opening_outside_of_crate() { - cargo::find_workspace(Utf8Path::new("/")).unwrap_err(); - } - - #[test] - fn open_subdirectory_of_crate_opens_the_crate() { - let root = cargo::find_workspace(Utf8Path::new("testdata/tree/factorial/src")) - .expect("open source tree from subdirectory"); - assert!(root.is_dir()); - assert!(root.join("Cargo.toml").is_file()); - assert!(root.join("src/bin/factorial.rs").is_file()); - assert_eq!(root.file_name().unwrap(), OsStr::new("factorial")); - } - - #[test] - fn find_root_from_subdirectory_of_workspace_finds_the_workspace_root() { - let root = cargo::find_workspace(Utf8Path::new("testdata/tree/workspace/main")) - .expect("Find root from within workspace/main"); - assert_eq!(root.file_name(), Some("workspace"), "Wrong root: {root:?}"); - } - - #[test] - fn find_top_source_files_from_subdirectory_of_workspace() { - let root_dir = cargo::find_workspace(Utf8Path::new("testdata/tree/workspace/main")) - .expect("Find workspace root"); - let top_source_files = top_source_files(&root_dir, &[]).expect("Find root files"); - println!("{top_source_files:#?}"); - let paths = top_source_files - .iter() - .map(|sf| sf.tree_relative_path.to_slash_path()) - .collect_vec(); - // The order here might look strange, but they're actually deterministically - // sorted by the package name, not the path name. - assert_eq!( - paths, - ["utils/src/lib.rs", "main/src/main.rs", "main2/src/main.rs"] - ); - } - - #[test] - fn filter_by_single_package() { - let root_dir = cargo::find_workspace(Utf8Path::new("testdata/tree/workspace/main")) - .expect("Find workspace root"); - assert_eq!( - root_dir.file_name(), - Some("workspace"), - "found the workspace root" - ); - let top_source_files = - top_source_files(&root_dir, &["main".to_owned()]).expect("Find root files"); - println!("{top_source_files:#?}"); - assert_eq!(top_source_files.len(), 1); - assert_eq!( - top_source_files - .iter() - .map(|sf| sf.tree_relative_path.clone()) - .collect_vec(), - ["main/src/main.rs"] - ); - } - - #[test] - fn filter_by_multiple_packages() { - let root_dir = cargo::find_workspace(Utf8Path::new("testdata/tree/workspace/main")) - .expect("Find workspace root"); - assert_eq!( - root_dir.file_name(), - Some("workspace"), - "found the workspace root" - ); - let top_source_files = - top_source_files(&root_dir, &["main".to_owned(), "main2".to_owned()]) - .expect("Find root files"); - println!("{top_source_files:#?}"); - assert_eq!( - top_source_files - .iter() - .map(|sf| sf.tree_relative_path.clone()) - .collect_vec(), - ["main/src/main.rs", "main2/src/main.rs"] - ); - } } diff --git a/src/lab.rs b/src/lab.rs index 241acdc2..7d2c5f82 100644 --- a/src/lab.rs +++ b/src/lab.rs @@ -17,7 +17,7 @@ use crate::cargo::run_cargo; use crate::console::Console; use crate::outcome::{LabOutcome, Phase, ScenarioOutcome}; use crate::output::OutputDir; -use crate::source::Package; +use crate::package::Package; use crate::*; /// Run all possible mutation experiments. diff --git a/src/main.rs b/src/main.rs index c5453c1d..ad69885e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod mutate; mod options; mod outcome; mod output; +mod package; mod path; mod pretty; mod process; @@ -24,6 +25,7 @@ mod scenario; mod source; mod textedit; mod visit; +pub mod workspace; use std::env; use std::io; @@ -53,6 +55,8 @@ use crate::path::Utf8PathSlashes; use crate::scenario::Scenario; use crate::source::SourceFile; use crate::visit::walk_tree; +use crate::workspace::PackageFilter; +use crate::workspace::Workspace; const VERSION: &str = env!("CARGO_PKG_VERSION"); const NAME: &str = env!("CARGO_PKG_NAME"); @@ -222,17 +226,20 @@ fn main() -> Result<()> { console.setup_global_trace(args.level)?; interrupt::install_handler(); - let source_path: &Utf8Path = args.dir.as_deref().unwrap_or(Utf8Path::new(".")); - let workspace_dir = cargo::find_workspace(source_path)?; + let start_dir: &Utf8Path = args.dir.as_deref().unwrap_or(Utf8Path::new(".")); + let workspace = Workspace::open(&start_dir)?; + // let discovered_workspace = discover_packages(start_dir, false, &args.mutate_packages)?; + // let workspace_dir = &discovered_workspace.workspace_dir; let config = if args.no_config { config::Config::default() } else { - config::Config::read_tree_config(&workspace_dir)? + config::Config::read_tree_config(&workspace.dir)? }; debug!(?config); let options = Options::new(&args, &config)?; debug!(?options); - let discovered = walk_tree(&workspace_dir, &args.mutate_packages, &options, &console)?; + let package_filter = PackageFilter::All; // TODO: From args + let discovered = workspace.discover(&package_filter, &options, &console)?; if args.list_files { console.clear(); list_files(FmtToIoWrite::new(io::stdout()), discovered, &options)?; @@ -240,7 +247,7 @@ fn main() -> Result<()> { console.clear(); list_mutants(FmtToIoWrite::new(io::stdout()), discovered, &options)?; } else { - let lab_outcome = test_mutants(discovered.mutants, &workspace_dir, options, &console)?; + let lab_outcome = test_mutants(discovered.mutants, &workspace.dir, options, &console)?; exit(lab_outcome.exit_code()); } Ok(()) diff --git a/src/mutate.rs b/src/mutate.rs index 2a59f42d..41d38704 100644 --- a/src/mutate.rs +++ b/src/mutate.rs @@ -14,7 +14,7 @@ use serde::Serialize; use similar::TextDiff; use crate::build_dir::BuildDir; -use crate::source::Package; +use crate::package::Package; use crate::source::SourceFile; use crate::textedit::{replace_region, Span}; @@ -211,11 +211,11 @@ mod test { #[test] fn discover_factorial_mutants() { let tree_path = Utf8Path::new("testdata/tree/factorial"); - let workspace_dir = cargo::find_workspace(tree_path).unwrap(); + let workspace = Workspace::open(tree_path).unwrap(); let options = Options::default(); - let mutants = walk_tree(&workspace_dir, &[], &options, &Console::new()) - .unwrap() - .mutants; + let mutants = workspace + .mutants(&PackageFilter::All, &options, &Console::new()) + .unwrap(); assert_eq!(mutants.len(), 3); assert_eq!( format!("{:?}", mutants[0]), @@ -264,15 +264,10 @@ mod test { #[test] fn filter_by_attributes() { - let tree_path = Utf8Path::new("testdata/tree/hang_avoided_by_attr"); - let mutants = walk_tree( - &cargo::find_workspace(tree_path).unwrap(), - &[], - &Options::default(), - &Console::new(), - ) - .unwrap() - .mutants; + let mutants = Workspace::open(Utf8Path::new("testdata/tree/hang_avoided_by_attr")) + .unwrap() + .mutants(&PackageFilter::All, &Options::default(), &Console::new()) + .unwrap(); let descriptions = mutants.iter().map(Mutant::describe_change).collect_vec(); insta::assert_snapshot!( descriptions.join("\n"), @@ -281,12 +276,13 @@ mod test { } #[test] - fn mutate_factorial() { + fn mutate_factorial() -> Result<()> { let tree_path = Utf8Path::new("testdata/tree/factorial"); - let source_tree = cargo::find_workspace(tree_path).unwrap(); - let mutants = walk_tree(&source_tree, &[], &Options::default(), &Console::new()) - .unwrap() - .mutants; + let mutants = Workspace::open(&tree_path)?.mutants( + &PackageFilter::All, + &Options::default(), + &Console::new(), + )?; assert_eq!(mutants.len(), 3); let mut mutated_code = mutants[0].mutated_code(); @@ -340,5 +336,6 @@ mod test { "# } ); + Ok(()) } } diff --git a/src/output.rs b/src/output.rs index 611da8b8..54e44183 100644 --- a/src/output.rs +++ b/src/output.rs @@ -279,13 +279,14 @@ mod test { #[test] fn create_output_dir() { let tmp = minimal_source_tree(); - let tmp_path = tmp.path().try_into().unwrap(); - let root = cargo::find_workspace(tmp_path).unwrap(); - let output_dir = OutputDir::new(&root).unwrap(); + let tmp_path: &Utf8Path = tmp.path().try_into().unwrap(); + let workspace = Workspace::open(tmp_path).unwrap(); + let output_dir = OutputDir::new(&workspace.dir).unwrap(); assert_eq!( list_recursive(tmp.path()), &[ "", + "Cargo.lock", "Cargo.toml", "mutants.out", "mutants.out/caught.txt", @@ -298,8 +299,8 @@ mod test { "src/lib.rs", ] ); - assert_eq!(output_dir.path(), root.join("mutants.out")); - assert_eq!(output_dir.log_dir, root.join("mutants.out/log")); + assert_eq!(output_dir.path(), workspace.dir.join("mutants.out")); + assert_eq!(output_dir.log_dir, workspace.dir.join("mutants.out/log")); assert!(output_dir.path().join("lock.json").is_file()); } diff --git a/src/package.rs b/src/package.rs new file mode 100644 index 00000000..c550b918 --- /dev/null +++ b/src/package.rs @@ -0,0 +1,23 @@ +// Copyright 2023 Martin Pool + +//! Discover and represent cargo packages within a workspace. + +use std::sync::Arc; + +use anyhow::{anyhow, Context}; +use camino::{Utf8Path, Utf8PathBuf}; +use itertools::Itertools; +use tracing::{debug_span, warn}; + +use crate::source::SourceFile; +use crate::*; + +/// A package built and tested as a unit. +#[derive(Debug, Eq, PartialEq, Hash, Clone)] +pub struct Package { + /// The short name of the package, like "mutants". + pub name: String, + + /// For Cargo, the path of the `Cargo.toml` manifest file, relative to the top of the tree. + pub relative_manifest_path: Utf8PathBuf, +} diff --git a/src/snapshots/cargo_mutants__visit__test__expected_mutants_for_own_source_tree.snap b/src/snapshots/cargo_mutants__visit__test__expected_mutants_for_own_source_tree.snap index e3d2e0b1..3823baf0 100644 --- a/src/snapshots/cargo_mutants__visit__test__expected_mutants_for_own_source_tree.snap +++ b/src/snapshots/cargo_mutants__visit__test__expected_mutants_for_own_source_tree.snap @@ -13,11 +13,8 @@ src/build_dir.rs: replace copy_tree -> Result with Ok(Default::default( src/build_dir.rs: replace copy_tree -> Result with Err(::anyhow::anyhow!("mutated!")) src/cargo.rs: replace run_cargo -> Result with Ok(Default::default()) src/cargo.rs: replace run_cargo -> Result with Err(::anyhow::anyhow!("mutated!")) -src/cargo.rs: replace find_workspace -> Result with Ok(Default::default()) -src/cargo.rs: replace find_workspace -> Result with Err(::anyhow::anyhow!("mutated!")) -src/cargo.rs: replace top_source_files -> Result>> with Ok(vec![]) -src/cargo.rs: replace top_source_files -> Result>> with Ok(vec![Arc::new(Default::default())]) -src/cargo.rs: replace top_source_files -> Result>> with Err(::anyhow::anyhow!("mutated!")) +src/cargo.rs: replace run_cargo_metadata -> Result with Ok(Default::default()) +src/cargo.rs: replace run_cargo_metadata -> Result with Err(::anyhow::anyhow!("mutated!")) src/cargo.rs: replace cargo_bin -> String with String::new() src/cargo.rs: replace cargo_bin -> String with "xyzzy".into() src/cargo.rs: replace cargo_argv -> Vec with vec![] @@ -25,11 +22,6 @@ src/cargo.rs: replace cargo_argv -> Vec with vec![String::new()] src/cargo.rs: replace cargo_argv -> Vec with vec!["xyzzy".into()] src/cargo.rs: replace rustflags -> String with String::new() src/cargo.rs: replace rustflags -> String with "xyzzy".into() -src/cargo.rs: replace direct_package_sources -> Result> with Ok(vec![]) -src/cargo.rs: replace direct_package_sources -> Result> with Ok(vec![Default::default()]) -src/cargo.rs: replace direct_package_sources -> Result> with Err(::anyhow::anyhow!("mutated!")) -src/cargo.rs: replace should_mutate_target -> bool with true -src/cargo.rs: replace should_mutate_target -> bool with false src/config.rs: replace Config::read_file -> Result with Ok(Default::default()) src/config.rs: replace Config::read_file -> Result with Err(::anyhow::anyhow!("mutated!")) src/config.rs: replace Config::read_tree_config -> Result with Ok(Default::default()) @@ -335,4 +327,32 @@ src/visit.rs: replace path_is -> bool with true src/visit.rs: replace path_is -> bool with false src/visit.rs: replace attr_is_mutants_skip -> bool with true src/visit.rs: replace attr_is_mutants_skip -> bool with false +src/workspace.rs: replace ::fmt -> fmt::Result with Ok(Default::default()) +src/workspace.rs: replace ::fmt -> fmt::Result with Err(::anyhow::anyhow!("mutated!")) +src/workspace.rs: replace PackageFilter::explicit -> PackageFilter with Default::default() +src/workspace.rs: replace Workspace::open -> Result with Ok(Default::default()) +src/workspace.rs: replace Workspace::open -> Result with Err(::anyhow::anyhow!("mutated!")) +src/workspace.rs: replace Workspace::packages -> Result>> with Ok(vec![]) +src/workspace.rs: replace Workspace::packages -> Result>> with Ok(vec![Arc::new(Default::default())]) +src/workspace.rs: replace Workspace::packages -> Result>> with Err(::anyhow::anyhow!("mutated!")) +src/workspace.rs: replace Workspace::top_package_sources -> Result> with Ok(vec![]) +src/workspace.rs: replace Workspace::top_package_sources -> Result> with Ok(vec![Default::default()]) +src/workspace.rs: replace Workspace::top_package_sources -> Result> with Err(::anyhow::anyhow!("mutated!")) +src/workspace.rs: replace Workspace::top_sources -> Result>> with Ok(vec![]) +src/workspace.rs: replace Workspace::top_sources -> Result>> with Ok(vec![Arc::new(Default::default())]) +src/workspace.rs: replace Workspace::top_sources -> Result>> with Err(::anyhow::anyhow!("mutated!")) +src/workspace.rs: replace Workspace::discover -> Result with Ok(Default::default()) +src/workspace.rs: replace Workspace::discover -> Result with Err(::anyhow::anyhow!("mutated!")) +src/workspace.rs: replace Workspace::mutants -> Result> with Ok(vec![]) +src/workspace.rs: replace Workspace::mutants -> Result> with Ok(vec![Default::default()]) +src/workspace.rs: replace Workspace::mutants -> Result> with Err(::anyhow::anyhow!("mutated!")) +src/workspace.rs: replace filter_package_metadata -> Vec<&'m cargo_metadata::Package> with vec![] +src/workspace.rs: replace filter_package_metadata -> Vec<&'m cargo_metadata::Package> with vec![&Default::default()] +src/workspace.rs: replace direct_package_sources -> Result> with Ok(vec![]) +src/workspace.rs: replace direct_package_sources -> Result> with Ok(vec![Default::default()]) +src/workspace.rs: replace direct_package_sources -> Result> with Err(::anyhow::anyhow!("mutated!")) +src/workspace.rs: replace should_mutate_target -> bool with true +src/workspace.rs: replace should_mutate_target -> bool with false +src/workspace.rs: replace find_workspace -> Result with Ok(Default::default()) +src/workspace.rs: replace find_workspace -> Result with Err(::anyhow::anyhow!("mutated!")) diff --git a/src/source.rs b/src/source.rs index 44eaa173..1984f12c 100644 --- a/src/source.rs +++ b/src/source.rs @@ -9,6 +9,7 @@ use camino::{Utf8Path, Utf8PathBuf}; #[allow(unused_imports)] use tracing::{debug, info, warn}; +use crate::package::Package; use crate::path::Utf8PathSlashes; /// A Rust source file within a source tree. @@ -23,7 +24,7 @@ pub struct SourceFile { /// Package within the workspace. pub package: Arc, - /// Path relative to the root of the tree. + /// Path of this source file relative to workspace. pub tree_relative_path: Utf8PathBuf, /// Full copy of the source. @@ -56,15 +57,6 @@ impl SourceFile { } } -/// A package built and tested as a unit. -#[derive(Debug, Eq, PartialEq, Hash, Clone)] -pub struct Package { - /// The short name of the package, like "mutants". - pub name: String, - /// For Cargo, the path of the `Cargo.toml` manifest file, relative to the top of the tree. - pub relative_manifest_path: Utf8PathBuf, -} - #[cfg(test)] mod test { use std::fs::File; diff --git a/src/visit.rs b/src/visit.rs index fec886f9..af2ebef6 100644 --- a/src/visit.rs +++ b/src/visit.rs @@ -17,7 +17,6 @@ use syn::visit::Visit; use syn::{Attribute, Expr, ItemFn, ReturnType}; use tracing::{debug, debug_span, trace, trace_span, warn}; -use crate::cargo::top_source_files; use crate::fnvalue::return_type_replacements; use crate::pretty::ToPrettyString; use crate::source::SourceFile; @@ -36,21 +35,20 @@ pub struct Discovered { /// /// The list of source files includes even those with no mutants. /// -/// `mutate_packages`: If non-empty, only generate mutants from these packages. pub fn walk_tree( workspace_dir: &Utf8Path, - mutate_packages: &[String], + top_source_files: &[Arc], options: &Options, console: &Console, ) -> Result { + // TODO: Lift up parsing the error expressions... let error_exprs = options .error_values .iter() .map(|e| syn::parse_str(e).with_context(|| format!("Failed to parse error value {e:?}"))) .collect::>>()?; console.walk_tree_start(); - let mut file_queue: VecDeque> = - top_source_files(workspace_dir, mutate_packages)?.into(); + let mut file_queue: VecDeque> = top_source_files.iter().cloned().collect(); let mut mutants = Vec::new(); let mut files: Vec> = Vec::new(); while let Some(source_file) = file_queue.pop_front() { @@ -413,12 +411,16 @@ mod test { ..Default::default() }; let mut list_output = String::new(); - let workspace_dir = &Utf8Path::new(".") - .canonicalize_utf8() - .expect("Canonicalize source path"); let console = Console::new(); - let discovered = - walk_tree(workspace_dir, &[], &options, &console).expect("Discover mutants"); + let workspace = Workspace::open( + &Utf8Path::new(".") + .canonicalize_utf8() + .expect("Canonicalize source path"), + ) + .unwrap(); + let discovered = workspace + .discover(&PackageFilter::All, &options, &console) + .expect("Discover mutants"); crate::list_mutants(&mut list_output, discovered, &options) .expect("Discover mutants in own source tree"); diff --git a/src/workspace.rs b/src/workspace.rs new file mode 100644 index 00000000..10ce8e78 --- /dev/null +++ b/src/workspace.rs @@ -0,0 +1,357 @@ +// Copyright 2023 Martin Pool + +use std::fmt; +use std::sync::Arc; + +use anyhow::{anyhow, ensure, Context}; +use camino::{Utf8Path, Utf8PathBuf}; +use itertools::Itertools; +use serde_json::Value; +use tracing::{debug, debug_span, warn}; + +use crate::cargo::cargo_bin; +use crate::console::Console; +use crate::interrupt::check_interrupted; +use crate::mutate::Mutant; +use crate::options::Options; +use crate::package::Package; +use crate::process::get_command_output; +use crate::source::SourceFile; +use crate::visit::{walk_tree, Discovered}; +use crate::Result; + +pub struct Workspace { + pub dir: Utf8PathBuf, + metadata: cargo_metadata::Metadata, +} + +impl fmt::Debug for Workspace { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Workspace") + .field("dir", &self.dir) + // .field("metadata", &self.metadata) + .finish() + } +} + +pub enum PackageFilter { + All, + Explicit(Vec), + Auto(Utf8PathBuf), +} + +impl PackageFilter { + pub fn explicit>(names: I) -> PackageFilter { + PackageFilter::Explicit(names.into_iter().map(|s| s.to_string()).collect_vec()) + } +} + +impl Workspace { + pub fn open(start_dir: &Utf8Path) -> Result { + let dir = find_workspace(start_dir)?; + let cargo_toml_path = dir.join("Cargo.toml"); + debug!(?cargo_toml_path, ?dir, "Find root files"); + check_interrupted()?; + let metadata = cargo_metadata::MetadataCommand::new() + .manifest_path(&cargo_toml_path) + .exec() + .context("run cargo metadata")?; + Ok(Workspace { dir, metadata }) + } + + /// Find packages to mutate, subject to some filtering. + pub fn packages(&self, package_filter: &PackageFilter) -> Result>> { + let mut packages = Vec::new(); + for package_metadata in filter_package_metadata(&self.metadata, package_filter) + .into_iter() + .sorted_by_key(|p| &p.name) + { + check_interrupted()?; + let name = &package_metadata.name; + let _span = debug_span!("package", %name).entered(); + let manifest_path = &package_metadata.manifest_path; + debug!(%manifest_path, "walk package"); + let relative_manifest_path = manifest_path + .strip_prefix(&self.dir) + .map_err(|_| { + anyhow!( + "manifest path {manifest_path:?} for package {name:?} is not \ + within the detected source root path {dir:?}", + dir = self.dir + ) + })? + .to_owned(); + let package = Arc::new(Package { + name: package_metadata.name.clone(), + relative_manifest_path, + }); + packages.push(package); + } + if let PackageFilter::Explicit(names) = package_filter { + for wanted in names { + if !packages.iter().any(|found| found.name == *wanted) { + warn!("package {wanted:?} not found in source tree"); + } + } + } + Ok(packages) + } + + /// Return the top source files (like `src/lib.rs`) for a named package. + fn top_package_sources(&self, package_name: &str) -> Result> { + if let Some(package_metadata) = self + .metadata + .workspace_packages() + .iter() + .find(|p| p.name == package_name) + { + direct_package_sources(&self.dir, package_metadata) + } else { + Err(anyhow!( + "package {package_name:?} not found in workspace metadata" + )) + } + } + + /// Find all the top source files for selected packages. + pub fn top_sources(&self, package_filter: &PackageFilter) -> Result>> { + let mut sources = Vec::new(); + for package in self.packages(package_filter)? { + for source_path in self.top_package_sources(&package.name)? { + sources.push(Arc::new(SourceFile::new( + &self.dir, + source_path.to_owned(), + &package, + )?)); + } + } + Ok(sources) + } + + /// Make all the mutants from the filtered packages in this workspace. + pub fn discover( + &self, + package_filter: &PackageFilter, + options: &Options, + console: &Console, + ) -> Result { + walk_tree( + &self.dir, + &self.top_sources(package_filter)?, + options, + console, + ) + } + + pub fn mutants( + &self, + package_filter: &PackageFilter, + options: &Options, + console: &Console, + ) -> Result> { + Ok(self.discover(package_filter, options, console)?.mutants) + } +} + +fn filter_package_metadata<'m>( + metadata: &'m cargo_metadata::Metadata, + package_filter: &PackageFilter, +) -> Vec<&'m cargo_metadata::Package> { + metadata + .workspace_packages() + .iter() + .filter(move |pmeta| match package_filter { + PackageFilter::All => true, + PackageFilter::Explicit(include_names) => include_names.contains(&pmeta.name), + PackageFilter::Auto(..) => todo!(), + }) + .sorted_by_key(|pm| &pm.name) + .copied() + .collect() +} + +/// 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> { + 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) + .map(ToOwned::to_owned) + { + debug!( + "found mutation target {} of kind {:?}", + relpath, target.kind + ); + found.push(relpath); + } else { + warn!("{:?} is not in {:?}", target.src_path, pkg_dir); + } + } else { + debug!( + "skipping target {:?} of kinds {:?}", + target.name, target.kind + ); + } + } + found.sort(); + found.dedup(); + Ok(found) +} + +fn should_mutate_target(target: &cargo_metadata::Target) -> bool { + target.kind.iter().any(|k| k.ends_with("lib") || k == "bin") +} + +/// Return the path of the workspace directory enclosing a given directory. +fn find_workspace(path: &Utf8Path) -> Result { + ensure!(path.is_dir(), "{path:?} is not a directory"); + let cargo_bin = cargo_bin(); // needed for lifetime + let argv: Vec<&str> = vec![&cargo_bin, "locate-project", "--workspace"]; + let stdout = get_command_output(&argv, path) + .with_context(|| format!("run cargo locate-project in {path:?}"))?; + let val: Value = serde_json::from_str(&stdout).context("parse cargo locate-project output")?; + let cargo_toml_path: Utf8PathBuf = val["root"] + .as_str() + .with_context(|| format!("cargo locate-project output has no root: {stdout:?}"))? + .to_owned() + .into(); + debug!(?cargo_toml_path, "Found workspace root manifest"); + ensure!( + cargo_toml_path.is_file(), + "cargo locate-project root {cargo_toml_path:?} is not a file" + ); + let root = cargo_toml_path + .parent() + .ok_or_else(|| anyhow!("cargo locate-project root {cargo_toml_path:?} has no parent"))? + .to_owned(); + ensure!( + root.is_dir(), + "apparent project root directory {root:?} is not a directory" + ); + Ok(root) +} + +#[cfg(test)] +mod test { + use std::ffi::OsStr; + + use camino::Utf8Path; + use itertools::Itertools; + + use crate::console::Console; + use crate::options::Options; + use crate::workspace::PackageFilter; + + use super::Workspace; + + #[test] + fn error_opening_outside_of_crate() { + Workspace::open(&Utf8Path::new("/")).unwrap_err(); + } + + #[test] + fn open_subdirectory_of_crate_opens_the_crate() { + let workspace = Workspace::open(Utf8Path::new("testdata/tree/factorial/src")) + .expect("open source tree from subdirectory"); + let root = &workspace.dir; + assert!(root.is_dir()); + assert!(root.join("Cargo.toml").is_file()); + assert!(root.join("src/bin/factorial.rs").is_file()); + assert_eq!(root.file_name().unwrap(), OsStr::new("factorial")); + } + + #[test] + fn find_root_from_subdirectory_of_workspace_finds_the_workspace_root() { + let root = Workspace::open(Utf8Path::new("testdata/tree/workspace/main")) + .expect("Find root from within workspace/main") + .dir; + assert_eq!(root.file_name(), Some("workspace"), "Wrong root: {root:?}"); + } + + #[test] + fn find_top_source_files_from_subdirectory_of_workspace() { + let workspace = Workspace::open(Utf8Path::new("testdata/tree/workspace/main")) + .expect("Find workspace root"); + assert_eq!( + workspace + .packages(&PackageFilter::All) + .unwrap() + .iter() + .map(|p| p.name.clone()) + .collect_vec(), + ["cargo_mutants_testdata_workspace_utils", "main", "main2"] + ); + assert_eq!( + workspace + .top_sources(&PackageFilter::All) + .unwrap() + .iter() + .map(|sf| sf.tree_relative_path.clone()) + .collect_vec(), + // ordered by package name + ["utils/src/lib.rs", "main/src/main.rs", "main2/src/main.rs"] + ); + } + + #[test] + fn filter_by_single_package() { + let workspace = Workspace::open(Utf8Path::new("testdata/tree/workspace/main")) + .expect("Find workspace root"); + let root_dir = &workspace.dir; + assert_eq!( + root_dir.file_name(), + Some("workspace"), + "found the workspace root" + ); + let filter = PackageFilter::explicit(["main"]); + assert_eq!( + workspace + .packages(&filter) + .unwrap() + .iter() + .map(|p| p.name.clone()) + .collect_vec(), + ["main"] + ); + let top_sources = workspace.top_sources(&filter).unwrap(); + println!("{top_sources:#?}"); + assert_eq!( + top_sources + .iter() + .map(|sf| sf.tree_relative_path.clone()) + .collect_vec(), + [Utf8Path::new("main/src/main.rs")] + ); + } + + #[test] + fn filter_by_multiple_packages() { + let workspace = Workspace::open(Utf8Path::new("testdata/tree/workspace/main")).unwrap(); + assert_eq!( + workspace.dir.file_name(), + Some("workspace"), + "found the workspace root" + ); + let selection = PackageFilter::explicit(["main", "main2"]); + let discovered = workspace + .discover(&selection, &Options::default(), &Console::new()) + .unwrap(); + + assert_eq!( + discovered + .files + .iter() + .map(|sf| sf.tree_relative_path.clone()) + .collect_vec(), + ["main/src/main.rs", "main2/src/main.rs"] + ); + } +}