use anyhow::{Context, Result};
use cargo_metadata::{Metadata, MetadataCommand};
use std::path::Path;
use std::process::Command;
pub struct ProjectContext {
pub metadata: Metadata,
pub cargo_config: Option<toml::Table>,
pub cargo_toml: toml::Table,
pub rustc_version: Option<RustcVersion>,
pub host_triple: Option<String>,
pub installed_linkers: InstalledLinkers,
pub env_vars: EnvVars,
pub os: Os,
}
#[derive(Debug)]
pub struct RustcVersion {
pub major: u32,
pub minor: u32,
pub patch: u32,
pub raw: String,
}
#[derive(Debug, Default)]
pub struct InstalledLinkers {
pub mold: bool,
pub lld: bool,
}
#[derive(Debug, Default)]
pub struct EnvVars {
pub cargo_incremental: Option<String>,
pub rustflags: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Os {
Linux,
Macos,
Windows,
Other,
}
impl ProjectContext {
pub fn gather(manifest_path: Option<&Path>) -> Result<Self> {
let mut cmd = MetadataCommand::new();
if let Some(path) = manifest_path {
cmd.manifest_path(path);
}
let metadata = cmd.exec().context("Failed to run `cargo metadata`")?;
let project_root = metadata.workspace_root.as_std_path().to_path_buf();
let cargo_toml = read_toml(&project_root.join("Cargo.toml"))?;
let cargo_config = read_cargo_config(&project_root);
let RustcInfo {
version: rustc_version,
host_triple,
} = parse_rustc_info();
let installed_linkers = detect_linkers();
let env_vars = gather_env_vars();
let os = detect_os();
Ok(Self {
metadata,
cargo_config,
cargo_toml,
rustc_version,
host_triple,
installed_linkers,
env_vars,
os,
})
}
pub fn project_name(&self) -> &str {
self.metadata
.root_package()
.map_or("project", |p| p.name.as_str())
}
pub fn is_workspace(&self) -> bool {
self.metadata.workspace_members.len() > 1
}
pub fn declared_msrv(&self) -> Option<(u32, u32)> {
let from_package = self
.cargo_toml
.get("package")
.and_then(|p| p.as_table())
.and_then(|p| p.get("rust-version"))
.and_then(toml::Value::as_str);
let from_workspace = self
.cargo_toml
.get("workspace")
.and_then(|w| w.as_table())
.and_then(|w| w.get("package"))
.and_then(|p| p.as_table())
.and_then(|p| p.get("rust-version"))
.and_then(toml::Value::as_str);
let raw = from_package.or(from_workspace)?;
parse_major_minor(raw)
}
}
fn parse_major_minor(raw: &str) -> Option<(u32, u32)> {
let mut parts = raw.trim().split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
Some((major, minor))
}
fn read_toml(path: &Path) -> Result<toml::Table> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
content
.parse::<toml::Table>()
.with_context(|| format!("Failed to parse {}", path.display()))
}
fn read_cargo_config(root: &Path) -> Option<toml::Table> {
let candidates = [root.join(".cargo/config.toml"), root.join(".cargo/config")];
for path in &candidates {
if let Ok(content) = std::fs::read_to_string(path) {
if let Ok(table) = content.parse::<toml::Table>() {
return Some(table);
}
}
}
None
}
struct RustcInfo {
version: Option<RustcVersion>,
host_triple: Option<String>,
}
fn parse_rustc_info() -> RustcInfo {
let Some(output) = Command::new("rustc").arg("-vV").output().ok() else {
return RustcInfo {
version: None,
host_triple: None,
};
};
let text = String::from_utf8_lossy(&output.stdout);
let version = text.lines().next().and_then(parse_version_line);
let host_triple = text
.lines()
.find_map(|line| line.strip_prefix("host:"))
.map(|t| t.trim().to_string());
RustcInfo {
version,
host_triple,
}
}
fn parse_version_line(line: &str) -> Option<RustcVersion> {
let raw = line.trim().to_string();
let version_part = raw.strip_prefix("rustc ")?.split_whitespace().next()?;
let mut parts = version_part.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
Some(RustcVersion {
major,
minor,
patch,
raw,
})
}
fn detect_linkers() -> InstalledLinkers {
InstalledLinkers {
mold: on_path("mold"),
lld: on_path("lld") || on_path("ld.lld"),
}
}
fn on_path(name: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| {
let candidate = dir.join(name);
candidate.is_file() || (cfg!(windows) && candidate.with_extension("exe").is_file())
})
}
fn gather_env_vars() -> EnvVars {
EnvVars {
cargo_incremental: std::env::var("CARGO_INCREMENTAL").ok(),
rustflags: std::env::var("RUSTFLAGS").ok(),
}
}
fn detect_os() -> Os {
if cfg!(target_os = "linux") {
Os::Linux
} else if cfg!(target_os = "macos") {
Os::Macos
} else if cfg!(target_os = "windows") {
Os::Windows
} else {
Os::Other
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_full_version_line() {
let v = parse_version_line("rustc 1.96.0 (ac68faa20 2026-05-25)").unwrap();
assert_eq!((v.major, v.minor, v.patch), (1, 96, 0));
}
#[test]
fn rejects_non_version_line() {
assert!(parse_version_line("host: aarch64-apple-darwin").is_none());
}
#[test]
fn parses_msrv_two_and_three_component() {
assert_eq!(parse_major_minor("1.74"), Some((1, 74)));
assert_eq!(parse_major_minor("1.74.1"), Some((1, 74)));
assert_eq!(parse_major_minor("nightly"), None);
}
}