Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 7 additions & 44 deletions src/skeleton/mod.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
mod read;
mod target;
mod version_masking;
mod workspace;

use crate::skeleton::target::{Target, TargetKind};
use crate::skeleton::workspace::filter_workspace_for_target;
use crate::OptimisationProfile;
use anyhow::Context;
use cargo_manifest::Product;
use fs_err as fs;
use globwalk::GlobWalkerBuilder;
use guppy::graph::PackageGraph;
use pathdiff::diff_paths;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

Expand Down Expand Up @@ -45,20 +46,21 @@ impl Skeleton {
/// Find all Cargo.toml files in `base_path` by traversing sub-directories recursively.
pub fn derive<P: AsRef<Path>>(
base_path: P,
member: Option<String>,
target: Option<String>,
) -> Result<Self, anyhow::Error> {
let graph = extract_package_graph(base_path.as_ref())?;

// Read relevant files from the filesystem
let config_file = read::config(&base_path)?;
let mut manifests = read::manifests(&base_path, &graph)?;
if let Some(member) = member {
ignore_all_members_except(&mut manifests, &graph, member);
}

let mut lock_file = read::lockfile(&base_path)?;
let rust_toolchain_file = read::rust_toolchain(&base_path)?;

if let Some(target) = &target {
filter_workspace_for_target(&graph, &mut manifests, &mut lock_file, target)?;
}

version_masking::mask_local_crate_versions(&mut manifests, &mut lock_file);

let lock_file = lock_file.map(|l| toml::to_string(&l)).transpose()?;
Expand Down Expand Up @@ -316,42 +318,3 @@ fn extract_package_graph(path: &Path) -> Result<PackageGraph, anyhow::Error> {
cmd.current_dir(path);
cmd.build_graph().context("Cannot extract package graph")
}

/// 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],
graph: &PackageGraph,
member: String,
) {
let workspace_toml = manifests
.iter_mut()
.find(|manifest| manifest.relative_path == std::path::Path::new("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 ws = graph.workspace();
let workspace_root = ws.root();

if let Ok(pkg) = ws.member_by_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(), 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");
}
}
}
215 changes: 215 additions & 0 deletions src/skeleton/workspace.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
//! Workspace filtering for `--bin` builds.
//!
//! Filters unrequired workspace members and their dependencies from manifests and lockfile.

use std::collections::HashSet;

use anyhow::Result;
use guppy::graph::{BuildTargetId, DependencyDirection, PackageGraph};
use toml::Value;

use crate::skeleton::ParsedManifest;

pub(super) fn filter_workspace_for_target(
graph: &PackageGraph,
manifests: &mut Vec<ParsedManifest>,
lock_file: &mut Option<Value>,
target_name: &str,
) -> Result<()> {
let workspace = graph.workspace();

// Find the target package by name or binary target
let target_pkg = match workspace.member_by_name(target_name) {
Ok(pkg) => pkg,
Err(_) => workspace
.iter()
.find(|pkg| {
pkg.build_targets()
.any(|t| matches!(t.id(), BuildTargetId::Binary(name) if name == target_name))
})
.ok_or_else(|| {
anyhow::anyhow!(
"No workspace package or binary target named '{}'",
target_name
)
})?,
};

// Get transitive dependencies of target package
let resolved = graph
.query_forward(std::iter::once(target_pkg.id()))?
.resolve();

// Collect workspace members required by the target (including transitive dependencies)
let required_members: HashSet<String> = workspace
.iter()
.filter(|ws_pkg| resolved.contains(ws_pkg.id()).unwrap_or(false))
.map(|ws_pkg| ws_pkg.name().to_string())
.collect();

// 1. Filter manifests: keep root workspace manifest + required members
manifests.retain(|m| {
extract_package_name(&m.contents).is_none_or(|name| required_members.contains(&name))
});

// 2. Update [workspace.members] in root manifest and remove default-members
filter_root_manifest(manifests, graph, &required_members)?;

// 3. Collect ALL (name, version) required member pairs (workspace + external)
let closure_packages: HashSet<(String, String)> = resolved
.packages(DependencyDirection::Forward)
.map(|pkg| (pkg.name().to_string(), pkg.version().to_string()))
.collect();

// 4. Filter lockfile: keep only required packages
if let Some(lockfile) = lock_file {
filter_lockfile(lockfile, &closure_packages)?;
}

Ok(())
}

/// Filters `[workspace] members` to only include required packages.
/// Also removes `default-members` if present.
fn filter_root_manifest(
manifests: &mut [ParsedManifest],
graph: &PackageGraph,
required_members: &HashSet<String>,
) -> Result<()> {
let workspace_toml = manifests
.iter_mut()
.find(|m| m.relative_path == std::path::PathBuf::from("Cargo.toml"));

let Some(workspace) = workspace_toml.and_then(|toml| toml.contents.get_mut("workspace")) else {
return Ok(());
};

if let Some(members) = workspace.get_mut("members") {
let workspace_root = graph.workspace().root();
let member_paths: Vec<toml::Value> = graph
.workspace()
.iter()
.filter(|pkg| required_members.contains(pkg.name()))
.filter_map(|pkg| {
let manifest_path = pkg.manifest_path();
manifest_path
.parent()
.and_then(|p| pathdiff::diff_paths(p, workspace_root))
.and_then(|d| d.to_str().map(|s| toml::Value::String(s.to_string())))
})
.collect();

if !member_paths.is_empty() {
*members = toml::Value::Array(member_paths);
}
}

if let Some(workspace) = workspace.as_table_mut() {
workspace.remove("default-members");
}

Ok(())
}

/// Filters the lockfile to keep only required packages.
/// Matches packages by (name, version) pairs.
fn filter_lockfile(
lock_file: &mut Value,
required_packages: &HashSet<(String, String)>,
) -> 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(|package| {
let Some(pkg_table) = package.as_table() else {
return true;
};

let name = pkg_table.get("name").and_then(|v| v.as_str()).unwrap_or("");
let version = pkg_table
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("");

required_packages.contains(&(name.to_string(), version.to_string()))
});

Ok(())
}

fn extract_package_name(contents: &Value) -> Option<String> {
contents
.get("package")?
.get("name")?
.as_str()
.map(ToOwned::to_owned)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_filter_lockfile() {
let lockfile: toml::Value = toml::from_str(
r#"
[[package]]
name = "app"
version = "0.1.0"

[[package]]
name = "lib"
version = "0.1.0"

[[package]]
name = "serde"
version = "1.0.0"
"#,
)
.unwrap();

let mut lockfile = Some(lockfile);
let required_members = HashSet::from([
("app".to_string(), "0.1.0".to_string()),
("serde".to_string(), "1.0.0".to_string()),
]);

filter_lockfile(lockfile.as_mut().unwrap(), &required_members).unwrap();

let packages = lockfile
.as_ref()
.unwrap()
.get("package")
.unwrap()
.as_array()
.unwrap()
.clone();
let names: Vec<_> = packages
.iter()
.filter_map(|p| p.get("name")?.as_str())
.collect();

// "lib" was filtered out, "app" and "serde" remain
assert_eq!(names, vec!["app", "serde"]);
}

#[test]
fn test_extract_package_name() {
let toml: Value = toml::from_str(
r#"
[package]
name = "my-crate"
version = "0.1.0"
"#,
)
.unwrap();

assert_eq!(extract_package_name(&toml), Some("my-crate".to_string()));
}
}
29 changes: 29 additions & 0 deletions tests/caching/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,3 +401,32 @@ fn selected_bin_transitive_scenario() -> Scenario {
.member(Member::lib("mid").local_dep("leaf"))
.member(Member::lib("leaf").external_dep("ryu@1"))
}

#[test]
fn recipe_unchanged_when_unrelated_member_dep_added_for_selected_bin() {
// app depends on shared (both use itoa)
// other uses ryu and is not needed by app
// Adding a dep to other should not affect app's recipe
let scenario = Scenario::workspace()
.member(
Member::lib("app")
.local_dep("shared")
.external_dep("itoa@1")
.with_bin("app_cli"),
)
.member(Member::lib("shared").external_dep("itoa@1"))
.member(Member::lib("other").external_dep("ryu@1"));

scenario.run_with_options(
Modification::AddExternalDep {
member: "other",
dep: ExternalDepSpec {
name: "itoa",
version: "1",
},
section: DependencySection::Dependencies,
},
Expectation::RecipeUnchanged,
RunOptions::for_bin("app_cli"),
);
}
Loading