#![warn(clippy::pedantic)]
use std::fmt;
use std::panic::catch_unwind;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use anyhow::{Context, anyhow, bail, ensure};
use camino::{Utf8Path, Utf8PathBuf};
use itertools::Itertools;
use serde_json::Value;
use tracing::{debug, error, warn};
use crate::Result;
use crate::cargo::cargo_bin;
use crate::console::Console;
use crate::interrupt::check_interrupted;
use crate::options::Options;
use crate::package::{Package, PackageSelection, packages_from_metadata};
use crate::visit::{Discovered, walk_tree};
#[derive(Debug, Clone)]
pub enum PackageFilter {
All,
Explicit(Vec<String>),
Auto(Utf8PathBuf),
}
impl PackageFilter {
pub fn explicit<S: ToString, I: IntoIterator<Item = S>>(names: I) -> PackageFilter {
PackageFilter::Explicit(names.into_iter().map(|s| s.to_string()).collect_vec())
}
}
pub struct Workspace {
metadata: cargo_metadata::Metadata,
packages: Vec<Arc<Package>>,
}
impl fmt::Debug for Workspace {
#[mutants::skip]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Workspace")
.field("root", &self.root().to_string())
.finish_non_exhaustive()
}
}
impl Workspace {
pub fn root(&self) -> &Utf8Path {
&self.metadata.workspace_root
}
pub fn open<P: AsRef<Path>>(start_dir: P) -> Result<Self> {
let start_dir = start_dir.as_ref();
let dir = locate_project(start_dir.try_into().expect("start_dir is UTF-8"), true)?;
assert!(
dir.is_absolute(),
"project location {dir:?} is not absolute"
);
let manifest_path = dir.join("Cargo.toml");
debug!(?manifest_path, "Find workspace metadata");
check_interrupted()?;
let metadata = cargo_metadata::MetadataCommand::new()
.no_deps()
.manifest_path(&manifest_path)
.current_dir(&dir)
.verbose(false)
.exec()
.with_context(|| format!("Failed to run cargo metadata on {manifest_path}"))?;
debug!(workspace_root = ?metadata.workspace_root, "Found workspace root");
let packages = packages_from_metadata(&metadata);
debug!(?packages, "Found packages");
Ok(Workspace { metadata, packages })
}
pub fn packages_by_name<S: AsRef<str>>(&self, names: &[S]) -> Vec<Arc<Package>> {
names
.iter()
.map(AsRef::as_ref)
.sorted()
.filter_map(|name| {
if let Some(p) = self.packages.iter().find(|p| p.name == name) {
Some(Arc::clone(p))
} else {
warn!("Package {name:?} not found in source tree");
None
}
})
.collect()
}
fn filter_packages(&self, filter: &PackageFilter) -> Result<PackageSelection> {
match filter {
PackageFilter::Auto(dir) => {
let root = self.root();
let package_dir = locate_project(dir, false)?;
assert!(package_dir.is_absolute());
for package in &self.packages {
if root.join(&package.relative_dir) == package_dir {
debug!(
package = package.name,
?package_dir,
"Resolved auto package filter based on enclosing directory"
);
return Ok(PackageSelection::Explicit(vec![package.clone()]));
}
}
ensure!(
package_dir == root,
"package {package_dir:?} doesn't match any child and doesn't match the workspace root {root:?}?",
);
let default_packages = self.default_packages();
debug!(
?default_packages,
"Resolved auto package filter to workspace default packages"
);
Ok(default_packages)
}
PackageFilter::All => Ok(PackageSelection::All),
PackageFilter::Explicit(names) => {
Ok(PackageSelection::Explicit(self.packages_by_name(names)))
}
}
}
fn expand_selection(&self, selection: PackageSelection) -> Vec<Arc<Package>> {
match selection {
PackageSelection::All => self.packages.clone(),
PackageSelection::Explicit(packages) => packages,
}
}
pub fn discover(
&self,
package_filter: &PackageFilter,
options: &Options,
console: &Console,
) -> Result<Discovered> {
walk_tree(
self.root(),
&self.expand_selection(self.filter_packages(package_filter)?),
options,
console,
)
}
fn default_packages(&self) -> PackageSelection {
let metadata = &self.metadata;
match catch_unwind(|| metadata.workspace_default_packages()) {
Ok(default_packages) => {
let default_package_names: Vec<&str> = default_packages
.iter()
.map(|pmeta| pmeta.name.as_str())
.sorted() .collect();
debug!(
?default_package_names,
"Manifest defines explicit default packages"
);
PackageSelection::Explicit(self.packages_by_name(&default_package_names))
}
Err(err) => {
warn!(
cargo_metadata_error = err.downcast::<String>().unwrap_or_default(),
"workspace_default_packages is not supported; testing all packages",
);
PackageSelection::All
}
}
}
}
fn locate_project(path: &Utf8Path, workspace: bool) -> Result<Utf8PathBuf> {
ensure!(path.is_dir(), "{path:?} is not a directory");
let mut args: Vec<&str> = vec!["locate-project"];
if workspace {
args.push("--workspace");
}
let output = Command::new(cargo_bin())
.args(&args)
.current_dir(path)
.output()
.with_context(|| format!("failed to spawn {args:?}"))?;
let exit = output.status;
if !exit.success() {
error!(
?exit,
"cargo locate-project failed: {}",
String::from_utf8_lossy(&output.stderr)
);
bail!("cargo locate-project failed");
}
let stdout =
String::from_utf8(output.stdout).context("cargo locate-project output is not UTF-8")?;
debug!("cargo locate-project output: {}", stdout.trim());
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 assert_matches::assert_matches;
use camino::Utf8PathBuf;
use itertools::Itertools;
use crate::console::Console;
use crate::options::Options;
use crate::package::PackageSelection;
use crate::test_util::copy_of_testdata;
use crate::workspace::PackageFilter;
use super::Workspace;
#[test]
fn error_opening_outside_of_crate() {
Workspace::open("/").unwrap_err();
}
#[test]
fn open_subdirectory_of_crate_opens_the_crate() {
let tmp = copy_of_testdata("factorial");
let workspace = Workspace::open(&tmp).expect("open source tree from subdirectory");
let root = workspace.root();
assert!(root.is_dir());
assert!(root.join("Cargo.toml").is_file());
assert!(root.join("src/bin/factorial.rs").is_file());
}
#[test]
fn find_root_from_subdirectory_of_workspace_finds_the_workspace_root() {
let tmp = copy_of_testdata("workspace");
let workspace = Workspace::open(tmp.path()).expect("Find root from within workspace/main");
let root = workspace.root();
assert_eq!(
root.canonicalize().unwrap(),
tmp.path().canonicalize().unwrap()
);
}
#[test]
fn find_top_source_files_from_subdirectory_of_workspace() {
let tmp = copy_of_testdata("workspace");
let workspace = Workspace::open(tmp.path()).expect("Find workspace root");
let packages = workspace.filter_packages(&PackageFilter::All).unwrap();
assert_matches!(packages, super::PackageSelection::All);
let packages = workspace.expand_selection(packages);
assert_eq!(packages[0].name, "cargo_mutants_testdata_workspace_utils");
assert_eq!(packages[0].top_sources, ["utils/src/lib.rs"]);
assert_eq!(packages[1].name, "main");
assert_eq!(packages[1].top_sources, ["main/src/main.rs"]);
assert_eq!(packages[2].name, "main2");
assert_eq!(packages[2].top_sources, ["main2/src/main.rs"]);
}
#[test]
fn package_filter_all_from_subdir_gets_everything() {
let tmp = copy_of_testdata("workspace");
let workspace = Workspace::open(tmp.path().join("main")).expect("Find workspace root");
let packages = workspace.filter_packages(&PackageFilter::All).unwrap();
assert_matches!(packages, super::PackageSelection::All);
let packages = workspace.expand_selection(packages);
assert_eq!(
packages.iter().map(|p| &p.name).collect_vec(),
["cargo_mutants_testdata_workspace_utils", "main", "main2"]
);
}
#[test]
fn auto_packages_in_workspace_subdir_finds_single_package() {
let tmp = copy_of_testdata("workspace");
let subdir_path = Utf8PathBuf::try_from(tmp.path().join("main")).unwrap();
let workspace = Workspace::open(&subdir_path).expect("Find workspace root");
let packages = workspace
.filter_packages(&PackageFilter::Auto(subdir_path.clone()))
.unwrap();
let PackageSelection::Explicit(packages) = packages else {
panic!("Expected PackageSelection::Explicit, got {packages:?}");
};
assert_eq!(packages.len(), 1);
assert_eq!(packages[0].name, "main");
}
#[test]
fn auto_packages_in_virtual_workspace_gets_everything() {
let tmp = copy_of_testdata("workspace");
let workspace = Workspace::open(tmp.path()).expect("Find workspace root");
let packages = workspace
.filter_packages(&PackageFilter::Auto(
tmp.path().to_owned().try_into().unwrap(),
))
.unwrap();
let PackageSelection::Explicit(packages) = packages else {
panic!("Expected PackageSelection::Explicit, got {packages:?}");
};
assert_eq!(
packages.iter().map(|p| &p.name).sorted().collect_vec(),
["cargo_mutants_testdata_workspace_utils", "main", "main2"]
);
}
#[test]
fn filter_by_single_package() {
let tmp = copy_of_testdata("workspace");
let workspace = Workspace::open(tmp.path().join("main")).expect("Find workspace root");
let root_dir = workspace.root();
assert_eq!(
root_dir.canonicalize().unwrap(),
tmp.path().canonicalize().unwrap()
);
let filter = PackageFilter::explicit(["main"]);
let packages = workspace.filter_packages(&filter).unwrap();
println!("{packages:#?}");
let PackageSelection::Explicit(packages) = packages else {
panic!("Expected PackageSelection::Explicit, got {packages:?}");
};
assert_eq!(packages.len(), 1);
assert_eq!(packages[0].name, "main");
assert_eq!(packages[0].top_sources, ["main/src/main.rs"]);
}
#[test]
fn filter_by_multiple_packages() {
let tmp = copy_of_testdata("workspace");
let workspace = Workspace::open(tmp.path().join("main")).expect("Find workspace root");
assert_eq!(
workspace.root().canonicalize().unwrap(),
tmp.path().canonicalize().unwrap()
);
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"]
);
}
}