use std::collections::BTreeSet;
use std::path::PathBuf;
use cargo_metadata::TargetKind;
use crate::error::{ChdirConflictsWithOnceError, EachError};
use crate::substitute::{Placeholders, substitute, validate_placeholders};
use crate::workspace::Member;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Mode {
PerPackage,
PerTarget,
Once,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PackagesExpansion {
Workspace,
Explicit,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Invocation {
pub(crate) label: Option<String>,
pub(crate) argv: Vec<String>,
pub(crate) work_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Plan {
pub(crate) invocations: Vec<Invocation>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct BuildOptions<'a> {
pub(crate) mode: Mode,
pub(crate) chdir: bool,
pub(crate) packages: PackagesExpansion,
pub(crate) target_kinds: &'a BTreeSet<TargetKind>,
pub(crate) target_required_features: &'a BTreeSet<String>,
pub(crate) workspace_rust_version: Option<&'a str>,
}
impl Plan {
pub(crate) fn is_empty(members: &[&Member], command: &[String], options: BuildOptions<'_>) -> Result<bool, EachError> {
validate_build(command, options)?;
Ok(match options.mode {
Mode::PerPackage | Mode::Once => members.is_empty(),
Mode::PerTarget => !members
.iter()
.flat_map(|member| &member.targets)
.any(|target| target_matches(target, options.target_kinds, options.target_required_features)),
})
}
pub(crate) fn build(members: &[&Member], command: &[String], options: BuildOptions<'_>) -> Result<Self, EachError> {
if Self::is_empty(members, command, options)? {
return Ok(Self { invocations: Vec::new() });
}
let BuildOptions {
mode,
chdir,
packages,
target_kinds,
target_required_features,
workspace_rust_version,
} = options;
let invocations = match mode {
Mode::PerPackage => members
.iter()
.map(|m| {
let placeholders = Placeholders::Package {
name: m.name.clone(),
spec: m.spec(),
version: m.version.clone(),
manifest: m.manifest_path.display().to_string(),
workspace_rust_version: workspace_rust_version.map(str::to_owned),
};
Ok(Invocation {
label: Some(m.name.clone()),
argv: substitute(command, &placeholders)?,
work_dir: chdir.then(|| m.manifest_dir().to_path_buf()),
})
})
.collect::<Result<Vec<_>, EachError>>()?,
Mode::PerTarget => members
.iter()
.flat_map(|member| {
member
.targets
.iter()
.filter(|target| target_matches(target, target_kinds, target_required_features))
.map(|target| {
let placeholders = Placeholders::Target {
name: member.name.clone(),
spec: member.spec(),
version: member.version.clone(),
manifest: member.manifest_path.display().to_string(),
target: target.name.clone(),
workspace_rust_version: workspace_rust_version.map(str::to_owned),
};
Ok(Invocation {
label: Some(format!("{}::{}", member.name, target.name)),
argv: substitute(command, &placeholders)?,
work_dir: chdir.then(|| member.manifest_dir().to_path_buf()),
})
})
})
.collect::<Result<Vec<_>, EachError>>()?,
Mode::Once => {
let packages = packages_flags(members, packages);
let placeholders = Placeholders::Once {
packages,
workspace_rust_version: workspace_rust_version.map(str::to_owned),
};
vec![Invocation {
label: None,
argv: substitute(command, &placeholders)?,
work_dir: None,
}]
}
};
Ok(Self { invocations })
}
}
fn validate_build(command: &[String], options: BuildOptions<'_>) -> Result<(), EachError> {
if options.chdir && options.mode == Mode::Once {
return Err(ChdirConflictsWithOnceError::new().into());
}
validate_placeholders(command, options.mode)
}
fn target_matches(
target: &crate::workspace::MemberTarget,
target_kinds: &BTreeSet<TargetKind>,
target_required_features: &BTreeSet<String>,
) -> bool {
target.kinds.iter().any(|kind| target_kinds.contains(kind))
&& target_required_features
.iter()
.all(|feature| target.required_features.contains(feature))
}
fn packages_flags(members: &[&Member], packages: PackagesExpansion) -> Vec<String> {
if packages == PackagesExpansion::Workspace {
return vec!["--workspace".to_owned()];
}
members.iter().flat_map(|member| ["--package".to_owned(), member.spec()]).collect()
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use serde_json::Value;
use super::*;
use crate::workspace::MemberTarget;
fn member(name: &str) -> Member {
Member {
name: name.to_owned(),
version: "1.2.3".to_owned(),
rust_version: Some("1.70.0".parse().expect("valid Rust version")),
manifest_path: PathBuf::from(format!("/ws/{name}/Cargo.toml")),
publishable: true,
features: BTreeSet::new(),
targets: Vec::new(),
dependencies: BTreeSet::new(),
metadata: Value::Null,
}
}
fn cmd(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| (*s).to_owned()).collect()
}
fn build(members: &[&Member], mode: Mode, chdir: bool, packages: PackagesExpansion, command: &[String]) -> Result<Plan, EachError> {
Plan::build(
members,
command,
BuildOptions {
mode,
chdir,
packages,
target_kinds: &BTreeSet::new(),
target_required_features: &BTreeSet::new(),
workspace_rust_version: None,
},
)
}
#[test]
fn empty_set_yields_empty_plan() {
let plan = build(&[], Mode::PerPackage, false, PackagesExpansion::Explicit, &cmd(&["cargo", "test"])).expect("build");
assert!(plan.invocations.is_empty());
}
#[test]
fn empty_set_still_validates_placeholders() {
let err = build(
&[],
Mode::Once,
false,
PackagesExpansion::Explicit,
&cmd(&["cargo", "test", "{name}"]),
)
.expect_err("misused placeholder must error");
assert!(err.to_string().contains("{name}"));
let plan = build(
&[],
Mode::Once,
false,
PackagesExpansion::Explicit,
&cmd(&["cargo", "test", "{packages}"]),
)
.expect("build");
assert!(plan.invocations.is_empty());
}
#[test]
fn per_package_builds_one_invocation_each() {
let a = member("alpha");
let b = member("beta");
let plan = build(
&[&a, &b],
Mode::PerPackage,
false,
PackagesExpansion::Explicit,
&cmd(&["cargo", "check-external-types", "--manifest-path", "{manifest}"]),
)
.expect("build");
assert_eq!(plan.invocations.len(), 2);
assert_eq!(plan.invocations[0].label.as_deref(), Some("alpha"));
assert_eq!(
plan.invocations[0].argv,
["cargo", "check-external-types", "--manifest-path", "/ws/alpha/Cargo.toml"]
);
assert!(plan.invocations[0].work_dir.is_none());
assert_eq!(plan.invocations[1].argv[3], "/ws/beta/Cargo.toml");
}
#[test]
fn chdir_sets_work_dir_to_crate_root() {
let a = member("alpha");
let plan = build(&[&a], Mode::PerPackage, true, PackagesExpansion::Explicit, &cmd(&["cargo", "fmt"])).expect("build");
assert_eq!(plan.invocations[0].work_dir.as_deref(), Some(PathBuf::from("/ws/alpha").as_path()));
}
#[test]
fn chdir_with_once_is_a_usage_error() {
let a = member("alpha");
let err = build(
&[&a],
Mode::Once,
true,
PackagesExpansion::Explicit,
&cmd(&["cargo", "test", "{packages}"]),
)
.expect_err("chdir+once must error");
let rendered = err.to_string();
assert!(rendered.contains("--chdir"), "rendered: {rendered}");
assert!(rendered.contains("--once"), "rendered: {rendered}");
}
#[test]
fn once_whole_workspace_uses_workspace_flag() {
let a = member("alpha");
let plan = build(
&[&a],
Mode::Once,
false,
PackagesExpansion::Workspace,
&cmd(&["cargo", "clippy", "{packages}"]),
)
.expect("build");
assert_eq!(plan.invocations.len(), 1);
assert_eq!(plan.invocations[0].argv, ["cargo", "clippy", "--workspace"]);
}
#[test]
fn once_subset_uses_explicit_package_flags() {
let a = member("alpha");
let b = member("beta");
let plan = build(
&[&a, &b],
Mode::Once,
false,
PackagesExpansion::Explicit,
&cmd(&["cargo", "clippy", "{packages}"]),
)
.expect("build");
assert_eq!(
plan.invocations[0].argv,
["cargo", "clippy", "--package", "alpha@1.2.3", "--package", "beta@1.2.3"]
);
}
#[test]
fn per_target_filters_and_substitutes_targets() {
let mut a = member("alpha");
a.targets = vec![
MemberTarget {
name: "ordinary".to_owned(),
kinds: std::iter::once(TargetKind::Test).collect(),
required_features: BTreeSet::new(),
},
MemberTarget {
name: "loom".to_owned(),
kinds: std::iter::once(TargetKind::Test).collect(),
required_features: std::iter::once("loom".to_owned()).collect(),
},
];
let kinds = std::iter::once(TargetKind::Test).collect();
let required = std::iter::once("loom".to_owned()).collect();
let plan = Plan::build(
&[&a],
&cmd(&["cargo", "test", "-p", "{name}", "--test", "{target}"]),
BuildOptions {
mode: Mode::PerTarget,
chdir: false,
packages: PackagesExpansion::Explicit,
target_kinds: &kinds,
target_required_features: &required,
workspace_rust_version: None,
},
)
.expect("build target plan");
assert_eq!(plan.invocations.len(), 1);
assert_eq!(plan.invocations[0].label.as_deref(), Some("alpha::loom"));
assert_eq!(plan.invocations[0].argv, ["cargo", "test", "-p", "alpha", "--test", "loom"]);
}
#[test]
fn per_target_matches_when_any_target_kind_is_requested() {
let mut a = member("alpha");
a.targets.push(MemberTarget {
name: "multi-kind".to_owned(),
kinds: [TargetKind::Bin, TargetKind::Example].into_iter().collect(),
required_features: BTreeSet::new(),
});
let kinds = [TargetKind::Example, TargetKind::Test].into_iter().collect();
let required = BTreeSet::new();
let plan = Plan::build(
&[&a],
&cmd(&["echo", "{target}"]),
BuildOptions {
mode: Mode::PerTarget,
chdir: false,
packages: PackagesExpansion::Explicit,
target_kinds: &kinds,
target_required_features: &required,
workspace_rust_version: None,
},
)
.expect("one matching kind is sufficient");
assert_eq!(
plan.invocations,
[Invocation {
label: Some("alpha::multi-kind".to_owned()),
argv: cmd(&["echo", "multi-kind"]),
work_dir: None,
}]
);
}
#[test]
fn explicit_package_flags_preserve_member_order() {
let a = member("alpha");
let b = member("beta");
assert_eq!(
packages_flags(&[&b, &a], PackagesExpansion::Explicit),
["--package", "beta@1.2.3", "--package", "alpha@1.2.3"]
);
}
#[test]
fn per_target_supports_chdir() {
let mut a = member("alpha");
a.targets.push(MemberTarget {
name: "demo".to_owned(),
kinds: std::iter::once(TargetKind::Example).collect(),
required_features: BTreeSet::new(),
});
let kinds = std::iter::once(TargetKind::Example).collect();
let plan = Plan::build(
&[&a],
&cmd(&["echo", "{target}"]),
BuildOptions {
mode: Mode::PerTarget,
chdir: true,
packages: PackagesExpansion::Explicit,
target_kinds: &kinds,
target_required_features: &BTreeSet::new(),
workspace_rust_version: None,
},
)
.expect("build target plan");
assert_eq!(plan.invocations[0].work_dir.as_deref(), Some(PathBuf::from("/ws/alpha").as_path()));
}
#[test]
fn workspace_rust_version_is_available_in_once_mode() {
let a = member("alpha");
let plan = Plan::build(
&[&a],
&cmd(&["rustup", "toolchain", "install", "{workspace-rust-version}"]),
BuildOptions {
mode: Mode::Once,
chdir: false,
packages: PackagesExpansion::Workspace,
target_kinds: &BTreeSet::new(),
target_required_features: &BTreeSet::new(),
workspace_rust_version: Some("1.80"),
},
)
.expect("build");
assert_eq!(plan.invocations[0].argv, ["rustup", "toolchain", "install", "1.80"]);
}
}