use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ToolchainSpec {
pub pins: IndexMap<String, String>,
}
impl ToolchainSpec {
pub fn is_empty(&self) -> bool {
self.pins.is_empty()
}
}
pub fn effective_pins(
pipeline: Option<&ToolchainSpec>,
step: Option<&ToolchainSpec>,
) -> IndexMap<String, String> {
let mut out: IndexMap<String, String> = IndexMap::new();
if let Some(p) = pipeline {
for (k, v) in &p.pins {
out.insert(k.clone(), v.clone());
}
}
if let Some(s) = step {
for (k, v) in &s.pins {
out.insert(k.clone(), v.clone()); }
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Tool {
Rust,
Xcode,
Ndk,
Msvc,
}
impl Tool {
pub fn parse(name: &str) -> Option<Tool> {
match name.trim().to_ascii_lowercase().as_str() {
"rust" | "rustc" | "rustup" => Some(Tool::Rust),
"xcode" => Some(Tool::Xcode),
"ndk" | "android-ndk" => Some(Tool::Ndk),
"msvc" => Some(Tool::Msvc),
_ => None,
}
}
pub fn probe_version(&self) -> Option<String> {
match self {
Tool::Rust => probe_cmd_version("rustc", &["--version"], parse_rustc_version),
Tool::Xcode => probe_cmd_version("xcodebuild", &["-version"], parse_xcodebuild_version),
Tool::Ndk => probe_ndk_version(),
Tool::Msvc => probe_cmd_version(
"vswhere",
&["-property", "catalog_productDisplayVersion"],
|s| {
s.lines()
.next()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
},
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PinResolution {
Satisfied {
tool: String,
want: String,
got: String,
},
SatisfiedByImage { tool: String, want: String },
VersionMismatch {
tool: String,
want: String,
got: String,
},
Missing { tool: String, want: String },
Unverifiable { tool: String, want: String },
}
impl PinResolution {
pub fn is_blocking(&self) -> bool {
matches!(
self,
PinResolution::VersionMismatch { .. } | PinResolution::Missing { .. }
)
}
pub fn line(&self) -> String {
match self {
PinResolution::Satisfied { tool, want, got } => {
format!("{tool} {want} ✓ (host has {got})")
}
PinResolution::SatisfiedByImage { tool, want } => {
format!("{tool} {want} ✓ (provided by container image)")
}
PinResolution::VersionMismatch { tool, want, got } => format!(
"{tool} {want} ✗ — host has {got}; select/install {tool} {want} \
(e.g. via rustup/xcode-select/ndk manager) or run this step in a \
container image that carries it"
),
PinResolution::Missing { tool, want } => format!(
"{tool} {want} ✗ — not found on host; install {tool} {want} or run \
this step in a container image that carries it"
),
PinResolution::Unverifiable { tool, want } => {
format!("{tool} {want} ? (no host probe; not verified)")
}
}
}
}
pub fn resolve_pin(
tool: &str,
want: &str,
detected: Option<&str>,
satisfied_by_image: bool,
) -> PinResolution {
if satisfied_by_image {
return PinResolution::SatisfiedByImage {
tool: tool.to_string(),
want: want.to_string(),
};
}
if Tool::parse(tool).is_none() {
return PinResolution::Unverifiable {
tool: tool.to_string(),
want: want.to_string(),
};
}
match detected {
Some(got) if version_satisfies(want, got) => PinResolution::Satisfied {
tool: tool.to_string(),
want: want.to_string(),
got: got.to_string(),
},
Some(got) => PinResolution::VersionMismatch {
tool: tool.to_string(),
want: want.to_string(),
got: got.to_string(),
},
None => PinResolution::Missing {
tool: tool.to_string(),
want: want.to_string(),
},
}
}
pub fn version_satisfies(want: &str, got: &str) -> bool {
let want_n = normalize_version(want);
let got_n = normalize_version(got);
if want_n.is_empty() {
return true; }
let want_segs: Vec<&str> = want_n.split('.').collect();
let got_segs: Vec<&str> = got_n.split('.').collect();
want_segs.len() <= got_segs.len() && want_segs.iter().zip(got_segs.iter()).all(|(w, g)| w == g)
}
fn normalize_version(v: &str) -> &str {
let head = v.trim().trim_start_matches(|c: char| !c.is_ascii_digit());
let end = head
.find(|c: char| !(c.is_ascii_digit() || c == '.'))
.unwrap_or(head.len());
&head[..end]
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolchainPreflight {
pub entries: Vec<PreflightEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreflightEntry {
pub step: String,
pub resolution: PinResolution,
}
impl ToolchainPreflight {
pub fn blocking(&self) -> impl Iterator<Item = &PreflightEntry> {
self.entries.iter().filter(|e| e.resolution.is_blocking())
}
pub fn is_satisfied(&self) -> bool {
self.blocking().next().is_none()
}
pub fn report(&self) -> Vec<String> {
self.entries
.iter()
.map(|e| format!("{} · {}", e.step, e.resolution.line()))
.collect()
}
pub fn error_report(&self) -> Option<String> {
let blocking: Vec<String> = self
.blocking()
.map(|e| format!(" {} · {}", e.step, e.resolution.line()))
.collect();
if blocking.is_empty() {
return None;
}
Some(format!(
"toolchain preflight failed — {} pinned toolchain(s) the host can't satisfy:\n{}",
blocking.len(),
blocking.join("\n"),
))
}
}
fn probe_cmd_version(
cmd: &str,
args: &[&str],
parse: impl Fn(&str) -> Option<String>,
) -> Option<String> {
let output = std::process::Command::new(cmd).args(args).output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse(&stdout)
}
fn parse_rustc_version(stdout: &str) -> Option<String> {
stdout.split_whitespace().nth(1).map(str::to_string)
}
fn parse_xcodebuild_version(stdout: &str) -> Option<String> {
let first = stdout.lines().next()?;
first
.strip_prefix("Xcode")
.map(|r| r.trim().to_string())
.filter(|s| !s.is_empty())
}
fn probe_ndk_version() -> Option<String> {
let root =
std::env::var_os("ANDROID_NDK_HOME").or_else(|| std::env::var_os("ANDROID_NDK_ROOT"))?;
let props = std::path::Path::new(&root).join("source.properties");
let content = std::fs::read_to_string(props).ok()?;
parse_ndk_revision(&content)
}
fn parse_ndk_revision(content: &str) -> Option<String> {
for line in content.lines() {
if let Some((key, val)) = line.split_once('=') {
if key.trim() == "Pkg.Revision" {
let v = val.trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
}
None
}
pub fn detect_host_versions<'a, I>(pins: I) -> HashMap<String, Option<String>>
where
I: IntoIterator<Item = &'a str>,
{
let mut cache: HashMap<Tool, Option<String>> = HashMap::new();
let mut out: HashMap<String, Option<String>> = HashMap::new();
for key in pins {
let Some(tool) = Tool::parse(key) else {
continue;
};
let detected = cache
.entry(tool)
.or_insert_with(|| tool.probe_version())
.clone();
out.insert(key.to_string(), detected);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(pairs: &[(&str, &str)]) -> ToolchainSpec {
ToolchainSpec {
pins: pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
}
}
#[test]
fn toolchain_spec_round_trips_through_toml() {
let src = r#"
rust = "1.84.0"
xcode = "15.4"
ndk = "r27"
"#;
let s: ToolchainSpec = toml::from_str(src).unwrap();
assert_eq!(s.pins.get("rust").map(String::as_str), Some("1.84.0"));
assert_eq!(s.pins.get("xcode").map(String::as_str), Some("15.4"));
let back = toml::to_string(&s).unwrap();
let reparsed: ToolchainSpec = toml::from_str(&back).unwrap();
assert_eq!(reparsed, s);
}
#[test]
fn step_inline_toolchain_table_parses() {
#[derive(serde::Deserialize)]
struct StepLike {
#[serde(default)]
toolchain: Option<ToolchainSpec>,
}
let step: StepLike = toml::from_str(r#"toolchain = { ndk = "r26d" }"#).unwrap();
assert_eq!(
step.toolchain.unwrap().pins.get("ndk").map(String::as_str),
Some("r26d")
);
}
#[test]
fn empty_toolchain_spec_is_empty() {
let s: ToolchainSpec = toml::from_str("").unwrap();
assert!(s.is_empty());
}
#[test]
fn tool_parse_maps_known_aliases() {
assert_eq!(Tool::parse("rust"), Some(Tool::Rust));
assert_eq!(Tool::parse("rustc"), Some(Tool::Rust));
assert_eq!(Tool::parse("Xcode"), Some(Tool::Xcode));
assert_eq!(Tool::parse("ndk"), Some(Tool::Ndk));
assert_eq!(Tool::parse("android-ndk"), Some(Tool::Ndk));
assert_eq!(Tool::parse("msvc"), Some(Tool::Msvc));
assert_eq!(Tool::parse("emsdk"), None);
}
#[test]
fn effective_pins_overlays_step_over_pipeline() {
let pipe = spec(&[("rust", "1.84.0"), ("ndk", "r27")]);
let step = spec(&[("ndk", "r26d")]);
let eff = effective_pins(Some(&pipe), Some(&step));
assert_eq!(eff.get("ndk").map(String::as_str), Some("r26d"));
assert_eq!(eff.get("rust").map(String::as_str), Some("1.84.0"));
}
#[test]
fn effective_pins_handles_missing_either_side() {
let pipe = spec(&[("rust", "1.84.0")]);
assert_eq!(
effective_pins(Some(&pipe), None)
.get("rust")
.map(String::as_str),
Some("1.84.0")
);
let step = spec(&[("xcode", "15.4")]);
assert_eq!(
effective_pins(None, Some(&step))
.get("xcode")
.map(String::as_str),
Some("15.4")
);
assert!(effective_pins(None, None).is_empty());
}
#[test]
fn version_satisfies_segment_prefix() {
assert!(version_satisfies("15.4", "15.4"));
assert!(version_satisfies("15.4", "15.4.1"));
assert!(!version_satisfies("15.4", "15.3"));
assert!(!version_satisfies("15.4", "15"));
assert!(version_satisfies("1.84.0", "1.84.0"));
assert!(!version_satisfies("1.84.0", "1.84"));
}
#[test]
fn version_satisfies_normalizes_leading_nondigits() {
assert!(version_satisfies("r27", "27.0.12077973"));
assert!(!version_satisfies("r27", "26.3.11579264"));
assert!(version_satisfies("r26d", "26.3.11579264"));
}
#[test]
fn resolve_pin_satisfied_when_host_matches() {
let r = resolve_pin("xcode", "15.4", Some("15.4.1"), false);
assert_eq!(
r,
PinResolution::Satisfied {
tool: "xcode".into(),
want: "15.4".into(),
got: "15.4.1".into()
}
);
assert!(!r.is_blocking());
}
#[test]
fn resolve_pin_version_mismatch_blocks() {
let r = resolve_pin("xcode", "15.4", Some("15.2"), false);
assert_eq!(
r,
PinResolution::VersionMismatch {
tool: "xcode".into(),
want: "15.4".into(),
got: "15.2".into()
}
);
assert!(r.is_blocking());
}
#[test]
fn resolve_pin_missing_blocks() {
let r = resolve_pin("xcode", "15.4", None, false);
assert_eq!(
r,
PinResolution::Missing {
tool: "xcode".into(),
want: "15.4".into()
}
);
assert!(r.is_blocking());
}
#[test]
fn resolve_pin_image_satisfies_regardless_of_host() {
let r = resolve_pin("xcode", "15.4", None, true);
assert_eq!(
r,
PinResolution::SatisfiedByImage {
tool: "xcode".into(),
want: "15.4".into()
}
);
assert!(!r.is_blocking());
let r2 = resolve_pin("xcode", "15.4", Some("15.2"), true);
assert!(matches!(r2, PinResolution::SatisfiedByImage { .. }));
}
#[test]
fn resolve_pin_unknown_tool_is_unverifiable_not_blocking() {
let r = resolve_pin("emsdk", "3.1.50", None, false);
assert_eq!(
r,
PinResolution::Unverifiable {
tool: "emsdk".into(),
want: "3.1.50".into()
}
);
assert!(!r.is_blocking());
}
#[test]
fn resolve_pin_is_total_over_the_class_space() {
let tools = ["rust", "xcode", "ndk", "msvc", "emsdk"]; let detected = [None, Some("15.4"), Some("15.2"), Some("27.0.1")];
for t in tools {
for d in detected {
for image in [true, false] {
let r = resolve_pin(t, "15.4", d, image);
if image {
assert!(matches!(r, PinResolution::SatisfiedByImage { .. }));
} else if Tool::parse(t).is_none() {
assert!(matches!(r, PinResolution::Unverifiable { .. }));
}
}
}
}
}
fn entry(step: &str, res: PinResolution) -> PreflightEntry {
PreflightEntry {
step: step.into(),
resolution: res,
}
}
#[test]
fn preflight_is_satisfied_when_nothing_blocks() {
let pf = ToolchainPreflight {
entries: vec![
entry(
"build",
PinResolution::Satisfied {
tool: "rust".into(),
want: "1.84.0".into(),
got: "1.84.0".into(),
},
),
entry(
"sign",
PinResolution::SatisfiedByImage {
tool: "xcode".into(),
want: "15.4".into(),
},
),
entry(
"wasm",
PinResolution::Unverifiable {
tool: "emsdk".into(),
want: "3.1".into(),
},
),
],
};
assert!(pf.is_satisfied());
assert!(pf.error_report().is_none());
assert_eq!(pf.report().len(), 3);
}
#[test]
fn preflight_fails_fast_with_actionable_report() {
let pf = ToolchainPreflight {
entries: vec![
entry(
"build-ios",
PinResolution::Missing {
tool: "xcode".into(),
want: "15.4".into(),
},
),
entry(
"build",
PinResolution::Satisfied {
tool: "rust".into(),
want: "1.84.0".into(),
got: "1.84.0".into(),
},
),
],
};
assert!(!pf.is_satisfied());
let report = pf.error_report().expect("blocking ⇒ report");
assert!(report.contains("build-ios"));
assert!(report.contains("xcode"));
assert!(report.contains("15.4"));
assert!(!report.contains("rust 1.84.0 ✓"));
assert_eq!(pf.blocking().count(), 1);
}
#[test]
fn parse_rustc_version_extracts_the_semver() {
assert_eq!(
parse_rustc_version("rustc 1.84.0 (9fc6b4312 2024-12-04)").as_deref(),
Some("1.84.0")
);
}
#[test]
fn parse_xcodebuild_version_extracts_the_xcode_line() {
assert_eq!(
parse_xcodebuild_version("Xcode 15.4\nBuild version 15F31d").as_deref(),
Some("15.4")
);
}
#[test]
fn parse_ndk_revision_reads_pkg_revision() {
let props = "Pkg.Desc = Android NDK\nPkg.Revision = 27.0.12077973\n";
assert_eq!(parse_ndk_revision(props).as_deref(), Some("27.0.12077973"));
assert_eq!(parse_ndk_revision("nothing here").as_deref(), None);
}
#[test]
fn detect_host_versions_skips_unknown_tools() {
let detected = detect_host_versions(["emsdk", "zig"]);
assert!(detected.is_empty());
}
}