use serde::{Deserialize, Serialize};
use crate::nativecross::{select_cross_tool, CrossTool, ToolAvailability};
use crate::platform::{os_tag_of, target_is_msvc};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Shell {
Posix,
Windows,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallCommand {
pub shell: Shell,
pub line: String,
pub elevated: bool,
pub note: Option<String>,
}
impl InstallCommand {
pub fn new(shell: Shell, line: impl Into<String>, elevated: bool) -> InstallCommand {
let line = line.into();
assert!(!line.trim().is_empty(), "install command is empty");
assert!(
!line.contains('\n') && !line.contains('\r'),
"install command must be ONE LINE (W352): {line:?}"
);
assert!(
!line.trim_end().ends_with('`')
&& !line.trim_end().ends_with('\\')
&& !line.trim_end().ends_with('^'),
"install command must not end in a continuation character (W352): {line:?}"
);
InstallCommand {
shell,
line,
elevated,
note: None,
}
}
pub fn with_note(mut self, note: impl Into<String>) -> InstallCommand {
self.note = Some(note.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Requirement {
pub id: String,
pub label: String,
pub present: Option<bool>,
pub probe: Vec<String>,
pub install: Vec<InstallCommand>,
pub install_on: String,
}
impl Requirement {
fn missing(&self) -> bool {
self.present != Some(true)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum Capacity {
Ready,
Missing,
ForeignHost {
needs_os: String,
reason: String,
},
Unsupported {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildCapacity {
pub host: String,
pub target: String,
pub verdict: Capacity,
pub requirements: Vec<Requirement>,
}
impl BuildCapacity {
pub fn missing(&self) -> impl Iterator<Item = &Requirement> {
self.requirements.iter().filter(|r| r.missing())
}
pub fn is_ready(&self) -> bool {
matches!(self.verdict, Capacity::Ready)
}
pub fn report(&self) -> Vec<String> {
let mut out = Vec::new();
let what = if self.target.is_empty() {
"host-native".to_string()
} else {
self.target.clone()
};
out.push(match &self.verdict {
Capacity::Ready => format!("{what} on {} — READY", self.host),
Capacity::Missing => format!(
"{what} on {} — {} requirement(s) missing",
self.host,
self.missing().count()
),
Capacity::ForeignHost { needs_os, reason } => format!(
"{what} on {} — NOT BUILDABLE HERE: {reason}; needs a {needs_os} machine",
self.host
),
Capacity::Unsupported { reason } => {
format!("{what} on {} — UNSUPPORTED: {reason}", self.host)
}
});
for req in &self.requirements {
let mark = match req.present {
Some(true) => "✓",
Some(false) => "✗",
None => "?",
};
out.push(format!(" {mark} {} ({})", req.label, req.id));
if req.present == Some(true) {
continue;
}
for cmd in &req.install {
let elev = if cmd.elevated {
" [needs an elevated shell]"
} else {
""
};
out.push(format!(" $ {}{elev}", cmd.line));
if let Some(note) = &cmd.note {
out.push(format!(" — {note}"));
}
}
if req.install.is_empty() {
out.push(" (nothing installable fixes this here)".to_string());
}
}
out
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostProbe {
pub cross: ToolAvailability,
pub rustup: bool,
pub rustup_targets: Vec<String>,
pub msvc: bool,
pub xcode_clt: bool,
}
impl HostProbe {
pub fn none() -> HostProbe {
HostProbe {
cross: ToolAvailability::NONE,
rustup: false,
rustup_targets: Vec::new(),
msvc: false,
xcode_clt: false,
}
}
pub fn full(targets: &[&str]) -> HostProbe {
HostProbe {
cross: ToolAvailability::FULL,
rustup: true,
rustup_targets: targets.iter().map(|t| t.to_string()).collect(),
msvc: true,
xcode_clt: true,
}
}
pub fn probe() -> HostProbe {
let rustup_targets = rustup_installed_targets();
HostProbe {
cross: ToolAvailability::probe(),
rustup: rustup_targets.is_some(),
rustup_targets: rustup_targets.unwrap_or_default(),
msvc: cfg!(windows) && crate::toolchain::Tool::Msvc.probe_version().is_some(),
xcode_clt: cfg!(target_os = "macos")
&& crate::nativecross::probe_ok(&["xcode-select", "-p"]),
}
}
fn has_target(&self, target: &str) -> bool {
self.rustup_targets.iter().any(|t| t == target)
}
}
fn rustup_installed_targets() -> Option<Vec<String>> {
let out = std::process::Command::new("rustup")
.args(["target", "list", "--installed"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(
String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect(),
)
}
pub fn plan(host: &str, target: &str, probe: &HostProbe) -> BuildCapacity {
let requested = target.trim();
let target = if requested.is_empty() { host } else { requested };
let requested = requested.to_string();
let host_os = os_tag_of(host);
let target_os = os_tag_of(target);
if target_os == "darwin" && host_os != "darwin" {
return BuildCapacity {
host: host.to_string(),
target: requested,
verdict: Capacity::ForeignHost {
needs_os: "darwin".to_string(),
reason: "the macOS SDK and codesign are not redistributable".to_string(),
},
requirements: foreign_requirements("darwin", target),
};
}
if target_os == "windows" && target_is_msvc(target) && host_os != "windows" {
return BuildCapacity {
host: host.to_string(),
target: requested,
verdict: Capacity::ForeignHost {
needs_os: "windows".to_string(),
reason: "nothing cross-compiles to the MSVC ABI".to_string(),
},
requirements: foreign_requirements("windows", target),
};
}
if target_os == "unknown" {
return BuildCapacity {
host: host.to_string(),
target: requested,
verdict: Capacity::Unsupported {
reason: format!("`{target}` names an OS QED has no build path for"),
},
requirements: Vec::new(),
};
}
let mut requirements = Vec::new();
if target_is_msvc(target) {
requirements.push(msvc_requirement(Some(probe.msvc)));
}
if target_os == "darwin" {
requirements.push(xcode_clt_requirement(Some(probe.xcode_clt)));
}
match select_cross_tool(host, Some(target), &probe.cross) {
Ok(CrossTool::CargoNative) => {}
Ok(tool) => requirements.push(cross_requirement(&tool, target, host_os, Some(true))),
Err(err) => {
requirements.push(cross_requirement(&err.preferred, target, host_os, Some(false)))
}
}
requirements.push(rustup_requirement(host_os, Some(probe.rustup)));
if target != host {
requirements.push(rustup_target_requirement(
target,
host_os,
Some(probe.rustup && probe.has_target(target)),
));
}
let verdict = if requirements.iter().any(Requirement::missing) {
Capacity::Missing
} else {
Capacity::Ready
};
BuildCapacity {
host: host.to_string(),
target: requested,
verdict,
requirements,
}
}
pub fn probe_and_plan(host: &str, target: &str) -> BuildCapacity {
plan(host, target, &HostProbe::probe())
}
fn foreign_requirements(needs_os: &str, target: &str) -> Vec<Requirement> {
let mut reqs = Vec::new();
match needs_os {
"windows" => reqs.push(msvc_requirement(None)),
"darwin" => reqs.push(xcode_clt_requirement(None)),
_ => {}
}
reqs.push(rustup_requirement(needs_os, None));
reqs.push(rustup_target_requirement(target, needs_os, None));
reqs
}
fn msvc_requirement(present: Option<bool>) -> Requirement {
Requirement {
id: "msvc-build-tools".to_string(),
label: "Visual Studio 2022 Build Tools (VCTools workload + Windows SDK)".to_string(),
present,
probe: vec![
"vswhere".to_string(),
"-property".to_string(),
"catalog_productDisplayVersion".to_string(),
],
install: vec![InstallCommand::new(
Shell::Windows,
"winget install --id Microsoft.VisualStudio.2022.BuildTools -e \
--accept-package-agreements --accept-source-agreements --override \
\"--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools \
--includeRecommended\"",
true,
)
.with_note(
"3-5 GB download; --includeRecommended is what pulls in the Windows SDK, \
and without it you get a toolset directory with no bin/",
)],
install_on: "windows".to_string(),
}
}
fn xcode_clt_requirement(present: Option<bool>) -> Requirement {
Requirement {
id: "xcode-clt".to_string(),
label: "Xcode command line tools (clang, ld, macOS SDK)".to_string(),
present,
probe: vec!["xcode-select".to_string(), "-p".to_string()],
install: vec![InstallCommand::new(Shell::Posix, "xcode-select --install", false)
.with_note("opens a GUI installer; on a machine with full Xcode already \
installed, `sudo xcode-select -s /Applications/Xcode.app` instead")],
install_on: "darwin".to_string(),
}
}
fn rustup_requirement(install_on: &str, present: Option<bool>) -> Requirement {
let install = match install_on {
"windows" => vec![InstallCommand::new(
Shell::Windows,
"winget install --id Rustlang.Rustup -e --accept-package-agreements \
--accept-source-agreements",
false,
)],
_ => vec![InstallCommand::new(
Shell::Posix,
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh",
false,
)],
};
Requirement {
id: "rustup".to_string(),
label: "rustup (Rust toolchain manager)".to_string(),
present,
probe: vec![
"rustup".to_string(),
"target".to_string(),
"list".to_string(),
"--installed".to_string(),
],
install,
install_on: install_on.to_string(),
}
}
fn rustup_target_requirement(target: &str, install_on: &str, present: Option<bool>) -> Requirement {
let shell = shell_for(install_on);
Requirement {
id: "rustup-target".to_string(),
label: format!("rustup std for {target}"),
present,
probe: vec![
"rustup".to_string(),
"target".to_string(),
"list".to_string(),
"--installed".to_string(),
],
install: vec![InstallCommand::new(
shell,
format!("rustup target add {target}"),
false,
)],
install_on: install_on.to_string(),
}
}
fn cross_requirement(
tool: &CrossTool,
target: &str,
install_on: &str,
present: Option<bool>,
) -> Requirement {
Requirement {
id: match tool {
CrossTool::CargoZigbuild => "cargo-zigbuild",
CrossTool::MuslCross => "musl-cross",
CrossTool::CargoNative => "cargo-native",
}
.to_string(),
label: tool.label().to_string(),
present,
probe: tool.probe_argv(),
install: tool.install_commands(target, shell_for(install_on)),
install_on: install_on.to_string(),
}
}
fn shell_for(os_tag: &str) -> Shell {
match os_tag {
"windows" => Shell::Windows,
_ => Shell::Posix,
}
}
#[cfg(test)]
mod tests {
use super::*;
const ARM_MAC: &str = "aarch64-apple-darwin";
const X64_MAC: &str = "x86_64-apple-darwin";
const X64_LINUX: &str = "x86_64-unknown-linux-gnu";
const X64_MUSL: &str = "x86_64-unknown-linux-musl";
const WIN_MSVC: &str = "x86_64-pc-windows-msvc";
const WIN_GNU: &str = "x86_64-pc-windows-gnu";
#[test]
fn every_install_command_is_one_line() {
let hosts = [ARM_MAC, X64_LINUX, WIN_MSVC];
let targets = ["", ARM_MAC, X64_MAC, X64_LINUX, X64_MUSL, WIN_MSVC, WIN_GNU];
let probes = [HostProbe::none(), HostProbe::full(&[]), HostProbe::full(&targets[1..])];
let mut seen = 0usize;
for host in hosts {
for target in targets {
for probe in &probes {
for req in plan(host, target, probe).requirements {
for cmd in req.install {
seen += 1;
assert!(!cmd.line.contains('\n'), "{:?}", cmd.line);
assert!(!cmd.line.contains('\r'), "{:?}", cmd.line);
assert_eq!(cmd.line.trim(), cmd.line, "untrimmed: {:?}", cmd.line);
let last = cmd.line.chars().last().unwrap();
assert!(
last != '`' && last != '\\' && last != '^',
"continuation character at end of {:?}",
cmd.line
);
}
}
}
}
}
assert!(seen > 20, "decision table barely exercised ({seen} commands)");
}
#[test]
fn windows_msvc_offers_the_exact_winget_commands() {
let cap = plan(WIN_MSVC, WIN_MSVC, &HostProbe::none());
let lines: Vec<&str> = cap
.requirements
.iter()
.flat_map(|r| r.install.iter())
.map(|c| c.line.as_str())
.collect();
assert!(
lines.iter().any(|l| l.contains("Microsoft.VisualStudio.2022.BuildTools")
&& l.contains("--includeRecommended")
&& l.contains("--wait")),
"{lines:#?}"
);
assert!(
lines.iter().any(|l| l.contains("Rustlang.Rustup")),
"{lines:#?}"
);
assert!(lines.iter().all(|l| !l.contains('\n')));
}
#[test]
fn msvc_off_windows_is_a_foreign_host_not_a_missing_tool() {
let cap = plan(ARM_MAC, WIN_MSVC, &HostProbe::full(&[WIN_MSVC]));
match &cap.verdict {
Capacity::ForeignHost { needs_os, .. } => assert_eq!(needs_os, "windows"),
other => panic!("{other:?}"),
}
assert!(cap.requirements.iter().all(|r| r.present.is_none()));
assert!(cap.requirements.iter().any(|r| r.id == "msvc-build-tools"));
assert!(cap.requirements.iter().all(|r| r.install_on == "windows"));
}
#[test]
fn darwin_off_a_mac_is_a_foreign_host_with_no_msvc_row() {
let cap = plan(X64_LINUX, ARM_MAC, &HostProbe::full(&[]));
match &cap.verdict {
Capacity::ForeignHost { needs_os, reason } => {
assert_eq!(needs_os, "darwin");
assert!(reason.contains("redistributable"), "{reason}");
}
other => panic!("{other:?}"),
}
assert!(cap.requirements.iter().all(|r| r.id != "msvc-build-tools"));
}
#[test]
fn windows_gnu_from_a_mac_wants_zigbuild_not_a_windows_box() {
let cap = plan(ARM_MAC, WIN_GNU, &HostProbe::none());
assert!(matches!(cap.verdict, Capacity::Missing), "{:?}", cap.verdict);
let zig = cap
.requirements
.iter()
.find(|r| r.id == "cargo-zigbuild")
.expect("zigbuild row");
assert_eq!(zig.present, Some(false));
assert!(zig
.install
.iter()
.any(|c| c.line.contains("cargo install cargo-zigbuild")));
}
#[test]
fn a_fully_equipped_mac_is_ready_for_a_darwin_slice() {
let cap = plan(ARM_MAC, X64_MAC, &HostProbe::full(&[X64_MAC]));
assert!(cap.is_ready(), "{:#?}", cap);
assert_eq!(cap.missing().count(), 0);
assert!(cap.requirements.iter().all(|r| r.id != "cargo-zigbuild"));
}
#[test]
fn a_missing_rustup_target_is_the_only_gap_when_the_toolchain_is_there() {
let cap = plan(ARM_MAC, X64_MUSL, &HostProbe::full(&[]));
assert!(matches!(cap.verdict, Capacity::Missing));
let missing: Vec<&str> = cap.missing().map(|r| r.id.as_str()).collect();
assert_eq!(missing, vec!["rustup-target"]);
assert_eq!(
cap.missing().next().unwrap().install[0].line,
format!("rustup target add {X64_MUSL}")
);
}
#[test]
fn no_emitted_command_carries_a_placeholder() {
for target in [X64_MUSL, WIN_GNU, X64_MAC] {
for host in [ARM_MAC, X64_LINUX] {
for cmd in plan(host, target, &HostProbe::none())
.requirements
.iter()
.flat_map(|r| r.install.iter())
{
assert!(
!cmd.line.contains("<triple>") && !cmd.line.contains("<arch>"),
"unsubstituted placeholder in {:?}",
cmd.line
);
}
}
}
}
#[test]
fn an_unknown_os_is_unsupported_rather_than_missing() {
let cap = plan(ARM_MAC, "wasm32-unknown-unknown", &HostProbe::full(&[]));
assert!(
matches!(cap.verdict, Capacity::Unsupported { .. }),
"{:?}",
cap.verdict
);
assert!(cap.requirements.is_empty());
}
#[test]
fn report_renders_the_commands_one_per_line() {
let cap = plan(WIN_MSVC, WIN_MSVC, &HostProbe::none());
let report = cap.report();
assert!(report.iter().all(|l| !l.contains('\n')));
assert!(report.iter().any(|l| l.contains("elevated shell")));
assert!(report[0].contains("missing"), "{}", report[0]);
}
#[test]
fn elevation_is_marked_only_where_it_is_needed() {
let cap = plan(WIN_MSVC, WIN_MSVC, &HostProbe::none());
let msvc = cap
.requirements
.iter()
.find(|r| r.id == "msvc-build-tools")
.unwrap();
assert!(msvc.install[0].elevated);
let rustup = cap.requirements.iter().find(|r| r.id == "rustup").unwrap();
assert!(!rustup.install[0].elevated);
}
#[test]
fn host_native_on_a_mac_wants_the_clt_and_rustup_and_nothing_else() {
let cap = plan(ARM_MAC, "", &HostProbe::full(&[]));
assert!(cap.is_ready(), "{:#?}", cap);
let ids: Vec<&str> = cap.requirements.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["xcode-clt", "rustup"]);
assert_eq!(cap.target, "");
assert!(cap.report()[0].contains("host-native"), "{}", cap.report()[0]);
}
#[test]
fn host_native_on_windows_still_requires_msvc() {
for target in ["", WIN_MSVC] {
let cap = plan(WIN_MSVC, target, &HostProbe::none());
assert!(
cap.requirements.iter().any(|r| r.id == "msvc-build-tools"),
"target {target:?} dropped the MSVC row: {:#?}",
cap.requirements
);
assert!(matches!(cap.verdict, Capacity::Missing), "{:?}", cap.verdict);
assert!(cap.requirements.iter().all(|r| r.id != "rustup-target"));
}
}
}