Skip to main content

cargo_build_rx/
context.rs

1//! Gathers everything the checks need, once, up front.
2//!
3//! [`ProjectContext::gather`] runs `cargo metadata`, reads the manifest and
4//! `.cargo/config.toml`, queries `rustc`, probes for fast linkers, and reads a
5//! couple of environment variables. Every check is then a pure function over
6//! the resulting [`ProjectContext`].
7
8use anyhow::{Context, Result};
9use cargo_metadata::{Metadata, MetadataCommand};
10use std::path::Path;
11use std::process::Command;
12
13/// All project data gathered up front. Checks are pure functions over this.
14pub struct ProjectContext {
15    /// Output of `cargo metadata` for the project.
16    pub metadata: Metadata,
17    /// Parsed `.cargo/config.toml`, if present.
18    pub cargo_config: Option<toml::Table>,
19    /// Parsed workspace-root `Cargo.toml`.
20    pub cargo_toml: toml::Table,
21    /// Parsed `rustc` version, if it could be determined.
22    pub rustc_version: Option<RustcVersion>,
23    /// The host target triple reported by `rustc -vV`, e.g.
24    /// `aarch64-apple-darwin`. `None` if it could not be parsed.
25    pub host_triple: Option<String>,
26    /// Which fast linkers were found on `PATH`.
27    pub installed_linkers: InstalledLinkers,
28    /// Build-relevant environment variables.
29    pub env_vars: EnvVars,
30    /// The operating system build-rx is running on.
31    pub os: Os,
32}
33
34/// A parsed `rustc` version (`major.minor.patch`) plus the raw version line.
35#[derive(Debug)]
36pub struct RustcVersion {
37    /// Major version (always `1` for current stable Rust).
38    pub major: u32,
39    /// Minor version, e.g. `96` for `1.96.0`.
40    pub minor: u32,
41    /// Patch version.
42    pub patch: u32,
43    /// The unparsed first line of `rustc -vV`.
44    pub raw: String,
45}
46
47/// Which fast linkers were detected on `PATH`.
48#[derive(Debug, Default)]
49pub struct InstalledLinkers {
50    /// `mold` is available.
51    pub mold: bool,
52    /// `lld` (or `ld.lld`) is available.
53    pub lld: bool,
54}
55
56/// Build-relevant environment variables read at startup.
57#[derive(Debug, Default)]
58pub struct EnvVars {
59    /// Value of `CARGO_INCREMENTAL`, if set.
60    pub cargo_incremental: Option<String>,
61    /// Value of `RUSTFLAGS`, if set.
62    pub rustflags: Option<String>,
63}
64
65/// The operating system build-rx is running on.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Os {
68    /// Linux.
69    Linux,
70    /// macOS.
71    Macos,
72    /// Windows.
73    Windows,
74    /// Anything else.
75    Other,
76}
77
78impl ProjectContext {
79    /// Gather all project data.
80    ///
81    /// `manifest_path` selects the project; `None` uses the current directory.
82    pub fn gather(manifest_path: Option<&Path>) -> Result<Self> {
83        let mut cmd = MetadataCommand::new();
84        if let Some(path) = manifest_path {
85            cmd.manifest_path(path);
86        }
87        let metadata = cmd.exec().context("Failed to run `cargo metadata`")?;
88
89        let project_root = metadata.workspace_root.as_std_path().to_path_buf();
90
91        let cargo_toml = read_toml(&project_root.join("Cargo.toml"))?;
92        let cargo_config = read_cargo_config(&project_root);
93        let RustcInfo {
94            version: rustc_version,
95            host_triple,
96        } = parse_rustc_info();
97        let installed_linkers = detect_linkers();
98        let env_vars = gather_env_vars();
99        let os = detect_os();
100
101        Ok(Self {
102            metadata,
103            cargo_config,
104            cargo_toml,
105            rustc_version,
106            host_triple,
107            installed_linkers,
108            env_vars,
109            os,
110        })
111    }
112
113    /// Get the project name from metadata.
114    pub fn project_name(&self) -> &str {
115        self.metadata
116            .root_package()
117            .map_or("project", |p| p.name.as_str())
118    }
119
120    /// Check if this is a workspace with multiple members.
121    pub fn is_workspace(&self) -> bool {
122        self.metadata.workspace_members.len() > 1
123    }
124
125    /// The project's declared minimum supported Rust version (`rust-version`),
126    /// as `(major, minor)`, read from `[package]` or `[workspace.package]`.
127    ///
128    /// Returns `None` when no MSRV is declared.
129    pub fn declared_msrv(&self) -> Option<(u32, u32)> {
130        let from_package = self
131            .cargo_toml
132            .get("package")
133            .and_then(|p| p.as_table())
134            .and_then(|p| p.get("rust-version"))
135            .and_then(toml::Value::as_str);
136
137        let from_workspace = self
138            .cargo_toml
139            .get("workspace")
140            .and_then(|w| w.as_table())
141            .and_then(|w| w.get("package"))
142            .and_then(|p| p.as_table())
143            .and_then(|p| p.get("rust-version"))
144            .and_then(toml::Value::as_str);
145
146        let raw = from_package.or(from_workspace)?;
147        parse_major_minor(raw)
148    }
149}
150
151/// Parse a `major.minor` (or `major.minor.patch`) version string.
152fn parse_major_minor(raw: &str) -> Option<(u32, u32)> {
153    let mut parts = raw.trim().split('.');
154    let major = parts.next()?.parse().ok()?;
155    let minor = parts.next()?.parse().ok()?;
156    Some((major, minor))
157}
158
159fn read_toml(path: &Path) -> Result<toml::Table> {
160    let content = std::fs::read_to_string(path)
161        .with_context(|| format!("Failed to read {}", path.display()))?;
162    content
163        .parse::<toml::Table>()
164        .with_context(|| format!("Failed to parse {}", path.display()))
165}
166
167fn read_cargo_config(root: &Path) -> Option<toml::Table> {
168    let candidates = [root.join(".cargo/config.toml"), root.join(".cargo/config")];
169    for path in &candidates {
170        if let Ok(content) = std::fs::read_to_string(path) {
171            if let Ok(table) = content.parse::<toml::Table>() {
172                return Some(table);
173            }
174        }
175    }
176    None
177}
178
179/// What we extract from a single `rustc -vV` call.
180struct RustcInfo {
181    version: Option<RustcVersion>,
182    host_triple: Option<String>,
183}
184
185fn parse_rustc_info() -> RustcInfo {
186    let Some(output) = Command::new("rustc").arg("-vV").output().ok() else {
187        return RustcInfo {
188            version: None,
189            host_triple: None,
190        };
191    };
192    let text = String::from_utf8_lossy(&output.stdout);
193
194    // First line: "rustc 1.96.0 (ac68faa20 2026-05-25)"
195    let version = text.lines().next().and_then(parse_version_line);
196
197    // A later line: "host: aarch64-apple-darwin"
198    let host_triple = text
199        .lines()
200        .find_map(|line| line.strip_prefix("host:"))
201        .map(|t| t.trim().to_string());
202
203    RustcInfo {
204        version,
205        host_triple,
206    }
207}
208
209fn parse_version_line(line: &str) -> Option<RustcVersion> {
210    let raw = line.trim().to_string();
211    let version_part = raw.strip_prefix("rustc ")?.split_whitespace().next()?;
212    let mut parts = version_part.split('.');
213    let major = parts.next()?.parse().ok()?;
214    let minor = parts.next()?.parse().ok()?;
215    let patch = parts.next()?.parse().ok()?;
216    Some(RustcVersion {
217        major,
218        minor,
219        patch,
220        raw,
221    })
222}
223
224fn detect_linkers() -> InstalledLinkers {
225    InstalledLinkers {
226        mold: on_path("mold"),
227        lld: on_path("lld") || on_path("ld.lld"),
228    }
229}
230
231/// Returns `true` if an executable named `name` is found on `PATH`.
232///
233/// Scans `PATH` directly rather than shelling out to `which`/`where`, so it is
234/// portable and spawns no subprocess.
235fn on_path(name: &str) -> bool {
236    let Some(path) = std::env::var_os("PATH") else {
237        return false;
238    };
239    std::env::split_paths(&path).any(|dir| {
240        let candidate = dir.join(name);
241        candidate.is_file() || (cfg!(windows) && candidate.with_extension("exe").is_file())
242    })
243}
244
245fn gather_env_vars() -> EnvVars {
246    EnvVars {
247        cargo_incremental: std::env::var("CARGO_INCREMENTAL").ok(),
248        rustflags: std::env::var("RUSTFLAGS").ok(),
249    }
250}
251
252fn detect_os() -> Os {
253    if cfg!(target_os = "linux") {
254        Os::Linux
255    } else if cfg!(target_os = "macos") {
256        Os::Macos
257    } else if cfg!(target_os = "windows") {
258        Os::Windows
259    } else {
260        Os::Other
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn parses_full_version_line() {
270        let v = parse_version_line("rustc 1.96.0 (ac68faa20 2026-05-25)").unwrap();
271        assert_eq!((v.major, v.minor, v.patch), (1, 96, 0));
272    }
273
274    #[test]
275    fn rejects_non_version_line() {
276        assert!(parse_version_line("host: aarch64-apple-darwin").is_none());
277    }
278
279    #[test]
280    fn parses_msrv_two_and_three_component() {
281        assert_eq!(parse_major_minor("1.74"), Some((1, 74)));
282        assert_eq!(parse_major_minor("1.74.1"), Some((1, 74)));
283        assert_eq!(parse_major_minor("nightly"), None);
284    }
285}