use crate::config::{Config, WorkspaceConfig, validate_workspace_metadata};
use crate::print_warning;
use crate::{
DEFAULT_METADATA_KEY, METADATA_KEYS, find_metadata_value, pkg_metadata_section,
ws_metadata_section,
};
use color_eyre::eyre;
use std::collections::HashSet;
const WORKSPACE_ONLY_KEYS: &[&str] = &[
"exclude_packages",
"targets",
"target",
"subcommands",
"driver",
];
pub trait Workspace {
fn workspace_config(&self) -> eyre::Result<WorkspaceConfig>;
fn candidate_packages_for_fc(&self) -> eyre::Result<Vec<&cargo_metadata::Package>>;
fn base_workspace_exclude_packages(&self) -> eyre::Result<HashSet<String>>;
fn packages_for_fc(&self) -> eyre::Result<Vec<&cargo_metadata::Package>>;
}
impl Workspace for cargo_metadata::Metadata {
fn workspace_config(&self) -> eyre::Result<WorkspaceConfig> {
let config: WorkspaceConfig = match find_metadata_value(&self.workspace_metadata) {
Some((value, key)) => {
validate_workspace_metadata(value, &ws_metadata_section(key))?;
serde_json::from_value(value.clone())?
}
None => WorkspaceConfig::default(),
};
Ok(config)
}
fn candidate_packages_for_fc(&self) -> eyre::Result<Vec<&cargo_metadata::Package>> {
warn_workspace_metadata_misuse(self);
Ok(self.workspace_packages())
}
fn base_workspace_exclude_packages(&self) -> eyre::Result<HashSet<String>> {
let mut exclude = self.workspace_config()?.exclude_packages;
if let Some(root_package) = self.root_package()
&& let Some((value, _key)) = find_metadata_value(&root_package.metadata)
&& let Ok(config) = serde_json::from_value::<Config>(value.clone())
{
exclude.extend(config.exclude_packages);
}
Ok(exclude)
}
fn packages_for_fc(&self) -> eyre::Result<Vec<&cargo_metadata::Package>> {
let mut packages = self.candidate_packages_for_fc()?;
let exclude = self.base_workspace_exclude_packages()?;
packages.retain(|p| !exclude.contains(p.name.as_str()));
Ok(packages)
}
}
fn warn_workspace_metadata_misuse(metadata: &cargo_metadata::Metadata) {
let Some(root_package) = metadata.root_package() else {
return;
};
let root_key =
find_metadata_value(&root_package.metadata).map_or(DEFAULT_METADATA_KEY, |(_, key)| key);
if let Some((value, _key)) = find_metadata_value(&root_package.metadata)
&& let Ok(config) = serde_json::from_value::<Config>(value.clone())
&& !config.exclude_packages.is_empty()
{
print_warning!(
"[{}].exclude_packages in the workspace root package is deprecated; use [{}].exclude_packages instead",
pkg_metadata_section(root_key),
ws_metadata_section(root_key),
);
}
let root_id = &root_package.id;
for package in &metadata.packages {
if &package.id == root_id {
continue;
}
if let Some((raw, key)) = find_metadata_value(&package.metadata)
&& let Ok(config) = serde_json::from_value::<Config>(raw.clone())
&& !config.exclude_packages.is_empty()
{
print_warning!(
"[{}].exclude_packages in package `{}` has no effect; this field is only read from the workspace root Cargo.toml",
pkg_metadata_section(key),
package.name,
);
}
if let Some(workspace) = package.metadata.get("workspace")
&& let Some((key, tool)) = METADATA_KEYS
.iter()
.find_map(|&key| workspace.get(key).map(|tool| (key, tool)))
{
for ws_key in WORKSPACE_ONLY_KEYS
.iter()
.chain(crate::config::FLAG_KEYS)
.copied()
{
if json_has_values(tool.get(ws_key)) {
print_warning!(
"[{}].{} in package `{}` has no effect; workspace metadata is only read from the workspace root Cargo.toml",
ws_metadata_section(key),
ws_key,
package.name,
);
}
}
}
}
}
fn json_has_values(value: Option<&serde_json::Value>) -> bool {
match value {
Some(serde_json::Value::Array(values)) => !values.is_empty(),
Some(serde_json::Value::Object(map)) => !map.is_empty(),
Some(serde_json::Value::Bool(value)) => *value,
Some(serde_json::Value::Null) | None => false,
Some(_) => true,
}
}
#[cfg(test)]
mod test {
use super::{Workspace, json_has_values};
use color_eyre::eyre;
use serde_json::json;
static INIT: std::sync::Once = std::sync::Once::new();
fn init() {
INIT.call_once(|| {
color_eyre::install().ok();
});
}
#[test]
fn workspace_with_package() -> eyre::Result<()> {
init();
let package = crate::package::test::package_with_features(&[])?;
let metadata = workspace_builder()
.packages(vec![package.clone()])
.workspace_members(vec![package.id.clone()])
.build()?;
let have = metadata.packages_for_fc()?;
similar_asserts::assert_eq!(have: have, want: vec![&package]);
Ok(())
}
#[test]
fn workspace_with_excluded_package() -> eyre::Result<()> {
init();
let package = crate::package::test::package_with_features(&[])?;
let metadata = workspace_builder()
.packages(vec![package.clone()])
.workspace_members(vec![package.id.clone()])
.workspace_metadata(json!({
"cargo-feature-combinations": {
"exclude_packages": [package.name]
}
}))
.build()?;
let have = metadata.packages_for_fc()?;
assert!(have.is_empty(), "expected no packages after exclusion");
Ok(())
}
#[test]
fn workspace_with_excluded_package_cargo_fc_alias() -> eyre::Result<()> {
init();
let package = crate::package::test::package_with_features(&[])?;
let metadata = workspace_builder()
.packages(vec![package.clone()])
.workspace_members(vec![package.id.clone()])
.workspace_metadata(json!({
"cargo-fc": {
"exclude_packages": [package.name]
}
}))
.build()?;
let have = metadata.packages_for_fc()?;
assert!(
have.is_empty(),
"expected no packages after exclusion via cargo-fc alias"
);
Ok(())
}
#[test]
fn workspace_with_excluded_package_fc_alias() -> eyre::Result<()> {
init();
let package = crate::package::test::package_with_features(&[])?;
let metadata = workspace_builder()
.packages(vec![package.clone()])
.workspace_members(vec![package.id.clone()])
.workspace_metadata(json!({
"fc": {
"exclude_packages": [package.name]
}
}))
.build()?;
let have = metadata.packages_for_fc()?;
assert!(
have.is_empty(),
"expected no packages after exclusion via fc alias"
);
Ok(())
}
#[test]
fn workspace_with_excluded_package_feature_combinations_alias() -> eyre::Result<()> {
init();
let package = crate::package::test::package_with_features(&[])?;
let metadata = workspace_builder()
.packages(vec![package.clone()])
.workspace_members(vec![package.id.clone()])
.workspace_metadata(json!({
"feature-combinations": {
"exclude_packages": [package.name]
}
}))
.build()?;
let have = metadata.packages_for_fc()?;
assert!(
have.is_empty(),
"expected no packages after exclusion via feature-combinations alias"
);
Ok(())
}
#[test]
fn workspace_config_reads_install_missing_targets() -> eyre::Result<()> {
init();
let package = crate::package::test::package_with_features(&[])?;
let metadata = workspace_builder()
.packages(vec![package.clone()])
.workspace_members(vec![package.id.clone()])
.workspace_metadata(json!({
"cargo-fc": {
"install_missing_targets": true
}
}))
.build()?;
let config = metadata.workspace_config()?;
assert_eq!(config.flags.install_missing_targets, Some(true));
Ok(())
}
#[test]
fn workspace_metadata_rejects_unknown_flag_keys() -> eyre::Result<()> {
init();
let package = crate::package::test::package_with_features(&[])?;
let metadata = workspace_builder()
.packages(vec![package.clone()])
.workspace_members(vec![package.id.clone()])
.workspace_metadata(json!({
"cargo-fc": {
"fail-fast": true
}
}))
.build()?;
let err = metadata
.workspace_config()
.expect_err("hyphenated cargo-fc flag should be rejected");
assert!(err.to_string().contains("unknown cargo-fc config key"));
assert!(err.to_string().contains("use `_`, not `-`"));
Ok(())
}
#[test]
fn json_has_values_treats_false_as_default_empty_value() {
assert!(!json_has_values(Some(&json!(false))));
assert!(json_has_values(Some(&json!(true))));
}
fn workspace_builder() -> cargo_metadata::MetadataBuilder {
use cargo_metadata::{MetadataBuilder, WorkspaceDefaultMembers};
MetadataBuilder::default()
.version(1u8)
.workspace_default_members(WorkspaceDefaultMembers::default())
.resolve(None)
.workspace_root("")
.workspace_metadata(json!({}))
.build_directory(None)
.target_directory("")
}
}