use std::path::PathBuf;
use std::process::Command;
use crate::error::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Toolchain {
pub release_line: String,
pub release: String,
pub host: String,
pub commit_hash: String,
pub sysroot: PathBuf,
}
pub fn capture() -> Result<Toolchain, Error> {
let version_out = Command::new("rustc")
.args(["--version", "--verbose"])
.output()
.map_err(|e| Error::SubprocessSpawn {
program: "rustc".into(),
source: e,
})?;
if !version_out.status.success() {
return Err(Error::Session(crate::error::Outcome::ConfigInvalid {
message: format!(
"`rustc --version --verbose` exited {} (stderr: {}).\nWhy this matters: lihaaf needs the active toolchain identity to detect drift.",
version_out.status,
String::from_utf8_lossy(&version_out.stderr).trim()
),
}));
}
let text = String::from_utf8_lossy(&version_out.stdout);
let mut release_line = String::new();
let mut release = String::new();
let mut host = String::new();
let mut commit_hash = String::new();
for (idx, line) in text.lines().enumerate() {
if idx == 0 {
release_line = line.trim().to_string();
continue;
}
if let Some((key, value)) = line.split_once(':') {
let key = key.trim();
let value = value.trim();
match key {
"release" => release = value.to_string(),
"host" => host = value.to_string(),
"commit-hash" => commit_hash = value.to_string(),
_ => {}
}
}
}
if release.is_empty() || host.is_empty() {
return Err(Error::Session(crate::error::Outcome::ConfigInvalid {
message: format!(
"`rustc --version --verbose` did not include `release:` and/or `host:` lines.\n output:\n{text}"
),
}));
}
let sysroot_out = Command::new("rustc")
.args(["--print", "sysroot"])
.output()
.map_err(|e| Error::SubprocessSpawn {
program: "rustc".into(),
source: e,
})?;
if !sysroot_out.status.success() {
return Err(Error::Session(crate::error::Outcome::ConfigInvalid {
message: format!(
"`rustc --print sysroot` exited {}.\n stderr: {}",
sysroot_out.status,
String::from_utf8_lossy(&sysroot_out.stderr).trim()
),
}));
}
let sysroot = PathBuf::from(
String::from_utf8_lossy(&sysroot_out.stdout)
.trim()
.to_string(),
);
Ok(Toolchain {
release_line,
release,
host,
commit_hash,
sysroot,
})
}
pub fn matches(original: &Toolchain, current: &Toolchain) -> bool {
original.release_line == current.release_line
&& original.host == current.host
&& original.commit_hash == current.commit_hash
&& original.sysroot == current.sysroot
}
pub(crate) fn format_drift_key(t: &Toolchain) -> String {
format!(
"release_line: {rl}\nhost: {host}\ncommit_hash: {ch}\nsysroot: {sr}",
rl = t.release_line,
host = t.host,
ch = t.commit_hash,
sr = t.sysroot.display(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_matches_real_rustc_block_shape() {
let captured = "\
rustc 1.95.0 (59807616e 2026-04-14)
binary: rustc
commit-hash: 59807616e2031c7c44a76b1b0c1bbd0fed9a07cf
commit-date: 2026-04-14
host: x86_64-unknown-linux-gnu
release: 1.95.0
LLVM version: 22.1.2";
let mut release_line = String::new();
let mut release = String::new();
let mut host = String::new();
let mut commit_hash = String::new();
for (idx, line) in captured.lines().enumerate() {
if idx == 0 {
release_line = line.trim().to_string();
continue;
}
if let Some((key, value)) = line.split_once(':') {
let key = key.trim();
let value = value.trim();
match key {
"release" => release = value.to_string(),
"host" => host = value.to_string(),
"commit-hash" => commit_hash = value.to_string(),
_ => {}
}
}
}
assert_eq!(release_line, "rustc 1.95.0 (59807616e 2026-04-14)");
assert_eq!(release, "1.95.0");
assert_eq!(host, "x86_64-unknown-linux-gnu");
assert_eq!(commit_hash, "59807616e2031c7c44a76b1b0c1bbd0fed9a07cf");
}
fn baseline_toolchain() -> Toolchain {
Toolchain {
release_line: "rustc 1.95.0 (59807616e 2026-04-14)".into(),
release: "1.95.0".into(),
host: "x86_64-unknown-linux-gnu".into(),
commit_hash: "59807616e2031c7c44a76b1b0c1bbd0fed9a07cf".into(),
sysroot: PathBuf::from("/usr/local/rustup/toolchains/stable-x86_64"),
}
}
fn assert_field_mutation_differs(mutate: impl FnOnce(&mut Toolchain)) {
let a = baseline_toolchain();
let mut b = a.clone();
mutate(&mut b);
assert!(!matches(&a, &b));
}
#[test]
fn matches_identical_toolchains() {
let a = baseline_toolchain();
let b = a.clone();
assert!(matches(&a, &b));
}
#[test]
fn matches_compares_full_key_release_line_differs() {
assert_field_mutation_differs(|b| {
b.release_line = "rustc 1.96.0 (deadbeef 2026-07-01)".into();
});
}
#[test]
fn matches_compares_full_key_host_differs() {
assert_field_mutation_differs(|b| {
b.host = "aarch64-apple-darwin".into();
});
}
#[test]
fn matches_compares_full_key_commit_hash_differs() {
assert_field_mutation_differs(|b| {
b.commit_hash = "0000000000000000000000000000000000000000".into();
});
}
#[test]
fn matches_compares_full_key_sysroot_differs() {
assert_field_mutation_differs(|b| {
b.sysroot = PathBuf::from("/home/user/.rustup/toolchains/nightly-x86_64");
});
}
#[test]
fn matches_custom_build_both_empty_commit_hash() {
let mut a = baseline_toolchain();
a.commit_hash = String::new();
let b = a.clone();
assert!(matches(&a, &b));
}
}