use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use super::{PATH_LOCK, spawn_from_stable_dir};
const CENSUS_DIR_NAME: &str = "toolchain-census";
pub(crate) struct ToolchainGate {
name: &'static str,
binary: &'static str,
version_arg: &'static str,
probe: CapabilityProbe,
require_env: &'static str,
}
#[derive(Clone, Copy)]
enum CapabilityProbe {
VersionOnly,
GoBuild,
SwiftFoundation,
RubyJson,
}
pub(crate) const GO: ToolchainGate = ToolchainGate {
name: "go",
binary: "go",
version_arg: "version",
probe: CapabilityProbe::GoBuild,
require_env: "ALEF_REQUIRE_GO",
};
pub(crate) const SWIFT: ToolchainGate = ToolchainGate {
name: "swift",
binary: "swift",
version_arg: "--version",
probe: CapabilityProbe::SwiftFoundation,
require_env: "ALEF_REQUIRE_SWIFT",
};
pub(crate) const RUBY: ToolchainGate = ToolchainGate {
name: "ruby",
binary: "ruby",
version_arg: "--version",
probe: CapabilityProbe::RubyJson,
require_env: "ALEF_REQUIRE_RUBY",
};
pub(crate) const WASM_BINDGEN: ToolchainGate = ToolchainGate {
name: "wasm-bindgen",
binary: "wasm-bindgen",
version_arg: "--version",
probe: CapabilityProbe::VersionOnly,
require_env: "ALEF_REQUIRE_WASM_BINDGEN",
};
#[derive(Clone, Copy, Default)]
struct Tally {
attempted: u32,
executed: u32,
absent: u32,
unusable: u32,
}
#[derive(Clone)]
enum Resolution {
Available(PathBuf),
Absent,
Unusable(String),
}
static CENSUS: Mutex<Census> = Mutex::new(Census {
tallies: BTreeMap::new(),
resolved: BTreeMap::new(),
});
struct Census {
tallies: BTreeMap<&'static str, Tally>,
resolved: BTreeMap<&'static str, Resolution>,
}
impl ToolchainGate {
pub(crate) fn name(&self) -> &'static str {
self.name
}
pub(crate) fn open(&self) -> Option<PathBuf> {
let resolved = self.resolve();
self.record(&resolved);
self.require_available(resolved, std::env::var_os(self.require_env).is_some())
}
fn require_available(&self, resolved: Resolution, required: bool) -> Option<PathBuf> {
match resolved {
Resolution::Available(path) => Some(path),
Resolution::Absent if !required => None,
Resolution::Unusable(_) if !required => None,
Resolution::Absent => panic!(
"{} is set but `{}` is absent: this fixture compiles alef's generated output \
with the real {} toolchain and verifies nothing without it",
self.require_env, self.binary, self.name
),
Resolution::Unusable(diagnostic) => panic!(
"{} is set and `{}` is present but unusable for the {} fixture:\n{}",
self.require_env, self.binary, self.name, diagnostic
),
}
}
fn resolve(&self) -> Resolution {
if let Some(cached) = lock().resolved.get(self.name) {
return cached.clone();
}
let binary = {
let _path = PATH_LOCK.lock().unwrap_or_else(|error| error.into_inner());
which::which(self.binary)
};
let resolved = match binary {
Ok(path) => match self.probe(&path) {
Ok(()) => Resolution::Available(path),
Err(diagnostic) => Resolution::Unusable(diagnostic),
},
Err(_) => Resolution::Absent,
};
lock().resolved.entry(self.name).or_insert(resolved).clone()
}
fn probe(&self, binary: &Path) -> Result<(), String> {
let version = spawn_from_stable_dir(binary)
.arg(self.version_arg)
.stdin(std::process::Stdio::null())
.output()
.map_err(|error| format!("failed to run `{}`: {error}", binary.display()))?;
if !version.status.success() {
return Err(command_diagnostic(binary, &version));
}
match self.probe {
CapabilityProbe::VersionOnly => Ok(()),
CapabilityProbe::RubyJson => {
let output = spawn_from_stable_dir(binary)
.args(["-e", "require \"json\""])
.output()
.map_err(|error| format!("failed to run `{}`: {error}", binary.display()))?;
output
.status
.success()
.then_some(())
.ok_or_else(|| command_diagnostic(binary, &output))
}
CapabilityProbe::GoBuild => probe_go_build(binary),
CapabilityProbe::SwiftFoundation => probe_swift_foundation(binary),
}
}
fn record(&self, resolution: &Resolution) {
let mut census = lock();
let tally = census.tallies.entry(self.name).or_default();
tally.attempted += 1;
match resolution {
Resolution::Available(_) => tally.executed += 1,
Resolution::Absent => tally.absent += 1,
Resolution::Unusable(_) => tally.unusable += 1,
}
flush(&census.tallies);
}
}
fn lock() -> std::sync::MutexGuard<'static, Census> {
CENSUS.lock().unwrap_or_else(|error| error.into_inner())
}
fn command_diagnostic(binary: &Path, output: &std::process::Output) -> String {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
format!(
"`{}` exited with {}\nstdout:\n{}\nstderr:\n{}",
binary.display(),
output.status,
stdout.trim(),
stderr.trim()
)
}
fn probe_go_build(binary: &Path) -> Result<(), String> {
let temp = tempfile::tempdir().map_err(|error| format!("failed to create Go probe directory: {error}"))?;
let source = temp.path().join("main.go");
let output_binary = temp.path().join(if cfg!(windows) { "probe.exe" } else { "probe" });
std::fs::write(&source, "package main\nfunc main() {}\n")
.map_err(|error| format!("failed to write Go probe source: {error}"))?;
let output = spawn_from_stable_dir(binary)
.args(["build", "-o"])
.arg(&output_binary)
.arg(&source)
.current_dir(temp.path())
.env("GOWORK", "off")
.env("GO111MODULE", "off")
.stdin(std::process::Stdio::null())
.output()
.map_err(|error| format!("failed to run `{}`: {error}", binary.display()))?;
output
.status
.success()
.then_some(())
.ok_or_else(|| command_diagnostic(binary, &output))
}
fn probe_swift_foundation(binary: &Path) -> Result<(), String> {
let temp = tempfile::tempdir().map_err(|error| format!("failed to create Swift probe directory: {error}"))?;
let source = temp.path().join("main.swift");
let output_binary = temp.path().join(if cfg!(windows) { "probe.exe" } else { "probe" });
std::fs::write(&source, "import Foundation\nlet data = Data()\nprint(data.count)\n")
.map_err(|error| format!("failed to write Swift probe source: {error}"))?;
let compiler = binary.with_file_name(if cfg!(windows) { "swiftc.exe" } else { "swiftc" });
let output = spawn_from_stable_dir(&compiler)
.arg(&source)
.arg("-o")
.arg(&output_binary)
.current_dir(temp.path())
.stdin(std::process::Stdio::null())
.output()
.map_err(|error| format!("failed to run `{}`: {error}", compiler.display()))?;
output
.status
.success()
.then_some(())
.ok_or_else(|| command_diagnostic(&compiler, &output))
}
fn flush(tallies: &BTreeMap<&'static str, Tally>) {
let Some(path) = census_file() else { return };
let Some(parent) = path.parent() else { return };
if std::fs::create_dir_all(parent).is_err() {
return;
}
let mut rendered = String::new();
for (name, tally) in tallies {
let _ = writeln!(
rendered,
"{name}\t{}\t{}\t{}\t{}",
tally.attempted, tally.executed, tally.absent, tally.unusable
);
}
let _ = std::fs::write(&path, rendered);
}
fn census_file() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let name = exe.file_stem()?.to_str()?.to_owned();
let target_dir = exe.parent()?.parent()?.parent()?;
Some(target_dir.join(CENSUS_DIR_NAME).join(format!("{name}.tsv")))
}
#[cfg(test)]
mod tests {
use super::*;
static GATE_TESTS: Mutex<()> = Mutex::new(());
fn serialize_gate_tests() -> std::sync::MutexGuard<'static, ()> {
GATE_TESTS.lock().unwrap_or_else(|error| error.into_inner())
}
#[test]
fn census_file_lands_under_the_cargo_target_directory() {
let path = census_file().expect("census path resolves for a cargo-built test binary");
let parent = path.parent().expect("census file has a parent directory");
assert_eq!(
parent.file_name().and_then(|name| name.to_str()),
Some(CENSUS_DIR_NAME),
"census file must sit in the {CENSUS_DIR_NAME} directory, got {path:?}"
);
assert_eq!(
path.extension().and_then(|extension| extension.to_str()),
Some("tsv"),
"census file must be a TSV the census script can sum, got {path:?}"
);
}
#[test]
fn opening_a_gate_records_exactly_one_attempt() {
let _serialized = serialize_gate_tests();
let before = tally_of(GO.name());
let resolved = GO.open();
let after = tally_of(GO.name());
assert_eq!(
after.attempted,
before.attempted + 1,
"opening the go gate must record exactly one attempt"
);
let executed = after.executed - before.executed;
let absent = after.absent - before.absent;
let unusable = after.unusable - before.unusable;
assert_eq!(executed + absent + unusable, 1, "an attempt must land in one outcome");
assert_eq!(
executed,
u32::from(resolved.is_some()),
"only a usable toolchain executes"
);
assert_eq!(
after.attempted,
after.executed + after.absent + after.unusable,
"attempted must equal the sum of every outcome"
);
}
#[test]
fn the_flushed_row_reports_each_resolution_outcome() {
let _serialized = serialize_gate_tests();
let _ = GO.open();
let path = census_file().expect("census path resolves");
let rendered = std::fs::read_to_string(&path).expect("census file was flushed to disk");
let row = rendered
.lines()
.find(|line| line.starts_with(&format!("{}\t", GO.name())))
.unwrap_or_else(|| panic!("no `{}` row in flushed census:\n{rendered}", GO.name()));
let columns: Vec<&str> = row.split('\t').collect();
assert_eq!(
columns.len(),
5,
"a census row is <toolchain>\\t<attempted>\\t<executed>\\t<absent>\\t<unusable>, got {row:?}"
);
let attempted: u32 = columns[1].parse().expect("attempted column is a number");
let executed: u32 = columns[2].parse().expect("executed column is a number");
let absent: u32 = columns[3].parse().expect("absent column is a number");
let unusable: u32 = columns[4].parse().expect("unusable column is a number");
assert!(
attempted > 0,
"the row must record the attempt that just happened: {row:?}"
);
assert_eq!(
attempted,
executed + absent + unusable,
"flushed row does not add up: {row:?}"
);
}
#[test]
fn concurrent_records_flush_the_latest_complete_tally() {
const CONCURRENT: ToolchainGate = ToolchainGate {
name: "concurrent-record-test",
binary: "unused",
version_arg: "unused",
probe: CapabilityProbe::VersionOnly,
require_env: "ALEF_REQUIRE_UNUSED",
};
let before = tally_of(CONCURRENT.name());
let threads: Vec<_> = (0..32)
.map(|index| {
std::thread::spawn(move || {
let resolution = if index % 2 == 0 {
Resolution::Available(PathBuf::from("unused"))
} else {
Resolution::Unusable("unused".to_owned())
};
CONCURRENT.record(&resolution);
})
})
.collect();
for thread in threads {
thread.join().expect("recording thread");
}
let after = tally_of(CONCURRENT.name());
assert_eq!(after.attempted - before.attempted, 32);
let rendered = std::fs::read_to_string(census_file().expect("census path")).expect("read census");
let expected = format!(
"{}\t{}\t{}\t{}\t{}",
CONCURRENT.name(),
after.attempted,
after.executed,
after.absent,
after.unusable
);
assert!(
rendered.lines().any(|line| line == expected),
"flushed census did not retain the latest tally:\n{rendered}"
);
}
#[test]
fn required_mode_fails_when_the_toolchain_is_unavailable() {
let result = std::panic::catch_unwind(|| GO.require_available(Resolution::Absent, true));
let panic = result.expect_err("required mode must fail when the toolchain is unavailable");
let message = panic
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| panic.downcast_ref::<&str>().copied())
.unwrap_or("non-string panic");
assert!(
message.contains("ALEF_REQUIRE_GO is set"),
"the panic must name the variable that made the toolchain required, got: {message}"
);
}
#[test]
fn required_mode_preserves_an_unusable_toolchains_diagnostic() {
let result = std::panic::catch_unwind(|| {
GO.require_available(Resolution::Unusable("compiler exploded".to_owned()), true)
});
let panic = result.expect_err("required mode must fail for an unusable toolchain");
let message = panic
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| panic.downcast_ref::<&str>().copied())
.unwrap_or("non-string panic");
assert!(message.contains("present but unusable"), "unexpected panic: {message}");
assert!(message.contains("compiler exploded"), "unexpected panic: {message}");
}
#[test]
fn unrequired_mode_reports_an_absent_toolchain_as_a_skip() {
assert_eq!(
GO.require_available(Resolution::Absent, false),
None,
"an absent, unrequired toolchain must be reported as not-run rather than panicking"
);
}
#[test]
fn resolve_waits_for_path_lock_before_caching_availability() {
static RUSTC: ToolchainGate = ToolchainGate {
name: "path-lock-regression-rustc",
binary: if cfg!(windows) { "rustc.exe" } else { "rustc" },
version_arg: "--version",
probe: CapabilityProbe::VersionOnly,
require_env: "ALEF_REQUIRE_PATH_LOCK_REGRESSION_RUSTC",
};
let path_lock = PATH_LOCK.lock().unwrap_or_else(|error| error.into_inner());
let expected = which::which(RUSTC.binary).expect("cargo tests require rustc on PATH");
assert!(expected.is_absolute(), "which must resolve rustc to an absolute path");
let (started_tx, started_rx) = std::sync::mpsc::channel();
let (resolved_tx, resolved_rx) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
started_tx.send(()).expect("signal resolve start");
resolved_tx.send(RUSTC.resolve()).expect("send resolution");
});
started_rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("resolve worker started");
match resolved_rx.recv_timeout(std::time::Duration::from_millis(500)) {
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(error) => panic!("resolve worker disconnected while PATH was guarded: {error}"),
Ok(_) => panic!("resolve bypassed the PATH lock"),
}
drop(path_lock);
let resolved = resolved_rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("resolve completed after the PATH lock was released");
worker.join().expect("resolve worker completed");
match resolved {
Resolution::Available(path) => assert_eq!(path, expected),
Resolution::Absent => panic!("rustc remained absent after PATH was restored"),
Resolution::Unusable(diagnostic) => panic!("rustc was unexpectedly unusable: {diagnostic}"),
}
}
#[test]
fn deterministic_capability_controls_accept_real_work_for_every_probed_language() {
let tools = fake_toolchains(false);
GO.probe(&tools.go).expect("Go control performs a build");
SWIFT
.probe(&tools.swift)
.expect("Swift control compiles Foundation source");
RUBY.probe(&tools.ruby).expect("Ruby control loads JSON");
}
#[test]
fn version_only_tools_are_unusable_for_every_capability_probe() {
let tools = fake_toolchains(true);
for (gate, binary) in [(&GO, &tools.go), (&SWIFT, &tools.swift), (&RUBY, &tools.ruby)] {
let Err(diagnostic) = gate.probe(binary) else {
panic!("{} version-only control unexpectedly passed", gate.name());
};
assert!(
diagnostic.contains("deliberately-broken"),
"{} lost the real capability diagnostic: {diagnostic}",
gate.name()
);
}
}
struct FakeToolchains {
_root: tempfile::TempDir,
go: PathBuf,
swift: PathBuf,
ruby: PathBuf,
}
fn fake_toolchains(broken: bool) -> FakeToolchains {
let root = tempfile::tempdir().expect("fake toolchain root");
let directory = root.path().join(if broken { "broken" } else { "working" });
std::fs::create_dir(&directory).expect("create fake toolchain directory");
let source = root.path().join("fake_tool.rs");
let compiled = root
.path()
.join(if cfg!(windows) { "fake_tool.exe" } else { "fake_tool" });
std::fs::write(&source, FAKE_TOOL_SOURCE).expect("write fake tool source");
let rustc = {
let _path = PATH_LOCK.lock().unwrap_or_else(|error| error.into_inner());
which::which("rustc").expect("cargo tests require rustc")
};
let output = std::process::Command::new(rustc)
.arg(&source)
.arg("-o")
.arg(&compiled)
.output()
.expect("compile fake tool");
assert!(output.status.success(), "fake tool compile failed: {output:?}");
let named = |name: &str| {
let path = directory.join(if cfg!(windows) {
format!("{name}.exe")
} else {
name.to_owned()
});
std::fs::copy(&compiled, &path).expect("copy fake tool");
path
};
let go = named("go");
let swift = named("swift");
let _swiftc = named("swiftc");
let ruby = named("ruby");
FakeToolchains {
_root: root,
go,
swift,
ruby,
}
}
const FAKE_TOOL_SOURCE: &str = r#"
use std::path::PathBuf;
fn main() {
let executable = std::env::current_exe().unwrap();
let name = executable.file_stem().unwrap().to_string_lossy();
let broken = executable.parent().unwrap().file_name().unwrap() == "broken";
let args: Vec<String> = std::env::args().skip(1).collect();
if matches!(args.first().map(String::as_str), Some("version" | "--version")) {
return;
}
if broken {
eprintln!("deliberately-broken capability");
std::process::exit(42);
}
let output = if name.starts_with("go") {
assert_eq!(args.first().map(String::as_str), Some("build"));
args.iter().position(|arg| arg == "-o").map(|index| PathBuf::from(&args[index + 1]))
} else if name.starts_with("swiftc") {
let source = std::fs::read_to_string(&args[0]).unwrap();
assert!(source.contains("import Foundation"));
args.iter().position(|arg| arg == "-o").map(|index| PathBuf::from(&args[index + 1]))
} else if name.starts_with("ruby") {
assert_eq!(args, ["-e", "require \"json\""]);
None
} else {
panic!("unexpected fake tool invocation: {name} {args:?}");
};
if let Some(path) = output {
std::fs::write(path, b"probe").unwrap();
}
}
"#;
fn tally_of(name: &'static str) -> Tally {
lock().tallies.get(name).copied().unwrap_or_default()
}
}