Skip to main content

chef/skeleton/
mod.rs

1mod read;
2mod target;
3mod version_masking;
4
5use crate::skeleton::target::{Target, TargetKind};
6use crate::OptimisationProfile;
7use anyhow::Context;
8use cargo_manifest::Product;
9use fs_err as fs;
10use globwalk::GlobWalkerBuilder;
11use guppy::graph::{DependencyDirection, PackageGraph};
12use pathdiff::diff_paths;
13use serde::{Deserialize, Serialize};
14use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16
17#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
18pub struct Skeleton {
19    pub manifests: Vec<Manifest>,
20    pub config_file: Option<String>,
21    pub lock_file: Option<String>,
22    pub rust_toolchain_file: Option<(RustToolchainFile, String)>,
23}
24
25#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
26pub enum RustToolchainFile {
27    Bare,
28    Toml,
29}
30
31#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
32pub struct Manifest {
33    /// Relative path with respect to the project root.
34    pub relative_path: PathBuf,
35    pub contents: String,
36    pub targets: Vec<Target>,
37}
38
39pub(in crate::skeleton) struct ParsedManifest {
40    relative_path: PathBuf,
41    contents: toml::Value,
42    targets: Vec<Target>,
43}
44
45impl Skeleton {
46    /// Find all Cargo.toml files in `base_path` by traversing sub-directories recursively.
47    pub fn derive<P: AsRef<Path>>(
48        base_path: P,
49        member: Option<String>,
50    ) -> Result<Self, anyhow::Error> {
51        // Use `--no-deps` to skip dependency resolution when we don't need the full graph
52        // (i.e. no `--bin` filtering). We still need full resolution when there's no existing
53        // Cargo.lock, since `cargo metadata` generates it as a side effect.
54        let no_deps = member.is_none() && base_path.as_ref().join("Cargo.lock").exists();
55        let graph = extract_package_graph(base_path.as_ref(), no_deps)?;
56
57        // Read relevant files from the filesystem
58        let config_file = read::config(&base_path)?;
59        let mut manifests = read::manifests(&base_path, &graph)?;
60        let mut lock_file = read::lockfile(&base_path)?;
61        if let Some(member) = &member {
62            filter_to_member_closure(&mut manifests, &mut lock_file, &graph, member)?;
63        }
64        let rust_toolchain_file = read::rust_toolchain(&base_path)?;
65
66        version_masking::mask_local_crate_versions(&mut manifests, &mut lock_file);
67
68        let lock_file = lock_file.map(|l| toml::to_string(&l)).transpose()?;
69
70        let mut serialised_manifests = serialize_manifests(manifests)?;
71        // We don't want an ordering issue (e.g. related to how files are read from the filesystem)
72        // to make our skeleton generation logic non-reproducible - therefore we sort!
73        serialised_manifests.sort_by_key(|m| m.relative_path.clone());
74
75        Ok(Skeleton {
76            manifests: serialised_manifests,
77            config_file,
78            lock_file,
79            rust_toolchain_file,
80        })
81    }
82
83    /// Given the manifests in the current skeleton, create the minimum set of files required to
84    /// have a valid Rust project (i.e. write all manifests to disk and create dummy `lib.rs`,
85    /// `main.rs` and `build.rs` files where needed).
86    ///
87    /// This function should be called on an empty canvas - i.e. an empty directory apart from
88    /// the recipe file used to restore the skeleton.
89    pub fn build_minimum_project(
90        &self,
91        base_path: &Path,
92        no_std: bool,
93    ) -> Result<(), anyhow::Error> {
94        // Save lockfile to disk, if available
95        if let Some(lock_file) = &self.lock_file {
96            let lock_file_path = base_path.join("Cargo.lock");
97            fs::write(lock_file_path, lock_file.as_str())?;
98        }
99
100        // Save rust-toolchain or rust-toolchain.toml to disk, if available
101        if let Some((file_kind, content)) = &self.rust_toolchain_file {
102            let file_name = match file_kind {
103                RustToolchainFile::Bare => "rust-toolchain",
104                RustToolchainFile::Toml => "rust-toolchain.toml",
105            };
106            let path = base_path.join(file_name);
107            fs::write(path, content.as_str())?;
108        }
109
110        // save config file to disk, if available
111        if let Some(config_file) = &self.config_file {
112            let parent_dir = base_path.join(".cargo");
113            let config_file_path = parent_dir.join("config.toml");
114            fs::create_dir_all(parent_dir)?;
115            fs::write(config_file_path, config_file.as_str())?;
116        }
117
118        const NO_STD_ENTRYPOINT: &str = "#![no_std]
119#![no_main]
120
121#[panic_handler]
122fn panic(_: &core::panic::PanicInfo) -> ! {
123    loop {}
124}
125";
126        const NO_STD_HARNESS_ENTRYPOINT: &str = r#"#![no_std]
127#![no_main]
128#![feature(custom_test_frameworks)]
129#![test_runner(test_runner)]
130
131#[no_mangle]
132pub extern "C" fn _init() {}
133
134fn test_runner(_: &[&dyn Fn()]) {}
135
136#[panic_handler]
137fn panic(_: &core::panic::PanicInfo) -> ! {
138    loop {}
139}
140"#;
141
142        let get_test_like_entrypoint = |harness: bool| -> &str {
143            match (no_std, harness) {
144                (true, true) => NO_STD_HARNESS_ENTRYPOINT,
145                (true, false) => NO_STD_ENTRYPOINT,
146                (false, true) => "",
147                (false, false) => "fn main() {}",
148            }
149        };
150
151        // Save all manifests to disks
152        for manifest in &self.manifests {
153            // Persist manifest
154            let manifest_path = base_path.join(&manifest.relative_path);
155            let parent_directory = if let Some(parent_directory) = manifest_path.parent() {
156                fs::create_dir_all(parent_directory)?;
157                parent_directory.to_path_buf()
158            } else {
159                base_path.to_path_buf()
160            };
161            fs::write(&manifest_path, &manifest.contents)?;
162            let parsed_manifest =
163                cargo_manifest::Manifest::from_slice(manifest.contents.as_bytes())?;
164
165            let is_harness = |products: &[Product], name: &str| -> bool {
166                products
167                    .iter()
168                    .find(|product| product.name.as_deref() == Some(name))
169                    .map(|p| p.harness)
170                    .unwrap_or(true)
171            };
172
173            // Create dummy entrypoints for all targets
174            for target in &manifest.targets {
175                let content = match target.kind {
176                    TargetKind::BuildScript => "fn main() {}",
177                    TargetKind::Bin | TargetKind::Example => {
178                        if no_std {
179                            NO_STD_ENTRYPOINT
180                        } else {
181                            "fn main() {}"
182                        }
183                    }
184                    TargetKind::Lib { is_proc_macro } => {
185                        if no_std && !is_proc_macro {
186                            "#![no_std]"
187                        } else {
188                            ""
189                        }
190                    }
191                    TargetKind::Bench => {
192                        get_test_like_entrypoint(is_harness(&parsed_manifest.bench, &target.name))
193                    }
194                    TargetKind::Test => {
195                        get_test_like_entrypoint(is_harness(&parsed_manifest.test, &target.name))
196                    }
197                };
198                let path = parent_directory.join(&target.path);
199                if let Some(dir) = path.parent() {
200                    fs::create_dir_all(dir)?;
201                }
202                fs::write(&path, content)?;
203            }
204        }
205        Ok(())
206    }
207
208    /// Scan the target directory and remove all compilation artifacts for libraries and build
209    /// scripts from the current workspace.
210    /// Given the usage of dummy `lib.rs` and `build.rs` files, keeping them around leads to funny
211    /// compilation errors.
212    pub fn remove_compiled_dummies<P: AsRef<Path>>(
213        &self,
214        base_path: P,
215        profile: OptimisationProfile,
216        target: Option<Vec<String>>,
217        target_dir: Option<PathBuf>,
218    ) -> Result<(), anyhow::Error> {
219        let target_dir = match target_dir {
220            None => base_path.as_ref().join("target"),
221            Some(target_dir) => target_dir,
222        };
223
224        // https://doc.rust-lang.org/cargo/guide/build-cache.html
225        // > For historical reasons, the `dev` and `test` profiles are stored
226        // > in the `debug` directory, and the `release` and `bench` profiles are
227        // > stored in the `release` directory. User-defined profiles are
228        // > stored in a directory with the same name as the profile.
229
230        let profile_dir = match &profile {
231            OptimisationProfile::Release => "release",
232            OptimisationProfile::Debug => "debug",
233            OptimisationProfile::Other(profile) if profile == "bench" => "release",
234            OptimisationProfile::Other(profile) if profile == "dev" || profile == "test" => "debug",
235            OptimisationProfile::Other(custom_profile) => custom_profile,
236        };
237
238        let target_directories: Vec<PathBuf> = target
239            .map_or(vec![target_dir.clone()], |targets| {
240                targets
241                    .iter()
242                    .map(|target| target_dir.join(target_str(target)))
243                    .collect()
244            })
245            .iter()
246            .map(|path| path.join(profile_dir))
247            .collect();
248
249        for manifest in &self.manifests {
250            let parsed_manifest =
251                cargo_manifest::Manifest::from_slice(manifest.contents.as_bytes())?;
252            if let Some(package) = parsed_manifest.package.as_ref() {
253                for target_directory in &target_directories {
254                    // Remove dummy libraries.
255                    if let Some(lib) = &parsed_manifest.lib {
256                        let library_name =
257                            lib.name.as_ref().unwrap_or(&package.name).replace('-', "_");
258                        let walker = GlobWalkerBuilder::from_patterns(
259                            target_directory,
260                            &[
261                                format!("/**/lib{library_name}.*"),
262                                format!("/**/lib{library_name}-*"),
263                            ],
264                        )
265                        .build()?;
266                        for file in walker {
267                            let file = file?;
268                            if file.file_type().is_file() {
269                                fs::remove_file(file.path())?;
270                            } else if file.file_type().is_dir() {
271                                fs::remove_dir_all(file.path())?;
272                            }
273                        }
274                    }
275
276                    // Remove dummy build.rs script artifacts.
277                    if package.build.is_some() {
278                        let walker = GlobWalkerBuilder::new(
279                            target_directory,
280                            format!("/build/{}-*/build[-_]script[-_]build*", package.name),
281                        )
282                        .build()?;
283                        for file in walker {
284                            let file = file?;
285                            fs::remove_file(file.path())?;
286                        }
287                    }
288                }
289            }
290        }
291
292        Ok(())
293    }
294}
295
296/// If a custom target spec file is used,
297/// (Part of the unstable cargo feature 'build-std'; c.f. https://doc.rust-lang.org/rustc/targets/custom.html )
298/// the `--target` flag refers to a `.json` file in the current directory.
299/// In this case, the actual name of the target is the value of `--target` without the `.json` suffix.
300fn target_str(target: &str) -> &str {
301    target.trim_end_matches(".json")
302}
303
304fn serialize_manifests(manifests: Vec<ParsedManifest>) -> Result<Vec<Manifest>, anyhow::Error> {
305    let mut serialised_manifests = vec![];
306    for manifest in manifests {
307        // The serialised contents might be different from the original manifest!
308        let contents = toml::to_string(&manifest.contents)?;
309        serialised_manifests.push(Manifest {
310            relative_path: manifest.relative_path,
311            contents,
312            targets: manifest.targets,
313        });
314    }
315    Ok(serialised_manifests)
316}
317
318fn extract_package_graph(path: &Path, no_deps: bool) -> Result<PackageGraph, anyhow::Error> {
319    let mut cmd = guppy::MetadataCommand::new();
320    cmd.current_dir(path);
321    if no_deps {
322        cmd.no_deps();
323    }
324    cmd.build_graph().context("Cannot extract package graph")
325}
326
327/// Filter the skeleton down to only the workspace members (and their transitive
328/// dependencies) needed to build `member`.
329///
330/// Uses guppy's `query_forward` + `resolve` to compute the full transitive
331/// dependency closure, then:
332/// 1. Removes manifests for workspace members outside the closure
333/// 2. Updates `[workspace.members]` to list only closure members
334/// 3. Removes `[workspace.default-members]`
335/// 4. Filters the lockfile to only packages in the closure
336fn filter_to_member_closure(
337    manifests: &mut Vec<ParsedManifest>,
338    lock_file: &mut Option<toml::Value>,
339    graph: &PackageGraph,
340    member: &str,
341) -> Result<(), anyhow::Error> {
342    let ws = graph.workspace();
343    // `member` may be a package name or a binary target name (from --bin).
344    // Try package name first, then search for a binary target.
345    let pkg = match ws.member_by_name(member) {
346        Ok(pkg) => pkg,
347        Err(_) => ws
348            .iter()
349            .find(|pkg| {
350                pkg.build_targets().any(|t| {
351                    matches!(t.id(), guppy::graph::BuildTargetId::Binary(name) if name == member)
352                })
353            })
354            .ok_or_else(|| {
355                anyhow::anyhow!("No workspace package or binary target named '{member}'")
356            })?,
357    };
358
359    // Get transitive closure of all dependencies
360    let resolved = graph.query_forward(std::iter::once(pkg.id()))?.resolve();
361
362    // Collect workspace member names in the closure
363    let closure_members: HashSet<String> = ws
364        .iter()
365        .filter(|ws_pkg| resolved.contains(ws_pkg.id()).unwrap_or(false))
366        .map(|ws_pkg| ws_pkg.name().to_string())
367        .collect();
368
369    // 1. Filter manifests: keep root workspace manifest + closure members
370    manifests.retain(|m| {
371        extract_pkg_name(&m.contents).is_none_or(|name| closure_members.contains(&name))
372    });
373
374    // 2. Collect workspace dep keys referenced by closure members
375    let referenced_workspace_deps = collect_workspace_dep_keys(manifests);
376
377    // 3. Update [workspace.members], remove default-members, filter [workspace.dependencies]
378    update_workspace_members(
379        manifests,
380        graph,
381        &closure_members,
382        &referenced_workspace_deps,
383    );
384
385    // 4. Collect ALL (name, version) pairs in the closure (workspace + external)
386    let closure_packages: HashSet<(String, String)> = resolved
387        .packages(DependencyDirection::Forward)
388        .map(|pkg| (pkg.name().to_string(), pkg.version().to_string()))
389        .collect();
390
391    // 5. Filter lockfile: keep only packages in the closure
392    if let Some(lockfile) = lock_file {
393        filter_lockfile_packages(lockfile, &closure_packages);
394    }
395
396    Ok(())
397}
398
399/// Extract the `[package].name` from a parsed TOML manifest, if present.
400fn extract_pkg_name(contents: &toml::Value) -> Option<String> {
401    contents
402        .get("package")?
403        .get("name")?
404        .as_str()
405        .map(|s| s.to_string())
406}
407
408/// Replace `[workspace.members]` with relative paths for all closure members,
409/// remove `[workspace.default-members]`, and filter `[workspace.dependencies]`
410/// to only those referenced by closure members.
411fn update_workspace_members(
412    manifests: &mut [ParsedManifest],
413    graph: &PackageGraph,
414    closure_members: &HashSet<String>,
415    referenced_workspace_deps: &HashSet<String>,
416) {
417    let workspace_toml = manifests
418        .iter_mut()
419        .find(|manifest| manifest.relative_path == std::path::Path::new("Cargo.toml"));
420
421    if let Some(workspace) = workspace_toml.and_then(|toml| toml.contents.get_mut("workspace")) {
422        if let Some(members) = workspace.get_mut("members") {
423            let ws = graph.workspace();
424            let workspace_root = ws.root();
425
426            let member_paths: Vec<toml::Value> = ws
427                .iter()
428                .filter(|pkg| closure_members.contains(pkg.name()))
429                .filter_map(|pkg| {
430                    let cargo_path = diff_paths(pkg.manifest_path(), workspace_root)?;
431                    let dir = cargo_path.parent()?;
432                    Some(toml::Value::String(dir.to_str()?.to_string()))
433                })
434                .collect();
435
436            *members = toml::Value::Array(member_paths);
437        }
438        if let Some(workspace) = workspace.as_table_mut() {
439            workspace.remove("default-members");
440            if let Some(deps) = workspace
441                .get_mut("dependencies")
442                .and_then(|d| d.as_table_mut())
443            {
444                deps.retain(|key, _| referenced_workspace_deps.contains(key));
445            }
446        }
447    }
448}
449
450/// Collect all `[workspace.dependencies]` keys referenced (via `workspace = true`)
451/// by the given manifests.
452fn collect_workspace_dep_keys(manifests: &[ParsedManifest]) -> HashSet<String> {
453    let mut keys = HashSet::new();
454    for manifest in manifests {
455        if extract_pkg_name(&manifest.contents).is_none() {
456            continue; // skip root workspace manifest
457        }
458        collect_workspace_keys_from(&manifest.contents, &mut keys);
459        if let Some(targets) = manifest.contents.get("target").and_then(|t| t.as_table()) {
460            for (_, target_config) in targets {
461                collect_workspace_keys_from(target_config, &mut keys);
462            }
463        }
464    }
465    keys
466}
467
468fn collect_workspace_keys_from(value: &toml::Value, keys: &mut HashSet<String>) {
469    for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
470        if let Some(deps) = value.get(section).and_then(|d| d.as_table()) {
471            for (key, dep) in deps {
472                if dep
473                    .get("workspace")
474                    .and_then(|w| w.as_bool())
475                    .unwrap_or(false)
476                {
477                    keys.insert(key.clone());
478                }
479            }
480        }
481    }
482}
483
484/// Retain only lockfile `[[package]]` entries whose `(name, version)` pair
485/// is in the resolved closure.
486fn filter_lockfile_packages(
487    lockfile: &mut toml::Value,
488    closure_packages: &HashSet<(String, String)>,
489) {
490    if let Some(packages) = lockfile.get_mut("package").and_then(|p| p.as_array_mut()) {
491        packages.retain(|pkg| {
492            let name = pkg.get("name").and_then(|n| n.as_str());
493            let version = pkg.get("version").and_then(|v| v.as_str());
494            match (name, version) {
495                (Some(n), Some(v)) => closure_packages.contains(&(n.to_string(), v.to_string())),
496                _ => true,
497            }
498        });
499    }
500}