use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct ToolchainSpec {
pub target_id: String,
pub display_name: String,
pub binary_name: String,
pub version_args: Vec<String>,
pub compile_command: String,
pub compile_args: Vec<String>,
pub validate_per_project: bool,
pub run_plan: RunPlan,
pub install_hint: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepKind {
Toolchain,
Artifact,
}
#[derive(Debug, Clone)]
pub struct RunStep {
pub command: String,
pub args: Vec<String>,
pub kind: StepKind,
}
impl RunStep {
pub fn new(command: impl Into<String>, args: &[&str]) -> Self {
Self {
command: command.into(),
args: args.iter().map(|a| (*a).to_string()).collect(),
kind: StepKind::Toolchain,
}
}
pub fn artifact(name: impl Into<String>) -> Self {
Self {
command: name.into(),
args: Vec::new(),
kind: StepKind::Artifact,
}
}
}
#[derive(Debug, Clone)]
pub struct RunPlan {
pub steps: Vec<RunStep>,
}
#[derive(Debug, Clone)]
pub struct DetectedToolchain {
pub target_id: String,
pub binary_path: PathBuf,
pub version: Option<String>,
}
#[derive(Debug)]
pub struct CompilationResult {
pub target_id: String,
pub command: String,
pub stdout: String,
pub stderr: String,
pub success: bool,
}
#[derive(Debug, Clone)]
pub struct RunOutput {
pub target_id: String,
pub command: String,
pub stdout: String,
pub stderr: String,
pub exit: Option<i32>,
}
#[derive(Debug)]
pub enum ToolchainError {
NotFound {
target_id: String,
binary_name: String,
install_hint: String,
},
InvocationFailed {
target_id: String,
command: String,
stdout: String,
stderr: String,
exit_code: Option<i32>,
},
Io(std::io::Error),
}
impl fmt::Display for ToolchainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ToolchainError::NotFound {
target_id,
binary_name,
install_hint,
} => {
write!(
f,
"Toolchain not found for target '{target_id}': \
'{binary_name}' is not installed or not on PATH.\n\
To install: {install_hint}"
)
}
ToolchainError::InvocationFailed {
target_id,
command,
stdout,
stderr,
exit_code,
} => {
let diagnostic = if !stderr.is_empty() { stderr } else { stdout };
write!(
f,
"Compilation failed for target '{target_id}'.\n\
Command: {command}\n\
Exit code: {}\n\
output:\n{diagnostic}",
exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".to_string())
)
}
ToolchainError::Io(err) => write!(f, "I/O error during toolchain invocation: {err}"),
}
}
}
impl std::error::Error for ToolchainError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ToolchainError::Io(err) => Some(err),
_ => None,
}
}
}
impl From<std::io::Error> for ToolchainError {
fn from(err: std::io::Error) -> Self {
ToolchainError::Io(err)
}
}
#[derive(Debug)]
pub struct ToolchainRegistry {
specs: HashMap<String, ToolchainSpec>,
}
impl ToolchainRegistry {
#[must_use]
pub fn new() -> Self {
Self {
specs: HashMap::new(),
}
}
#[must_use]
pub fn with_builtins() -> Self {
let mut registry = Self::new();
registry.register(builtin_javascript_spec());
registry.register(builtin_typescript_spec());
registry.register(builtin_python_spec());
registry.register(builtin_rust_spec());
registry.register(builtin_go_spec());
registry
}
pub fn register(&mut self, spec: ToolchainSpec) {
self.specs.insert(spec.target_id.clone(), spec);
}
#[must_use]
pub fn get(&self, target_id: &str) -> Option<&ToolchainSpec> {
self.specs.get(target_id)
}
#[must_use]
pub fn target_ids(&self) -> Vec<&str> {
self.specs.keys().map(|s| s.as_str()).collect()
}
pub fn detect(&self, target_id: &str) -> Result<DetectedToolchain, ToolchainError> {
let spec = self
.specs
.get(target_id)
.ok_or_else(|| ToolchainError::NotFound {
target_id: target_id.to_string(),
binary_name: target_id.to_string(),
install_hint: format!("No toolchain registered for target '{target_id}'"),
})?;
detect_toolchain(spec)
}
#[must_use]
pub fn detect_all(&self) -> ToolchainReport {
let mut found = Vec::new();
let mut missing = Vec::new();
for (target_id, spec) in &self.specs {
match detect_toolchain(spec) {
Ok(detected) => found.push(detected),
Err(err) => missing.push((target_id.clone(), err)),
}
}
ToolchainReport { found, missing }
}
pub fn invoke(
&self,
target_id: &str,
source_path: &Path,
source_only: bool,
) -> Result<CompilationResult, ToolchainError> {
if source_only {
return Ok(CompilationResult {
target_id: target_id.to_string(),
command: "(source-only, compilation skipped)".to_string(),
stdout: String::new(),
stderr: String::new(),
success: true,
});
}
let spec = self
.specs
.get(target_id)
.ok_or_else(|| ToolchainError::NotFound {
target_id: target_id.to_string(),
binary_name: target_id.to_string(),
install_hint: format!("No toolchain registered for target '{target_id}'"),
})?;
detect_toolchain(spec)?;
invoke_compile(spec, source_path)
}
#[must_use]
pub fn validates_per_project(&self, target_id: &str) -> bool {
self.specs
.get(target_id)
.is_some_and(|s| s.validate_per_project)
}
pub fn invoke_project(
&self,
target_id: &str,
project_dir: &Path,
source_only: bool,
) -> Result<CompilationResult, ToolchainError> {
if source_only {
return Ok(CompilationResult {
target_id: target_id.to_string(),
command: "(source-only, compilation skipped)".to_string(),
stdout: String::new(),
stderr: String::new(),
success: true,
});
}
let spec = self
.specs
.get(target_id)
.ok_or_else(|| ToolchainError::NotFound {
target_id: target_id.to_string(),
binary_name: target_id.to_string(),
install_hint: format!("No toolchain registered for target '{target_id}'"),
})?;
detect_toolchain(spec)?;
invoke_compile_in_dir(spec, project_dir)
}
pub fn run(&self, target_id: &str, workdir: &Path) -> Result<RunOutput, ToolchainError> {
let spec = self
.specs
.get(target_id)
.ok_or_else(|| ToolchainError::NotFound {
target_id: target_id.to_string(),
binary_name: target_id.to_string(),
install_hint: format!("No toolchain registered for target '{target_id}'"),
})?;
detect_toolchain(spec)?;
run_program(spec, workdir)
}
}
impl Default for ToolchainRegistry {
fn default() -> Self {
Self::with_builtins()
}
}
#[derive(Debug)]
pub struct ToolchainReport {
pub found: Vec<DetectedToolchain>,
pub missing: Vec<(String, ToolchainError)>,
}
impl ToolchainReport {
#[must_use]
pub fn all_found(&self) -> bool {
self.missing.is_empty()
}
}
fn builtin_javascript_spec() -> ToolchainSpec {
ToolchainSpec {
target_id: "js".to_string(),
display_name: "Node.js".to_string(),
binary_name: "node".to_string(),
version_args: vec!["--version".to_string()],
compile_command: "node".to_string(),
compile_args: vec!["--check".to_string()],
validate_per_project: false,
run_plan: RunPlan {
steps: vec![RunStep::new("node", &["main.js"])],
},
install_hint: "Install Node.js from https://nodejs.org/ or via your package manager \
(e.g., `brew install node`, `apt install nodejs`)"
.to_string(),
}
}
fn builtin_typescript_spec() -> ToolchainSpec {
ToolchainSpec {
target_id: "ts".to_string(),
display_name: "TypeScript compiler".to_string(),
binary_name: "tsc".to_string(),
version_args: vec!["--version".to_string()],
compile_command: "tsc".to_string(),
compile_args: vec!["--noEmit".to_string(), "-p".to_string(), ".".to_string()],
validate_per_project: true,
run_plan: RunPlan {
steps: vec![
RunStep::new("tsc", &["-p", "."]),
RunStep::new("node", &["main.js"]),
],
},
install_hint: "Install TypeScript via npm: `npm install -g typescript`".to_string(),
}
}
fn builtin_python_spec() -> ToolchainSpec {
ToolchainSpec {
target_id: "python".to_string(),
display_name: "Python 3".to_string(),
binary_name: "python3".to_string(),
version_args: vec!["--version".to_string()],
compile_command: "python3".to_string(),
compile_args: vec!["-m".to_string(), "py_compile".to_string()],
validate_per_project: false,
run_plan: RunPlan {
steps: vec![RunStep::new("python3", &["main.py"])],
},
install_hint: "Install Python 3 from https://python.org/ or via your package manager \
(e.g., `brew install python3`, `apt install python3`)"
.to_string(),
}
}
fn builtin_rust_spec() -> ToolchainSpec {
ToolchainSpec {
target_id: "rust".to_string(),
display_name: "Rust toolchain (cargo)".to_string(),
binary_name: "cargo".to_string(),
version_args: vec!["--version".to_string()],
compile_command: "cargo".to_string(),
compile_args: vec!["check".to_string(), "--quiet".to_string()],
validate_per_project: true,
run_plan: RunPlan {
steps: vec![RunStep::new("cargo", &["run", "--quiet"])],
},
install_hint: "Install Rust via rustup: https://rustup.rs/".to_string(),
}
}
fn builtin_go_spec() -> ToolchainSpec {
ToolchainSpec {
target_id: "go".to_string(),
display_name: "Go compiler".to_string(),
binary_name: "go".to_string(),
version_args: vec!["version".to_string()],
compile_command: "go".to_string(),
compile_args: vec!["build".to_string(), "-o".to_string(), os_devnull()],
validate_per_project: true,
run_plan: RunPlan {
steps: vec![RunStep::new("go", &["run", "."])],
},
install_hint: "Install Go from https://go.dev/dl/ or via your package manager \
(e.g., `brew install go`, `apt install golang`)"
.to_string(),
}
}
fn os_devnull() -> String {
if cfg!(windows) { "NUL" } else { "/dev/null" }.to_string()
}
fn detect_toolchain(spec: &ToolchainSpec) -> Result<DetectedToolchain, ToolchainError> {
let mut cmd = Command::new(&spec.binary_name);
for arg in &spec.version_args {
cmd.arg(arg);
}
let output = cmd.output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound
|| e.kind() == std::io::ErrorKind::PermissionDenied
{
ToolchainError::NotFound {
target_id: spec.target_id.clone(),
binary_name: spec.binary_name.clone(),
install_hint: spec.install_hint.clone(),
}
} else {
ToolchainError::Io(e)
}
})?;
let version = if output.status.success() {
let v = String::from_utf8_lossy(&output.stdout).trim().to_string();
if v.is_empty() {
None
} else {
Some(v)
}
} else {
None
};
Ok(DetectedToolchain {
target_id: spec.target_id.clone(),
binary_path: PathBuf::from(&spec.binary_name),
version,
})
}
fn invoke_compile(
spec: &ToolchainSpec,
source_path: &Path,
) -> Result<CompilationResult, ToolchainError> {
let mut cmd = Command::new(&spec.compile_command);
for arg in &spec.compile_args {
cmd.arg(arg);
}
cmd.arg(source_path);
let full_command = format!(
"{} {} {}",
spec.compile_command,
spec.compile_args.join(" "),
source_path.display()
);
let output = cmd.output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound
|| e.kind() == std::io::ErrorKind::PermissionDenied
{
ToolchainError::NotFound {
target_id: spec.target_id.clone(),
binary_name: spec.compile_command.clone(),
install_hint: spec.install_hint.clone(),
}
} else {
ToolchainError::Io(e)
}
})?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let success = output.status.success();
if !success {
return Err(ToolchainError::InvocationFailed {
target_id: spec.target_id.clone(),
command: full_command,
stdout: stdout.clone(),
stderr: stderr.clone(),
exit_code: output.status.code(),
});
}
Ok(CompilationResult {
target_id: spec.target_id.clone(),
command: full_command,
stdout,
stderr,
success,
})
}
fn invoke_compile_in_dir(
spec: &ToolchainSpec,
project_dir: &Path,
) -> Result<CompilationResult, ToolchainError> {
let mut cmd = Command::new(&spec.compile_command);
for arg in &spec.compile_args {
cmd.arg(arg);
}
cmd.current_dir(project_dir);
let full_command = format!(
"{} {} (in {})",
spec.compile_command,
spec.compile_args.join(" "),
project_dir.display()
);
let output = cmd.output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound
|| e.kind() == std::io::ErrorKind::PermissionDenied
{
ToolchainError::NotFound {
target_id: spec.target_id.clone(),
binary_name: spec.compile_command.clone(),
install_hint: spec.install_hint.clone(),
}
} else {
ToolchainError::Io(e)
}
})?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let success = output.status.success();
if !success {
return Err(ToolchainError::InvocationFailed {
target_id: spec.target_id.clone(),
command: full_command,
stdout: stdout.clone(),
stderr: stderr.clone(),
exit_code: output.status.code(),
});
}
Ok(CompilationResult {
target_id: spec.target_id.clone(),
command: full_command,
stdout,
stderr,
success,
})
}
fn run_program(spec: &ToolchainSpec, workdir: &Path) -> Result<RunOutput, ToolchainError> {
let steps = &spec.run_plan.steps;
debug_assert!(!steps.is_empty(), "run plan must have at least one step");
let display = steps
.iter()
.map(|step| {
format!("{} {}", step.command, step.args.join(" "))
.trim()
.to_string()
})
.collect::<Vec<_>>()
.join(" && ");
let last_idx = steps.len().saturating_sub(1);
let mut final_stdout = String::new();
let mut final_stderr = String::new();
let mut final_exit: Option<i32> = None;
for (idx, step) in steps.iter().enumerate() {
let program = match step.kind {
StepKind::Toolchain => PathBuf::from(&step.command),
StepKind::Artifact => {
workdir.join(format!("{}{}", step.command, std::env::consts::EXE_SUFFIX))
}
};
let mut cmd = Command::new(&program);
cmd.args(&step.args);
cmd.current_dir(workdir);
let output = cmd.output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound
|| e.kind() == std::io::ErrorKind::PermissionDenied
{
ToolchainError::NotFound {
target_id: spec.target_id.clone(),
binary_name: step.command.clone(),
install_hint: spec.install_hint.clone(),
}
} else {
ToolchainError::Io(e)
}
})?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if idx != last_idx && !output.status.success() {
return Err(ToolchainError::InvocationFailed {
target_id: spec.target_id.clone(),
command: format!("{} {}", step.command, step.args.join(" "))
.trim()
.to_string(),
stdout,
stderr,
exit_code: output.status.code(),
});
}
final_stdout = stdout;
final_stderr = stderr;
final_exit = output.status.code();
}
Ok(RunOutput {
target_id: spec.target_id.clone(),
command: display,
stdout: final_stdout,
stderr: final_stderr,
exit: final_exit,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_with_builtins_has_all_targets() {
let registry = ToolchainRegistry::with_builtins();
assert!(registry.get("js").is_some());
assert!(registry.get("ts").is_some());
assert!(registry.get("python").is_some());
assert!(registry.get("rust").is_some());
assert!(registry.get("go").is_some());
assert_eq!(registry.target_ids().len(), 5);
}
#[test]
fn registry_default_equals_builtins() {
let registry = ToolchainRegistry::default();
assert_eq!(registry.target_ids().len(), 5);
}
#[test]
fn registry_custom_spec() {
let mut registry = ToolchainRegistry::new();
assert!(registry.get("custom").is_none());
registry.register(ToolchainSpec {
target_id: "custom".to_string(),
display_name: "Custom Lang".to_string(),
binary_name: "customc".to_string(),
version_args: vec!["--version".to_string()],
compile_command: "customc".to_string(),
compile_args: vec!["--check".to_string()],
validate_per_project: false,
run_plan: RunPlan {
steps: vec![RunStep::new("customc", &["main.custom"])],
},
install_hint: "Install custom-lang from example.com".to_string(),
});
assert!(registry.get("custom").is_some());
assert_eq!(registry.get("custom").unwrap().display_name, "Custom Lang");
}
#[test]
fn unknown_target_returns_not_found() {
let registry = ToolchainRegistry::with_builtins();
let result = registry.detect("unknown_target_xyz");
assert!(result.is_err());
match result.unwrap_err() {
ToolchainError::NotFound { target_id, .. } => {
assert_eq!(target_id, "unknown_target_xyz");
}
other => panic!("Expected NotFound, got: {other}"),
}
}
#[test]
fn missing_binary_returns_not_found_error() {
let spec = ToolchainSpec {
target_id: "fake".to_string(),
display_name: "Fake".to_string(),
binary_name: "definitely_not_a_real_binary_xyz_123".to_string(),
version_args: vec!["--version".to_string()],
compile_command: "definitely_not_a_real_binary_xyz_123".to_string(),
compile_args: vec![],
validate_per_project: false,
run_plan: RunPlan {
steps: vec![RunStep::new("definitely_not_a_real_binary_xyz_123", &[])],
},
install_hint: "This is a test".to_string(),
};
let result = detect_toolchain(&spec);
assert!(result.is_err());
match result.unwrap_err() {
ToolchainError::NotFound {
target_id,
binary_name,
install_hint,
} => {
assert_eq!(target_id, "fake");
assert_eq!(binary_name, "definitely_not_a_real_binary_xyz_123");
assert_eq!(install_hint, "This is a test");
}
other => panic!("Expected NotFound, got: {other}"),
}
}
#[test]
fn not_found_error_display_includes_install_hint() {
let err = ToolchainError::NotFound {
target_id: "rust".to_string(),
binary_name: "rustc".to_string(),
install_hint: "Install via rustup".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("rust"));
assert!(msg.contains("rustc"));
assert!(msg.contains("Install via rustup"));
}
#[test]
fn invocation_failed_error_display() {
let err = ToolchainError::InvocationFailed {
target_id: "js".to_string(),
command: "node --check test.js".to_string(),
stdout: String::new(),
stderr: "SyntaxError: unexpected token".to_string(),
exit_code: Some(1),
};
let msg = err.to_string();
assert!(msg.contains("js"));
assert!(msg.contains("node --check test.js"));
assert!(msg.contains("SyntaxError"));
assert!(msg.contains("1"));
}
#[test]
fn invocation_failed_prefers_stderr_over_stdout() {
let err = ToolchainError::InvocationFailed {
target_id: "rust".to_string(),
command: "rustc test.rs".to_string(),
stdout: "ignored stdout".to_string(),
stderr: "real error on stderr".to_string(),
exit_code: Some(1),
};
let msg = err.to_string();
assert!(msg.contains("real error on stderr"));
assert!(!msg.contains("ignored stdout"));
}
#[test]
fn invocation_failed_falls_back_to_stdout() {
let err = ToolchainError::InvocationFailed {
target_id: "ts".to_string(),
command: "tsc --noEmit test.ts".to_string(),
stdout: "test.ts(1,1): error TS2304: Cannot find name 'x'.".to_string(),
stderr: String::new(),
exit_code: Some(2),
};
let msg = err.to_string();
assert!(msg.contains("error TS2304"));
assert!(msg.contains("Cannot find name"));
}
#[test]
fn source_only_skips_compilation() {
let registry = ToolchainRegistry::with_builtins();
let result = registry
.invoke("js", Path::new("test.js"), true)
.expect("source_only should always succeed");
assert!(result.success);
assert!(result.command.contains("source-only"));
assert_eq!(result.target_id, "js");
}
#[test]
fn source_only_works_for_any_target() {
let registry = ToolchainRegistry::with_builtins();
for target in &["js", "ts", "python", "rust", "go"] {
let result = registry
.invoke(target, Path::new("test.src"), true)
.expect("source_only should succeed for all targets");
assert!(result.success);
assert_eq!(result.target_id, *target);
}
}
#[test]
fn invoke_unknown_target_returns_error() {
let registry = ToolchainRegistry::with_builtins();
let result = registry.invoke("unknown_xyz", Path::new("test.src"), false);
assert!(result.is_err());
}
#[test]
fn builtin_specs_have_correct_binaries() {
let js = builtin_javascript_spec();
assert_eq!(js.binary_name, "node");
assert_eq!(js.compile_command, "node");
let ts = builtin_typescript_spec();
assert_eq!(ts.binary_name, "tsc");
let py = builtin_python_spec();
assert_eq!(py.binary_name, "python3");
let rs = builtin_rust_spec();
assert_eq!(rs.binary_name, "cargo");
assert!(rs.compile_args.contains(&"check".to_string()));
assert!(rs.validate_per_project);
let go = builtin_go_spec();
assert_eq!(go.binary_name, "go");
assert!(go.compile_args.contains(&"build".to_string()));
assert!(go.validate_per_project);
}
#[test]
fn detect_all_returns_report() {
let registry = ToolchainRegistry::with_builtins();
let report = registry.detect_all();
assert_eq!(report.found.len() + report.missing.len(), 5);
}
#[test]
fn toolchain_report_all_found() {
let registry = ToolchainRegistry::new();
let report = registry.detect_all();
assert!(report.all_found());
}
#[test]
fn detect_missing_binary_via_registry() {
let mut registry = ToolchainRegistry::new();
registry.register(ToolchainSpec {
target_id: "fake".to_string(),
display_name: "Fake".to_string(),
binary_name: "not_a_real_binary_abc_999".to_string(),
version_args: vec!["--version".to_string()],
compile_command: "not_a_real_binary_abc_999".to_string(),
compile_args: vec![],
validate_per_project: false,
run_plan: RunPlan {
steps: vec![RunStep::new("not_a_real_binary_abc_999", &[])],
},
install_hint: "Cannot install fake toolchain".to_string(),
});
let report = registry.detect_all();
assert!(!report.all_found());
assert_eq!(report.missing.len(), 1);
assert_eq!(report.missing[0].0, "fake");
}
#[test]
fn invoke_with_missing_toolchain_gives_clear_error() {
let mut registry = ToolchainRegistry::new();
registry.register(ToolchainSpec {
target_id: "fake".to_string(),
display_name: "Fake Lang".to_string(),
binary_name: "not_a_real_binary_zzz".to_string(),
version_args: vec!["--version".to_string()],
compile_command: "not_a_real_binary_zzz".to_string(),
compile_args: vec!["--check".to_string()],
validate_per_project: false,
run_plan: RunPlan {
steps: vec![RunStep::new("not_a_real_binary_zzz", &[])],
},
install_hint: "Install from example.com".to_string(),
});
let result = registry.invoke("fake", Path::new("test.src"), false);
assert!(result.is_err());
let err = result.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not installed"));
assert!(msg.contains("Install from example.com"));
}
#[test]
fn builtin_specs_have_run_plans() {
let registry = ToolchainRegistry::with_builtins();
for target in &["js", "ts", "python", "rust", "go"] {
let spec = registry.get(target).expect("builtin spec present");
assert!(
!spec.run_plan.steps.is_empty(),
"{target} run plan must be non-empty"
);
}
}
#[test]
fn rust_run_plan_is_cargo_run() {
let spec = builtin_rust_spec();
assert_eq!(spec.binary_name, "cargo");
assert_eq!(spec.run_plan.steps.len(), 1, "rust runs via `cargo run`");
assert_eq!(spec.run_plan.steps[0].command, "cargo");
assert_eq!(spec.run_plan.steps[0].kind, StepKind::Toolchain);
assert!(spec.run_plan.steps[0].args.contains(&"run".to_string()));
}
#[test]
fn go_run_plan_is_go_run_dir() {
let spec = builtin_go_spec();
assert_eq!(spec.run_plan.steps.len(), 1, "go runs via `go run .`");
assert_eq!(spec.run_plan.steps[0].command, "go");
assert_eq!(
spec.run_plan.steps[0].args,
vec!["run".to_string(), ".".to_string()]
);
}
#[test]
fn ts_run_plan_is_tsc_project_then_node() {
let spec = builtin_typescript_spec();
assert_eq!(spec.run_plan.steps.len(), 2, "ts emits then runs");
assert_eq!(spec.run_plan.steps[0].command, "tsc");
assert_eq!(
spec.run_plan.steps[0].args,
vec!["-p".to_string(), ".".to_string()]
);
assert_eq!(spec.run_plan.steps[1].command, "node");
assert_eq!(spec.run_plan.steps[1].args, vec!["main.js".to_string()]);
}
#[test]
fn single_step_targets_run_one_command() {
for spec in [
builtin_javascript_spec(),
builtin_python_spec(),
builtin_rust_spec(),
builtin_go_spec(),
] {
assert_eq!(
spec.run_plan.steps.len(),
1,
"{} should be a single run step",
spec.target_id
);
}
}
#[test]
fn run_unknown_target_returns_not_found() {
let registry = ToolchainRegistry::with_builtins();
let result = registry.run("unknown_xyz", Path::new("."));
assert!(result.is_err());
match result.unwrap_err() {
ToolchainError::NotFound { target_id, .. } => {
assert_eq!(target_id, "unknown_xyz");
}
other => panic!("Expected NotFound, got: {other}"),
}
}
#[test]
fn run_with_missing_toolchain_gives_not_found() {
let mut registry = ToolchainRegistry::new();
registry.register(ToolchainSpec {
target_id: "fake".to_string(),
display_name: "Fake Lang".to_string(),
binary_name: "not_a_real_binary_run_xyz".to_string(),
version_args: vec!["--version".to_string()],
compile_command: "not_a_real_binary_run_xyz".to_string(),
compile_args: vec![],
validate_per_project: false,
run_plan: RunPlan {
steps: vec![RunStep::new("not_a_real_binary_run_xyz", &[])],
},
install_hint: "Install from example.com".to_string(),
});
let result = registry.run("fake", Path::new("."));
assert!(matches!(result, Err(ToolchainError::NotFound { .. })));
}
#[test]
fn run_captures_stdout_of_final_step() {
if Command::new("printf").arg("").output().is_err() {
return;
}
let mut registry = ToolchainRegistry::new();
registry.register(ToolchainSpec {
target_id: "printer".to_string(),
display_name: "Printer".to_string(),
binary_name: "printf".to_string(),
version_args: vec!["%s".to_string(), "probe".to_string()],
compile_command: "printf".to_string(),
compile_args: vec![],
validate_per_project: false,
run_plan: RunPlan {
steps: vec![RunStep::new("printf", &["%s", "captured-output"])],
},
install_hint: "coreutils".to_string(),
});
let out = registry.run("printer", Path::new(".")).expect("run ok");
assert_eq!(out.target_id, "printer");
assert_eq!(out.stdout.trim(), "captured-output");
assert_eq!(out.exit, Some(0));
}
#[test]
fn run_reports_failing_compile_step() {
if Command::new("false").output().is_err() {
return;
}
let mut registry = ToolchainRegistry::new();
registry.register(ToolchainSpec {
target_id: "twostep".to_string(),
display_name: "Two Step".to_string(),
binary_name: "false".to_string(),
version_args: vec![],
compile_command: "false".to_string(),
compile_args: vec![],
validate_per_project: false,
run_plan: RunPlan {
steps: vec![
RunStep::new("false", &[]),
RunStep::new("echo", &["unreached"]),
],
},
install_hint: "coreutils".to_string(),
});
let result = registry.run("twostep", Path::new("."));
match result {
Err(ToolchainError::InvocationFailed { target_id, .. }) => {
assert_eq!(target_id, "twostep");
}
other => panic!("expected InvocationFailed, got {other:?}"),
}
}
}