Skip to content

Commit eaf2e72

Browse files
feat: Minimize recipe to increase cache hit ratio
1 parent 5823ce0 commit eaf2e72

3 files changed

Lines changed: 334 additions & 24 deletions

File tree

src/skeleton/mod.rs

Lines changed: 160 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ use anyhow::Context;
88
use cargo_manifest::Product;
99
use fs_err as fs;
1010
use globwalk::GlobWalkerBuilder;
11-
use guppy::graph::PackageGraph;
11+
use guppy::graph::{DependencyDirection, PackageGraph};
1212
use pathdiff::diff_paths;
1313
use serde::{Deserialize, Serialize};
14+
use std::collections::HashSet;
1415
use std::path::{Path, PathBuf};
1516

1617
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
@@ -52,11 +53,10 @@ impl Skeleton {
5253
// Read relevant files from the filesystem
5354
let config_file = read::config(&base_path)?;
5455
let mut manifests = read::manifests(&base_path, &graph)?;
55-
if let Some(member) = member {
56-
ignore_all_members_except(&mut manifests, &graph, member);
57-
}
58-
5956
let mut lock_file = read::lockfile(&base_path)?;
57+
if let Some(member) = &member {
58+
filter_to_member_closure(&mut manifests, &mut lock_file, &graph, member)?;
59+
}
6060
let rust_toolchain_file = read::rust_toolchain(&base_path)?;
6161

6262
version_masking::mask_local_crate_versions(&mut manifests, &mut lock_file);
@@ -317,15 +317,95 @@ fn extract_package_graph(path: &Path) -> Result<PackageGraph, anyhow::Error> {
317317
cmd.build_graph().context("Cannot extract package graph")
318318
}
319319

320-
/// If the top-level `Cargo.toml` has a `members` field, replace it with
321-
/// a list consisting of just the path to the package.
320+
/// Filter the skeleton down to only the workspace members (and their transitive
321+
/// dependencies) needed to build `member`.
322322
///
323-
/// Also deletes the `default-members` field because it does not play nicely
324-
/// with a modified `members` field and has no effect on cooking the final recipe.
325-
fn ignore_all_members_except(
323+
/// Uses guppy's `query_forward` + `resolve` to compute the full transitive
324+
/// dependency closure, then:
325+
/// 1. Removes manifests for workspace members outside the closure
326+
/// 2. Updates `[workspace.members]` to list only closure members
327+
/// 3. Removes `[workspace.default-members]`
328+
/// 4. Filters the lockfile to only packages in the closure
329+
fn filter_to_member_closure(
330+
manifests: &mut Vec<ParsedManifest>,
331+
lock_file: &mut Option<toml::Value>,
332+
graph: &PackageGraph,
333+
member: &str,
334+
) -> Result<(), anyhow::Error> {
335+
let ws = graph.workspace();
336+
// `member` may be a package name or a binary target name (from --bin).
337+
// Try package name first, then search for a binary target.
338+
let pkg = match ws.member_by_name(member) {
339+
Ok(pkg) => pkg,
340+
Err(_) => ws
341+
.iter()
342+
.find(|pkg| {
343+
pkg.build_targets().any(|t| {
344+
matches!(t.id(), guppy::graph::BuildTargetId::Binary(name) if name == member)
345+
})
346+
})
347+
.ok_or_else(|| {
348+
anyhow::anyhow!("No workspace package or binary target named '{member}'")
349+
})?,
350+
};
351+
352+
// Get transitive closure of all dependencies
353+
let resolved = graph.query_forward(std::iter::once(pkg.id()))?.resolve();
354+
355+
// Collect workspace member names in the closure
356+
let closure_members: HashSet<String> = ws
357+
.iter()
358+
.filter(|ws_pkg| resolved.contains(ws_pkg.id()).unwrap_or(false))
359+
.map(|ws_pkg| ws_pkg.name().to_string())
360+
.collect();
361+
362+
// 1. Filter manifests: keep root workspace manifest + closure members
363+
manifests.retain(|m| {
364+
extract_pkg_name(&m.contents).is_none_or(|name| closure_members.contains(&name))
365+
});
366+
367+
// 2. Collect workspace dep keys referenced by closure members
368+
let referenced_workspace_deps = collect_workspace_dep_keys(manifests);
369+
370+
// 3. Update [workspace.members], remove default-members, filter [workspace.dependencies]
371+
update_workspace_members(
372+
manifests,
373+
graph,
374+
&closure_members,
375+
&referenced_workspace_deps,
376+
);
377+
378+
// 4. Collect ALL (name, version) pairs in the closure (workspace + external)
379+
let closure_packages: HashSet<(String, String)> = resolved
380+
.packages(DependencyDirection::Forward)
381+
.map(|pkg| (pkg.name().to_string(), pkg.version().to_string()))
382+
.collect();
383+
384+
// 5. Filter lockfile: keep only packages in the closure
385+
if let Some(lockfile) = lock_file {
386+
filter_lockfile_packages(lockfile, &closure_packages);
387+
}
388+
389+
Ok(())
390+
}
391+
392+
/// Extract the `[package].name` from a parsed TOML manifest, if present.
393+
fn extract_pkg_name(contents: &toml::Value) -> Option<String> {
394+
contents
395+
.get("package")?
396+
.get("name")?
397+
.as_str()
398+
.map(|s| s.to_string())
399+
}
400+
401+
/// Replace `[workspace.members]` with relative paths for all closure members,
402+
/// remove `[workspace.default-members]`, and filter `[workspace.dependencies]`
403+
/// to only those referenced by closure members.
404+
fn update_workspace_members(
326405
manifests: &mut [ParsedManifest],
327406
graph: &PackageGraph,
328-
member: String,
407+
closure_members: &HashSet<String>,
408+
referenced_workspace_deps: &HashSet<String>,
329409
) {
330410
let workspace_toml = manifests
331411
.iter_mut()
@@ -336,22 +416,78 @@ fn ignore_all_members_except(
336416
let ws = graph.workspace();
337417
let workspace_root = ws.root();
338418

339-
if let Ok(pkg) = ws.member_by_name(&member) {
340-
// Make this a relative path to the workspace, and remove the `Cargo.toml` child.
341-
let member_cargo_path = diff_paths(pkg.manifest_path(), workspace_root);
342-
let member_workspace_path = member_cargo_path
343-
.as_ref()
344-
.and_then(|path| path.parent())
345-
.and_then(|dir| dir.to_str());
346-
347-
if let Some(member_path) = member_workspace_path {
348-
*members =
349-
toml::Value::Array(vec![toml::Value::String(member_path.to_string())]);
350-
}
351-
}
419+
let member_paths: Vec<toml::Value> = ws
420+
.iter()
421+
.filter(|pkg| closure_members.contains(pkg.name()))
422+
.filter_map(|pkg| {
423+
let cargo_path = diff_paths(pkg.manifest_path(), workspace_root)?;
424+
let dir = cargo_path.parent()?;
425+
Some(toml::Value::String(dir.to_str()?.to_string()))
426+
})
427+
.collect();
428+
429+
*members = toml::Value::Array(member_paths);
352430
}
353431
if let Some(workspace) = workspace.as_table_mut() {
354432
workspace.remove("default-members");
433+
if let Some(deps) = workspace
434+
.get_mut("dependencies")
435+
.and_then(|d| d.as_table_mut())
436+
{
437+
deps.retain(|key, _| referenced_workspace_deps.contains(key));
438+
}
439+
}
440+
}
441+
}
442+
443+
/// Collect all `[workspace.dependencies]` keys referenced (via `workspace = true`)
444+
/// by the given manifests.
445+
fn collect_workspace_dep_keys(manifests: &[ParsedManifest]) -> HashSet<String> {
446+
let mut keys = HashSet::new();
447+
for manifest in manifests {
448+
if extract_pkg_name(&manifest.contents).is_none() {
449+
continue; // skip root workspace manifest
450+
}
451+
collect_workspace_keys_from(&manifest.contents, &mut keys);
452+
if let Some(targets) = manifest.contents.get("target").and_then(|t| t.as_table()) {
453+
for (_, target_config) in targets {
454+
collect_workspace_keys_from(target_config, &mut keys);
455+
}
355456
}
356457
}
458+
keys
459+
}
460+
461+
fn collect_workspace_keys_from(value: &toml::Value, keys: &mut HashSet<String>) {
462+
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
463+
if let Some(deps) = value.get(section).and_then(|d| d.as_table()) {
464+
for (key, dep) in deps {
465+
if dep
466+
.get("workspace")
467+
.and_then(|w| w.as_bool())
468+
.unwrap_or(false)
469+
{
470+
keys.insert(key.clone());
471+
}
472+
}
473+
}
474+
}
475+
}
476+
477+
/// Retain only lockfile `[[package]]` entries whose `(name, version)` pair
478+
/// is in the resolved closure.
479+
fn filter_lockfile_packages(
480+
lockfile: &mut toml::Value,
481+
closure_packages: &HashSet<(String, String)>,
482+
) {
483+
if let Some(packages) = lockfile.get_mut("package").and_then(|p| p.as_array_mut()) {
484+
packages.retain(|pkg| {
485+
let name = pkg.get("name").and_then(|n| n.as_str());
486+
let version = pkg.get("version").and_then(|v| v.as_str());
487+
match (name, version) {
488+
(Some(n), Some(v)) => closure_packages.contains(&(n.to_string(), v.to_string())),
489+
_ => true,
490+
}
491+
});
492+
}
357493
}

tests/caching/tests.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,25 @@ fn multi_member_scenario() -> Scenario {
390390
.member(Member::lib("b").external_dep("itoa@1"))
391391
}
392392

393+
#[test]
394+
fn recipe_is_unchanged_when_workspace_dep_added_for_unrelated_member_with_selected_bin() {
395+
let scenario = Scenario::workspace()
396+
.workspace_external_dep("itoa@1")
397+
.member(Member::lib("app").workspace_dep("itoa").with_bin("app_cli"))
398+
.member(Member::lib("other"));
399+
400+
scenario.run_with_options(
401+
Modification::AddWorkspaceExternalDep {
402+
dep: ExternalDepSpec {
403+
name: "ryu",
404+
version: "1",
405+
},
406+
},
407+
Expectation::RecipeUnchanged,
408+
RunOptions::for_bin("app_cli"),
409+
);
410+
}
411+
393412
fn selected_bin_transitive_scenario() -> Scenario {
394413
Scenario::workspace()
395414
.member(

0 commit comments

Comments
 (0)