axoproject/
rust.rs

1//! Support for Cargo-based Rust projects
2
3use std::collections::BTreeMap;
4
5use crate::{
6    PackageInfo, Result, Version, WorkspaceInfo, WorkspaceKind, WorkspaceSearch, WorkspaceStructure,
7};
8use axoasset::SourceFile;
9use camino::{Utf8Path, Utf8PathBuf};
10use guppy::{
11    graph::{BuildTargetId, BuildTargetKind, DependencyDirection, PackageGraph, PackageMetadata},
12    MetadataCommand,
13};
14use itertools::{concat, Itertools};
15
16pub use axoasset::toml_edit::DocumentMut;
17
18/// All the `[profile]` entries we found in the root Cargo.toml
19pub type CargoProfiles = BTreeMap<String, CargoProfile>;
20
21/// Try to find a Cargo/Rust workspace at start_dir, walking up
22/// ancestors as necessary until we reach clamp_to_dir (or run out of ancestors).
23///
24/// Behaviour is unspecified if only part of the workspace is nested in clamp_to_dir
25/// We might find the workspace, or we might not. This is generally assumed to be fine,
26/// since we typically clamp to a git repo, if at all.
27///
28/// This relies on `cargo metadata` so will only work if you have `cargo` installed.
29pub fn get_workspace(start_dir: &Utf8Path, clamp_to_dir: Option<&Utf8Path>) -> WorkspaceSearch {
30    // The call to `workspace_manifest` here is technically redundant with what cargo-metadata will
31    // do, but doing it ourselves makes it really easy to distinguish between
32    // "no workspace at all" and "workspace is busted", and to provide better context in the latter.
33    let manifest_path = match workspace_manifest(start_dir, clamp_to_dir) {
34        Ok(path) => path,
35        Err(e) => {
36            return WorkspaceSearch::Missing(e);
37        }
38    };
39
40    let graph = match package_graph(start_dir) {
41        Ok(graph) => graph,
42        Err(e) => {
43            let error = match e {
44                // Indicates we failed to run `cargo metadata`; this is the
45                // one we want to intercept and replace with a friendlier error.
46                crate::AxoprojectError::CargoMetadata(e) => {
47                    if cargo_version_works() {
48                        // we have cargo, `cargo metadata` just failed though — relay
49                        // its stderr, which should clue in the users on TOML parse errors,
50                        // invalid dependencies, etc.
51                        crate::AxoprojectError::CargoMetadata(e)
52                    } else {
53                        // even `cargo --version` failed, so let's tell the user where they
54                        // can grab cargo!
55                        crate::AxoprojectError::CargoMissing {}
56                    }
57                }
58                // Any other errors are less expected, and we should pass
59                // those through unaltered.
60                _ => e,
61            };
62            return WorkspaceSearch::Missing(error);
63        }
64    };
65
66    // There's definitely some kind of Cargo workspace, now try to make sense of it
67    let workspace = workspace_info(&graph);
68    match workspace {
69        Ok(workspace) => WorkspaceSearch::Found(workspace),
70        Err(e) => WorkspaceSearch::Broken {
71            manifest_path,
72            cause: e,
73        },
74    }
75}
76
77/// Simple check if cargo is installed and can be executed
78fn cargo_version_works() -> bool {
79    std::process::Command::new("cargo")
80        .arg("--version")
81        .status()
82        .map(|s| s.success())
83        .unwrap_or(false)
84}
85
86/// Get the PackageGraph for the current workspace
87fn package_graph(start_dir: &Utf8Path) -> Result<PackageGraph> {
88    let mut metadata_cmd = MetadataCommand::new();
89
90    metadata_cmd.current_dir(start_dir);
91
92    let pkg_graph = metadata_cmd.build_graph()?;
93
94    Ok(pkg_graph)
95}
96
97/// Computes [`WorkspaceInfo`][] for the current workspace.
98fn workspace_info(pkg_graph: &PackageGraph) -> Result<WorkspaceStructure> {
99    let workspace = pkg_graph.workspace();
100    let members = pkg_graph.resolve_workspace();
101
102    let manifest_path = workspace.root().join("Cargo.toml");
103    // I originally had this as a proper Error but honestly this would be MADNESS and
104    // I want someone to tell me about this if they ever encounter it, so blow everything up
105    assert!(
106        manifest_path.exists(),
107        "cargo metadata returned a workspace without a Cargo.toml!?"
108    );
109
110    let cargo_profiles = get_profiles(&manifest_path)?;
111
112    let cargo_metadata_table = Some(workspace.metadata_table().clone());
113    let workspace_root = workspace.root();
114    let root_auto_includes = crate::find_auto_includes(workspace_root)?;
115    let mut all_package_info = vec![];
116    for package in members.packages(DependencyDirection::Forward) {
117        let mut info = package_info(workspace_root, &package, pkg_graph)?;
118        crate::merge_auto_includes(&mut info, &root_auto_includes);
119        all_package_info.push(info);
120    }
121
122    let target_dir = workspace.target_directory().to_owned();
123    let workspace_dir = workspace.root().to_owned();
124
125    Ok(WorkspaceStructure {
126        sub_workspaces: vec![],
127        packages: all_package_info,
128        workspace: WorkspaceInfo {
129            kind: WorkspaceKind::Rust,
130            target_dir,
131            workspace_dir,
132
133            manifest_path,
134            dist_manifest_path: None,
135            root_auto_includes,
136            cargo_metadata_table,
137            cargo_profiles,
138        },
139    })
140}
141
142fn package_info(
143    _workspace_root: &Utf8Path,
144    package: &PackageMetadata,
145    pkg_graph: &PackageGraph,
146) -> Result<PackageInfo> {
147    let manifest_path = package.manifest_path().to_owned();
148    let package_root = manifest_path
149        .parent()
150        .expect("package manifest had no parent!?")
151        .to_owned();
152    let cargo_package_id = Some(package.id().clone());
153    let cargo_metadata_table = Some(package.metadata_table().clone());
154
155    let mut binaries = vec![];
156    let mut cdylibs = vec![];
157    let mut cstaticlibs = vec![];
158    for target in package.build_targets() {
159        let build_id = target.id();
160        match build_id {
161            BuildTargetId::Binary(name) => {
162                // Hooray it's a proper binary
163                binaries.push(name.to_owned());
164            }
165            BuildTargetId::Library => {
166                // This is the ONE AND ONLY "true" library target, now that we've confirmed
167                // that we can trust BuildTargetKind::LibraryOrExample to only be non-examples.
168                // All the different kinds of library outputs like cdylibs and staticlibs are
169                // shoved into this one build target, making it impossible to build only one
170                // at a time (which is really unfortunate because cargo can produce conflicting
171                // names for some of the outputs on some platforms).
172                //
173                // crate-types is a messy field with weird naming and history. The outputs are
174                // roughly broken into two families (by me). See rustc's docs for details:
175                //
176                // https://doc.rust-lang.org/nightly/reference/linkage.html
177                //
178                //
179                // # rust-only / intermediates
180                //
181                // * proc-macro: a target to build the proc-macros *defined* by this crate
182                // * rlib: a rust-only staticlib
183                // * dylib: a rust-only dynamic library
184                // * lib: the fuzzy default library target that lets cargo/rustc pick
185                //   the "right" choice. this enables things like -Cprefer-dynamic
186                //   which override all libs to the desired result.
187                //
188                // The rust-only outputs are mostly things rust developers don't have to care
189                // about, and mostly exist as intermediate.temporary results (the main exception
190                // is the stdlib is shipped in this form, because it's released in lockstep with
191                // the rustc that understands it)
192                //
193                //
194                // # final outputs
195                //
196                // * staticlib: a C-style static library
197                // * cdylib: a C-style dynamic library
198                // * bin: a binary (not relevant here)
199                //
200                // Grouping a C-style static library here is kinda dubious but at very least
201                // it's something meaningful outside of cargo/rustc itself (I super don't care
202                // that rlibs are a thin veneer over staticlibs and that you got things to link,
203                // you're not "supposed" to do that.)
204                if let BuildTargetKind::LibraryOrExample(crate_types) = target.kind() {
205                    for crate_type in crate_types {
206                        match &**crate_type {
207                            "cdylib" => {
208                                cdylibs.push(target.name().to_owned());
209                            }
210                            "staticlib" => {
211                                cstaticlibs.push(target.name().to_owned());
212                            }
213                            _ => {
214                                // Don't care about these
215                            }
216                        }
217                    }
218                }
219            }
220            _ => {
221                // Don't care about these
222            }
223        }
224    }
225
226    let keywords_and_categories: Option<Vec<String>> =
227        if package.keywords().is_empty() && package.categories().is_empty() {
228            None
229        } else {
230            let categories = package.categories().to_vec();
231            let keywords = package.keywords().to_vec();
232            Some(
233                concat(vec![categories, keywords])
234                    .into_iter()
235                    .unique()
236                    .collect::<Vec<String>>(),
237            )
238        };
239
240    let query = pkg_graph.query_forward(std::iter::once(package.id()))?;
241    let package_set = query.resolve();
242    let mut axoupdater_versions = vec![];
243    for p in package_set.packages(DependencyDirection::Reverse) {
244        for subpackage in p.direct_links() {
245            if subpackage.dep_name() == "axoupdater" {
246                axoupdater_versions.push((
247                    p.name().to_owned(),
248                    Version::Cargo(subpackage.to().version().to_owned()),
249                ))
250            }
251        }
252    }
253
254    let version = Some(Version::Cargo(package.version().clone()));
255    let mut info = PackageInfo {
256        true_name: package.name().to_owned(),
257        true_version: version.clone(),
258        name: package.name().to_owned(),
259        version,
260        manifest_path,
261        dist_manifest_path: None,
262        package_root: package_root.clone(),
263        description: package.description().map(ToOwned::to_owned),
264        authors: package.authors().to_vec(),
265        keywords: keywords_and_categories,
266        license: package.license().map(ToOwned::to_owned),
267        publish: !package.publish().is_never(),
268        repository_url: package.repository().map(ToOwned::to_owned),
269        homepage_url: package.homepage().map(ToOwned::to_owned),
270        documentation_url: package.documentation().map(ToOwned::to_owned),
271        readme_file: package.readme().map(|readme| package_root.join(readme)),
272        license_files: package
273            .license_file()
274            .map(ToOwned::to_owned)
275            .into_iter()
276            .collect(),
277        changelog_file: None,
278        binaries,
279        cdylibs,
280        cstaticlibs,
281        cargo_metadata_table,
282        cargo_package_id,
283        npm_scope: None,
284        build_command: None,
285        axoupdater_versions,
286        dist: None,
287    };
288
289    // Find files we might want to auto-include
290    // It's kind of unfortunate that we do this unconditionally for every
291    // package, even if we'll never care about the result, but that's how
292    // separation of concerns gets ya.
293    let auto_includes = crate::find_auto_includes(&package_root)?;
294    crate::merge_auto_includes(&mut info, &auto_includes);
295
296    // If there's no documentation URL provided, default assume it's docs.rs like crates.io does
297    if info.documentation_url.is_none() {
298        info.documentation_url = Some(format!(
299            "https://docs.rs/{}/{}",
300            info.name,
301            info.version.as_ref().unwrap()
302        ));
303    }
304
305    Ok(info)
306}
307
308/// Find a Cargo.toml, starting at the given dir and walking up to ancestor dirs,
309/// optionally clamped to a given ancestor dir
310fn workspace_manifest(
311    start_dir: &Utf8Path,
312    clamp_to_dir: Option<&Utf8Path>,
313) -> Result<Utf8PathBuf> {
314    crate::find_file("Cargo.toml", start_dir, clamp_to_dir)
315}
316
317/// Load the root workspace toml into toml-edit form
318pub fn load_root_cargo_toml(manifest_path: &Utf8Path) -> Result<DocumentMut> {
319    let manifest_src = SourceFile::load_local(manifest_path)?;
320    let manifest = manifest_src.deserialize_toml_edit()?;
321    Ok(manifest)
322}
323
324fn get_profiles(manifest_path: &Utf8Path) -> Result<BTreeMap<String, CargoProfile>> {
325    let mut profiles = CargoProfiles::new();
326    let workspace_toml = load_root_cargo_toml(manifest_path)?;
327    let Some(profiles_table) = &workspace_toml.get("profile").and_then(|t| t.as_table()) else {
328        // No table, empty return
329        return Ok(profiles);
330    };
331
332    for (profile_name, profile) in profiles_table.iter() {
333        // Get the fields we care about
334        let debug = profile.get("debug");
335        let split_debuginfo = profile.get("split-debuginfo");
336        let inherits = profile.get("inherits");
337
338        // clean up the true/false sugar for "debug"
339        let debug = debug.and_then(|debug| {
340            debug
341                .as_bool()
342                .map(|val| if val { 2 } else { 0 })
343                .or_else(|| debug.as_integer())
344        });
345
346        // Just capture these directly
347        let split_debuginfo = split_debuginfo
348            .and_then(|v| v.as_str())
349            .map(ToOwned::to_owned);
350        let inherits = inherits.and_then(|v| v.as_str()).map(ToOwned::to_owned);
351
352        let entry = CargoProfile {
353            inherits,
354            debug,
355            split_debuginfo,
356        };
357        profiles.insert(profile_name.to_owned(), entry);
358    }
359
360    Ok(profiles)
361}
362
363/// Parts of a [profile.*] entry in a Cargo.toml we care about
364#[derive(Debug, Clone)]
365pub struct CargoProfile {
366    /// What profile a custom profile inherits from
367    pub inherits: Option<String>,
368    /// Whether debuginfo is enabled.
369    ///
370    /// can be 0, 1, 2, true (=2), false (=0).
371    pub debug: Option<i64>,
372    /// Whether split-debuginfo is enabled.
373    ///
374    /// Can be "off", "packed", or "unpacked".
375    ///
376    /// If "packed" then we expect a pdb/dsym/dwp artifact.
377    pub split_debuginfo: Option<String>,
378}