use std::collections::{
BTreeMap,
BTreeSet,
};
use std::path::PathBuf;
use anyhow::{
Context,
Result,
};
use async_fs_io::{
read_string_bounded,
write_bytes,
};
use cargo_plugin_utils::ProgressLogger;
use clap::Parser;
use toml_edit::{
DocumentMut,
Item,
};
#[derive(Parser, Debug)]
#[command(
name = "cargo",
about = "Automatically propagate workspace crate features to dependencies",
bin_name = "cargo-propagate-features",
version = env!("CARGO_PKG_VERSION"),
propagate_version = true
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Parser, Debug)]
enum Command {
#[command(name = "propagate-features")]
PropagateFeatures(PropagateArgs),
}
#[derive(Parser, Debug)]
struct PropagateArgs {
#[arg(long, conflicts_with = "dry_run")]
check: bool,
#[arg(long)]
dry_run: bool,
#[arg(long, default_value = "backend,cli,desktop,web")]
features: String,
#[arg(long)]
manifest_path: Option<PathBuf>,
#[arg(long)]
quiet: bool,
}
#[derive(Debug, Clone)]
struct CrateInfo {
name: String,
path: PathBuf,
features: BTreeSet<String>,
dependencies: BTreeMap<String, DependencyInfo>,
}
#[derive(Debug, Clone)]
struct DependencyInfo {
package: String,
optional: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::PropagateFeatures(args) => propagate_features(args).await,
}
}
async fn propagate_features(args: PropagateArgs) -> Result<()> {
let target_features: BTreeSet<String> = args
.features
.split(',')
.map(|s| s.trim().to_string())
.collect();
if target_features.iter().any(String::is_empty) {
anyhow::bail!("Feature list must contain nonempty names");
}
let mut logger = ProgressLogger::new(args.quiet);
let packages = cargo_plugin_utils::get_workspace_packages(args.manifest_path.as_deref())
.context("Failed to get cargo metadata. Make sure you're in a Cargo project or use --manifest-path.")?;
let mut crates: Vec<CrateInfo> = packages
.iter()
.filter_map(|pkg| {
let manifest_dir = pkg.manifest_path.as_std_path().parent()?;
let features: BTreeSet<String> = pkg.features.keys().cloned().collect();
let dependencies = pkg
.dependencies
.iter()
.map(|dependency| {
let alias = dependency
.rename
.as_deref()
.unwrap_or(dependency.name.as_str());
(
alias.to_owned(),
DependencyInfo {
package: dependency.name.as_str().to_owned(),
optional: dependency.optional,
},
)
})
.collect();
Some(CrateInfo {
name: pkg.name.as_str().to_string(),
path: manifest_dir.to_path_buf(),
features,
dependencies,
})
})
.collect();
let workspace_package_names: BTreeSet<String> = packages
.iter()
.map(|p| p.name.as_str().to_string())
.collect();
for crate_info in &mut crates {
crate_info
.dependencies
.retain(|_, dependency| workspace_package_names.contains(&dependency.package));
}
let crate_features: BTreeMap<String, BTreeSet<String>> = crates
.iter()
.map(|c| (c.name.clone(), c.features.clone()))
.collect();
let mut total_changes = 0;
logger.set_progress(crates.len() as u64);
logger.set_message("Checking");
for crate_info in &crates {
logger.inc();
let cargo_toml_path = crate_info.path.join("Cargo.toml");
let content = read_string_bounded(&cargo_toml_path, 16 * 1024 * 1024)
.await
.context(format!("Failed to read {:?}", cargo_toml_path))?;
let mut doc = content
.parse::<DocumentMut>()
.context(format!("Failed to parse {:?}", cargo_toml_path))?;
let mut changed = false;
let dependency_names = crate_info.dependencies.keys().cloned().collect();
changed |= remove_hardcoded_features(
&mut doc,
"dependencies",
&target_features,
&crate_info.name,
&dependency_names,
&mut logger,
)?;
changed |= remove_hardcoded_features(
&mut doc,
"dev-dependencies",
&target_features,
&crate_info.name,
&dependency_names,
&mut logger,
)?;
#[allow(clippy::excessive_nesting)]
if let Some(target_table) = doc.get_mut("target").and_then(|t| t.as_table_mut()) {
for (target_name, target_config) in target_table.iter_mut() {
let section_name = format!("target.{}.dependencies", target_name);
if let Some(deps) = target_config
.get_mut("dependencies")
.and_then(|d| d.as_table_mut())
{
for (dep_name, dep_value) in deps.iter_mut() {
let Some(dep_table) = dep_value.as_inline_table_mut() else {
continue;
};
let dep_name_str = dep_name.to_string();
if !dep_table.contains_key("workspace")
|| !dependency_names.contains(&dep_name_str)
|| !dep_table.contains_key("features")
{
continue;
}
let Some(features_array) =
dep_table.get_mut("features").and_then(|f| f.as_array_mut())
else {
continue;
};
let original_len = features_array.len();
features_array.retain(|v| {
v.as_str()
.map(|s| !target_features.contains(s))
.unwrap_or(true)
});
if features_array.len() < original_len {
logger.println(&format!(
" ⚠️ {}: Removed hardcoded runtime features from {} in {}",
crate_info.name, dep_name, section_name
));
changed = true;
if features_array.is_empty() {
dep_table.remove("features");
}
}
}
}
}
}
if changed {
total_changes += 1;
if args.dry_run || args.check {
logger.println(&format!(" [DRY RUN] Would update {:?}", cargo_toml_path));
} else {
let output = doc.to_string();
write_bytes(&cargo_toml_path, output.as_bytes())
.await
.context(format!("Failed to write {:?}", cargo_toml_path))?;
logger.println(&format!(" 💾 Updated {:?}", cargo_toml_path));
}
}
}
logger.finish();
logger.set_progress(crates.len() as u64);
logger.set_message("Propagating");
for crate_info in &crates {
logger.inc();
let has_target_features: Vec<_> = target_features
.iter()
.filter(|f| crate_info.features.contains(*f))
.collect();
if has_target_features.is_empty() {
continue;
}
let cargo_toml_path = crate_info.path.join("Cargo.toml");
let content = read_string_bounded(&cargo_toml_path, 16 * 1024 * 1024)
.await
.context(format!("Failed to read {:?}", cargo_toml_path))?;
let mut doc = content
.parse::<DocumentMut>()
.context(format!("Failed to parse {:?}", cargo_toml_path))?;
let mut changed = false;
#[allow(clippy::excessive_nesting)]
for feature_name in &has_target_features {
let deps_with_feature: Vec<_> = crate_info
.dependencies
.iter()
.filter(|(_, dependency)| {
crate_features
.get(&dependency.package)
.map(|f| f.contains(*feature_name))
.unwrap_or(false)
})
.collect();
if deps_with_feature.is_empty() {
continue;
}
if let Some(feature_item) = doc
.get_mut("features")
.and_then(|f| f.as_table_like_mut())
.and_then(|table| table.get_mut(feature_name.as_str()))
{
let feature_array = match feature_item {
Item::Value(toml_edit::Value::Array(arr)) => arr,
_ => continue,
};
let mut existing: BTreeSet<String> = feature_array
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
for (dep, dependency) in &deps_with_feature {
let dep_feature = if dependency.optional {
format!("{}?/{}", dep, feature_name)
} else {
format!("{}/{}", dep, feature_name)
};
if !existing.contains(&dep_feature) {
logger.status(
"Adding",
&format!("{}/{} to {}", dep, feature_name, feature_name),
);
feature_array.push(dep_feature.clone());
existing.insert(dep_feature);
changed = true;
total_changes += 1;
}
}
}
}
if changed {
if args.dry_run || args.check {
logger.println(&format!(" [DRY RUN] Would update {:?}", cargo_toml_path));
} else {
let output = doc.to_string();
write_bytes(&cargo_toml_path, output.as_bytes())
.await
.context(format!("Failed to write {:?}", cargo_toml_path))?;
logger.println(&format!(" 💾 Updated {:?}", cargo_toml_path));
}
}
}
logger.finish();
if !args.quiet {
if total_changes > 0 {
logger.println("✨ Complete!");
if args.dry_run || args.check {
logger.println(&format!(" Would make {} changes", total_changes));
logger.println(" Run without --check or --dry-run to apply changes");
} else {
logger.println(&format!(" Made {} changes", total_changes));
}
} else {
logger.println("✨ No changes needed");
}
}
if args.check && total_changes > 0 {
anyhow::bail!("Feature propagation changes are required");
}
Ok(())
}
fn remove_hardcoded_features(
doc: &mut DocumentMut,
section: &str,
target_features: &BTreeSet<String>,
crate_name: &str,
workspace_package_names: &BTreeSet<String>,
logger: &mut ProgressLogger,
) -> Result<bool> {
let mut changed = false;
let Some(deps) = doc.get_mut(section).and_then(|d| d.as_table_mut()) else {
return Ok(false);
};
for (dep_name, dep_value) in deps.iter_mut() {
let Some(dep_table) = dep_value.as_inline_table_mut() else {
continue;
};
let dep_name_str = dep_name.to_string();
if !dep_table.contains_key("workspace")
|| !workspace_package_names.contains(&dep_name_str)
|| !dep_table.contains_key("features")
{
continue;
}
let Some(features_array) = dep_table.get_mut("features").and_then(|f| f.as_array_mut())
else {
continue;
};
let original_len = features_array.len();
features_array.retain(|v| {
v.as_str()
.map(|s| !target_features.contains(s))
.unwrap_or(true)
});
if features_array.len() < original_len {
logger.println(&format!(
" ⚠️ {}: Removed hardcoded runtime features from {} in [{}]",
crate_name, dep_name, section
));
changed = true;
if features_array.is_empty() {
dep_table.remove("features");
}
}
}
Ok(changed)
}