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::{self, WrapErr};
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>>;
}
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()).wrap_err_with(|| {
format!(
"invalid [{}] configuration in workspace metadata",
ws_metadata_section(key)
)
})?
}
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 workspace_config = self.workspace_config()?;
let mut exclude =
apply_string_patch_to_empty(workspace_config.base.settings.exclude_packages.as_ref());
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())
&& let Some(patch) = config.base.settings.exclude_packages.as_ref()
{
exclude.extend(apply_string_patch_to_empty(Some(patch)));
}
Ok(exclude)
}
}
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
.base
.settings
.exclude_packages
.as_ref()
.is_some_and(string_patch_has_values)
{
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
.base
.settings
.exclude_packages
.as_ref()
.is_some_and(string_patch_has_values)
{
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 apply_string_patch_to_empty(
patch: Option<&crate::config::patch::StringSetPatch>,
) -> HashSet<String> {
let mut out = HashSet::new();
if let Some(patch) = patch {
if let Some(values) = patch.override_value() {
out.extend(values.iter().cloned());
}
for value in patch.remove_values() {
out.remove(value);
}
out.extend(patch.add_values().iter().cloned());
}
out
}
fn string_patch_has_values(patch: &crate::config::patch::StringSetPatch) -> bool {
patch
.override_value()
.is_some_and(|values| !values.is_empty())
|| !patch.add_values().is_empty()
|| !patch.remove_values().is_empty()
}
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 = packages_after_base_exclude(&metadata)?;
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 = packages_after_base_exclude(&metadata)?;
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 = packages_after_base_exclude(&metadata)?;
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 = packages_after_base_exclude(&metadata)?;
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 = packages_after_base_exclude(&metadata)?;
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.base.settings.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("")
}
fn packages_after_base_exclude(
metadata: &cargo_metadata::Metadata,
) -> eyre::Result<Vec<&cargo_metadata::Package>> {
let mut packages = metadata.candidate_packages_for_fc()?;
let exclude = metadata.base_workspace_exclude_packages()?;
packages.retain(|package| !exclude.contains(package.name.as_str()));
Ok(packages)
}
}