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,
}