From 27d56a01b501d07f2bd9ce475d708457b95847ad Mon Sep 17 00:00:00 2001 From: Philipp Reiter Date: Sun, 23 Nov 2025 14:32:13 +0100 Subject: [PATCH] feat(workspace): reduce manifests and dependencies for target members For the `prepare` command, if a `--bin` member is specified: - Keep only the workspace manifests relevant to the target member - Filter the lock file to include only dependencies used by those manifests --- src/skeleton/mod.rs | 54 ++-------- src/skeleton/workspace.rs | 180 +++++++++++++++++++++++++++++++++ tests/skeletons.rs | 202 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 388 insertions(+), 48 deletions(-) create mode 100644 src/skeleton/workspace.rs diff --git a/src/skeleton/mod.rs b/src/skeleton/mod.rs index 13648b6..2dc654d 100644 --- a/src/skeleton/mod.rs +++ b/src/skeleton/mod.rs @@ -1,15 +1,15 @@ mod read; mod target; mod version_masking; +mod workspace; use crate::skeleton::target::{Target, TargetKind}; +use crate::skeleton::workspace::reduce_workspace_by_member; use crate::OptimisationProfile; use anyhow::Context; use cargo_manifest::Product; -use cargo_metadata::Metadata; use fs_err as fs; use globwalk::GlobWalkerBuilder; -use pathdiff::diff_paths; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -52,13 +52,13 @@ impl Skeleton { // Read relevant files from the filesystem let config_file = read::config(&base_path)?; let mut manifests = read::manifests(&base_path, &metadata)?; - if let Some(member) = member { - ignore_all_members_except(&mut manifests, &metadata, member); - } - let mut lock_file = read::lockfile(&base_path)?; let rust_toolchain_file = read::rust_toolchain(&base_path)?; + if let Some(member) = &member { + reduce_workspace_by_member(&metadata, &mut manifests, &mut lock_file, member)?; + } + version_masking::mask_local_crate_versions(&mut manifests, &mut lock_file); let lock_file = lock_file.map(|l| toml::to_string(&l)).transpose()?; @@ -318,45 +318,3 @@ fn extract_cargo_metadata(path: &Path) -> Result, + lock_file: &mut Option, + member: &str, +) -> Result<()> { + let workspace_members = manifests + .iter() + .filter_map(|m| extract_pkg_name(&m.contents)) + .collect(); + + let root_manifest = manifests + .iter_mut() + .find(|m| m.relative_path.to_str() == Some("Cargo.toml")) + .context("no root manifest found")?; + + let root_manifest_contents = root_manifest + .contents + .get("workspace") + .context("get workspace")?; + + let workspace_dependencies = match root_manifest_contents.get("dependencies") { + Some(v) => { + let table = v.as_table().context("dependencies must be a table")?; + table.iter().map(|(name, _)| name.to_string()).collect() + } + None => HashSet::new(), + }; + + let members_to_members_graph = build_dependency_graph(&manifests, &workspace_members); + let members_to_dependencies_graph = build_dependency_graph(&manifests, &workspace_dependencies); + + let relevant_members = compute_transitive_deps(member, &members_to_members_graph); + let relevant_dependencies = compute_transitive_deps(member, &members_to_dependencies_graph); + + // Remove all workspace members from the root manifest + ignore_all_members_except(manifests, &metadata, member); + + // Retain only the manifests of the relevant workspace member + manifests.retain(|manifest| { + extract_pkg_name(&manifest.contents).is_none_or(|name| relevant_members.contains(&name)) + }); + + // Filter lockfile to keep only relevant dependencies + if let Some(lockfile) = lock_file { + filter_lockfile(lockfile, &workspace_members, &relevant_members)?; + filter_lockfile(lockfile, &workspace_dependencies, &relevant_dependencies)?; + }; + + Ok(()) +} + +/// Builds a dependency graph from a list of parsed Cargo manifests. +/// +/// For each manifest, this function collects all dependencies +/// under `[dependencies]` and `[dev-dependencies]` that are +/// also present in the provided `target_deps` set. +fn build_dependency_graph( + manifests: &[ParsedManifest], + target_deps: &HashSet, +) -> HashMap> { + let mut graph = HashMap::new(); + + for manifest in manifests { + if let Some(pkg_name) = extract_pkg_name(&manifest.contents) { + let mut deps = HashSet::new(); + for key in ["dependencies", "dev-dependencies"] { + if let Some(table) = manifest.contents.get(key).and_then(|v| v.as_table()) { + for (dep_name, _) in table { + if target_deps.contains(dep_name.as_str()) { + deps.insert(dep_name.to_string()); + } + } + } + } + graph.insert(pkg_name.clone(), deps); + } + } + + graph +} + +/// Compute all transitive dependencies of the given target member. +fn compute_transitive_deps( + target: &str, + deps: &HashMap>, +) -> HashSet { + let mut keep = HashSet::new(); + let mut stack = vec![target.to_string()]; + + while let Some(member) = stack.pop() { + if keep.insert(member.clone()) { + if let Some(children) = deps.get(&member) { + stack.extend(children.iter().cloned()); + } + } + } + + keep +} + +/// Filter lockfile to keep only relevant dependencies +fn filter_lockfile( + lock_file: &mut cargo_manifest::Value, + all: &HashSet, + relevant: &HashSet, +) -> Result<()> { + let cargo_manifest::Value::Table(lock_table) = lock_file else { + return Ok(()); + }; + + let packages = match lock_table.get_mut("package").and_then(|v| v.as_array_mut()) { + Some(arr) => arr, + None => return Ok(()), + }; + + packages.retain(|pkg| { + pkg.as_table() + .and_then(|t| t.get("name").and_then(|v| v.as_str())) + .map_or(true, |name| !all.contains(name) || relevant.contains(name)) + }); + + Ok(()) +} + +/// Extract the crate name from contents +fn extract_pkg_name(contents: &Value) -> Option { + contents + .get("package")? + .get("name")? + .as_str() + .map(ToOwned::to_owned) +} + +/// If the top-level `Cargo.toml` has a `members` field, replace it with +/// a list consisting of just the path to the package. +/// +/// Also deletes the `default-members` field because it does not play nicely +/// with a modified `members` field and has no effect on cooking the final recipe. +fn ignore_all_members_except(manifests: &mut [ParsedManifest], metadata: &Metadata, member: &str) { + let workspace_toml = manifests + .iter_mut() + .find(|manifest| manifest.relative_path == std::path::PathBuf::from("Cargo.toml")); + + if let Some(workspace) = workspace_toml.and_then(|toml| toml.contents.get_mut("workspace")) { + if let Some(members) = workspace.get_mut("members") { + let workspace_root = &metadata.workspace_root; + let workspace_packages = metadata.workspace_packages(); + + if let Some(pkg) = workspace_packages + .into_iter() + .find(|pkg| pkg.name == member) + { + // Make this a relative path to the workspace, and remove the `Cargo.toml` child. + let member_cargo_path = diff_paths(pkg.manifest_path.as_os_str(), workspace_root); + let member_workspace_path = member_cargo_path + .as_ref() + .and_then(|path| path.parent()) + .and_then(|dir| dir.to_str()); + + if let Some(member_path) = member_workspace_path { + *members = + toml::Value::Array(vec![toml::Value::String(member_path.to_string())]); + } + } + } + if let Some(workspace) = workspace.as_table_mut() { + workspace.remove("default-members"); + } + } +} diff --git a/tests/skeletons.rs b/tests/skeletons.rs index 4ebc6f1..43eb1ab 100644 --- a/tests/skeletons.rs +++ b/tests/skeletons.rs @@ -1754,6 +1754,208 @@ uuid = { version = "=0.8.0", features = ["v4"] } // with multiple binaries is probably a good idea here! } +fn create_test_workspace() -> BuiltWorkspace { + CargoWorkspace::new() + .manifest( + ".", + r#" +[workspace] +members = [ + "src/bin_a", + "src/bin_b", + "src/lib_a", +] + +[workspace.dependencies] +either = "1" +"#, + ) + .bin_package( + "src/bin_a", + r#" +[package] +name = "bin_a" +version = "0.1.0" +edition = "2018" + +[[bin]] +name = "test-dummy-a" +path = "src/main.rs" + +[dependencies] +uuid = { version = "=0.8.0", features = ["v4"] } +"#, + ) + .bin_package( + "src/bin_b", + r#" +[package] +name = "bin_b" +version = "0.1.0" +edition = "2018" + +[[bin]] +name = "test-dummy-b" +path = "src/main.rs" + +[dependencies] +either = { workspace = true } +lib_a = { path = "../../src/lib_a" } +"#, + ) + .lib_package( + "src/lib_a", + r#" +[package] +name = "lib_a" +version = "0.1.0" +edition = "2018" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +uuid = { version = "=0.8.0", features = ["v4"] } +"#, + ) + .file( + "Cargo.lock", + r#" +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "bin_a" +version = "0.1.0" + +[[package]] +name = "bin_b" +version = "0.1.0" +dependencies = [ + "either", + "lib_a", +] + +[[package]] +name = "lib_a" +version = "0.1.0" +dependencies = [ + "either", + "uuid", +] + +[[package]] +name = "uuid" +version = "0.8.0" + +[[package]] +name = "either" +version = "1.0.0" +"#, + ) + .build() +} + +#[test] +pub fn reduce_workspace_to_single_bin_member() { + // Arrange + let project = create_test_workspace(); + + // bin_a depends on nothing, so reducing bin_a should only include root and bin_a + let member = String::from("bin_a"); + // Act + let skeleton = Skeleton::derive(project.path(), Some(member)).unwrap(); + let cook_directory = TempDir::new().unwrap(); + skeleton + .build_minimum_project(cook_directory.path(), false) + .unwrap(); + + // Assert manifest + // root manifest + bin_a + assert_eq!(2, skeleton.manifests.len()); + cook_directory + .child("src") + .child("bin_a") + .child("src") + .child("main.rs") + .assert("fn main() {}"); + + // Assert lockfile + let lock_file = skeleton.lock_file.expect("there should be a lock_file"); + assert_eq!( + lock_file, + r#"version = 3 + +[[package]] +name = "bin_a" +version = "0.0.1" + +[[package]] +name = "uuid" +version = "0.8.0" +"# + ); +} + +#[test] +pub fn reduce_workspace_target_member_with_dependencies() { + // Arrange + let project = create_test_workspace(); + + // bin_b depends on lib_a, so reducing bin_b should include root, bin_b and lib_a + let member = String::from("bin_b"); + + // Act + let skeleton = Skeleton::derive(project.path(), Some(member)).unwrap(); + let cook_directory = TempDir::new().unwrap(); + skeleton + .build_minimum_project(cook_directory.path(), false) + .unwrap(); + + // Assert + // root manifest + bin_b + lib_a + assert_eq!(3, skeleton.manifests.len()); + cook_directory + .child("src") + .child("bin_b") + .child("src") + .child("main.rs") + .assert("fn main() {}"); + cook_directory + .child("src") + .child("lib_a") + .child("src") + .child("lib.rs") + .assert(""); + + // Assert lockfile + let lock_file = skeleton.lock_file.expect("there should be a lock_file"); + assert_eq!( + lock_file, + r#"version = 3 + +[[package]] +name = "bin_b" +version = "0.0.1" +dependencies = ["either", "lib_a"] + +[[package]] +name = "lib_a" +version = "0.0.1" +dependencies = ["either", "uuid"] + +[[package]] +name = "uuid" +version = "0.8.0" + +[[package]] +name = "either" +version = "1.0.0" +"# + ); +} + struct BuiltWorkspace { directory: TempDir, }