use crate::infra::{CommandExecutor, RealCommandExecutor};
use console::style;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ToolError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Failed to get version for {0}")]
VersionFailed(String),
#[error("Required tool missing: {0}")]
MissingTool(String),
}
#[derive(Debug)]
pub struct Tool<CE: CommandExecutor = RealCommandExecutor> {
pub name: &'static str,
pub binary: &'static str,
pub version_flag: &'static str,
pub required: bool,
cmd_executor: CE,
}
impl<CE: CommandExecutor> Tool<CE> {
pub fn with_executor(
name: &'static str,
binary: &'static str,
version_flag: &'static str,
required: bool,
cmd_executor: CE,
) -> Self {
Self {
name,
binary,
version_flag,
required,
cmd_executor,
}
}
pub fn is_installed(&self) -> bool {
which::which(self.binary).is_ok()
}
pub fn version(&self) -> Result<String, ToolError> {
let output = self
.cmd_executor
.execute(|cmd| cmd.arg(self.version_flag), self.binary)?;
if !output.status.success() {
return Err(ToolError::VersionFailed(self.name.to_string()));
}
let version = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.to_string();
Ok(version)
}
pub fn check(&self) -> ToolStatus {
if self.is_installed() {
match self.version() {
Ok(version) => ToolStatus::Available(version),
Err(_) => ToolStatus::InstalledButVersionUnknown,
}
} else {
ToolStatus::Missing
}
}
}
impl Tool<RealCommandExecutor> {
pub fn new(
name: &'static str,
binary: &'static str,
version_flag: &'static str,
required: bool,
) -> Self {
Self::with_executor(name, binary, version_flag, required, RealCommandExecutor)
}
}
#[derive(Debug)]
pub enum ToolStatus {
Available(String),
InstalledButVersionUnknown,
Missing,
}
pub struct ToolChain<CE: CommandExecutor = RealCommandExecutor> {
pub cargo: Tool<CE>,
pub wasm_bindgen: Tool<CE>,
pub wasm_opt: Tool<CE>,
pub wasm_snip: Tool<CE>,
}
impl Default for ToolChain<RealCommandExecutor> {
fn default() -> Self {
Self::new()
}
}
impl ToolChain<RealCommandExecutor> {
pub fn new() -> Self {
Self::with_executor(RealCommandExecutor)
}
}
impl<CE: CommandExecutor + Clone> ToolChain<CE> {
pub fn with_executor(cmd_executor: CE) -> Self {
Self {
cargo: Tool::with_executor("Cargo", "cargo", "--version", true, cmd_executor.clone()),
wasm_bindgen: Tool::with_executor(
"wasm-bindgen-cli",
"wasm-bindgen",
"--version",
true,
cmd_executor.clone(),
),
wasm_opt: Tool::with_executor(
"wasm-opt (Binaryen)",
"wasm-opt",
"--version",
false,
cmd_executor.clone(),
),
wasm_snip: Tool::with_executor(
"wasm-snip",
"wasm-snip",
"--version",
false,
cmd_executor,
),
}
}
}
impl<CE: CommandExecutor> ToolChain<CE> {
pub fn check_all(&self) -> Result<(), ToolError> {
println!("\n{} Checking build tools...", style("🔧").bold());
let tools = [
&self.cargo,
&self.wasm_bindgen,
&self.wasm_opt,
&self.wasm_snip,
];
let mut missing_required: Vec<&Tool<CE>> = Vec::new();
let mut missing_optional: Vec<&Tool<CE>> = Vec::new();
for tool in &tools {
match tool.check() {
ToolStatus::Available(version) => {
println!(
" {} {} - {}",
style("✓").green(),
style(tool.name).bold(),
style(version).dim()
);
}
ToolStatus::InstalledButVersionUnknown => {
println!(
" {} {} - {}",
style("✓").green(),
style(tool.name).bold(),
style("(version unknown)").dim()
);
}
ToolStatus::Missing => {
if tool.required {
println!(
" {} {} - {}",
style("✗").red(),
style(tool.name).bold(),
style("NOT FOUND").red()
);
missing_required.push(*tool);
} else {
println!(
" {} {} - {} {}",
style("○").yellow(),
style(tool.name).bold(),
style("NOT FOUND").yellow(),
style("(optional)").dim()
);
missing_optional.push(*tool);
}
}
}
}
if !missing_required.is_empty() {
println!("\n{} Missing required tools:", style("❌").bold());
for tool in &missing_required {
println!(" • {}", tool.name);
}
println!("\n{} Installation instructions:", style("💡").bold());
self.print_installation_instructions(&missing_required);
return Err(ToolError::MissingTool(
"Required tools are missing. Please install them and try again.".to_string(),
));
}
if !missing_optional.is_empty() {
println!("\n{} Optional tools not found:", style("ℹ️").bold());
println!(" These tools provide additional optimizations:");
for tool in &missing_optional {
println!(" • {}", tool.name);
}
println!(
"\n{} You can install them for better results:",
style("💡").bold()
);
self.print_installation_instructions(&missing_optional);
}
Ok(())
}
fn print_installation_instructions(&self, tools: &[&Tool<CE>]) {
for tool in tools {
match tool.binary {
"cargo" => {
println!("\n Cargo (Rust toolchain):");
println!(" curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh");
}
"wasm-bindgen" => {
println!("\n wasm-bindgen-cli:");
println!(" cargo install wasm-bindgen-cli");
}
"wasm-opt" => {
println!("\n wasm-opt (Binaryen):");
println!(" # macOS");
println!(" brew install binaryen");
println!(" # Linux (Debian/Ubuntu)");
println!(" sudo apt install binaryen");
println!(
" # Or build from source: https://github.com/WebAssembly/binaryen"
);
}
"wasm-snip" => {
println!("\n wasm-snip:");
println!(" cargo install wasm-snip");
}
_ => {}
}
}
}
pub fn check_required(&self) -> Result<(), ToolError> {
let tools = [&self.cargo, &self.wasm_bindgen];
for tool in &tools {
if tool.version().is_err() {
return Err(ToolError::MissingTool(format!(
"{} is required but not found or not working",
tool.name
)));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infra::mock_exit_status;
use std::io;
use std::process::{Command, Output};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct MockCommandExecutor {
should_succeed: Arc<Mutex<bool>>,
stdout_data: Arc<Mutex<Vec<u8>>>,
should_error: Arc<Mutex<bool>>,
}
impl MockCommandExecutor {
fn new() -> Self {
Self {
should_succeed: Arc::new(Mutex::new(true)),
stdout_data: Arc::new(Mutex::new(b"default version".to_vec())),
should_error: Arc::new(Mutex::new(false)),
}
}
fn set_success(&self, stdout: &str) {
*self.should_succeed.lock().unwrap() = true;
*self.stdout_data.lock().unwrap() = stdout.as_bytes().to_vec();
*self.should_error.lock().unwrap() = false;
}
fn set_failure(&self) {
*self.should_succeed.lock().unwrap() = false;
*self.stdout_data.lock().unwrap() = vec![];
*self.should_error.lock().unwrap() = false;
}
fn set_error(&self) {
*self.should_error.lock().unwrap() = true;
}
}
impl CommandExecutor for MockCommandExecutor {
fn status(&self, _cmd: &mut Command) -> io::Result<std::process::ExitStatus> {
if *self.should_error.lock().unwrap() {
return Err(io::Error::new(io::ErrorKind::NotFound, "command not found"));
}
if *self.should_succeed.lock().unwrap() {
Ok(mock_exit_status(0))
} else {
Ok(mock_exit_status(1))
}
}
fn output(&self, _cmd: &mut Command) -> io::Result<Output> {
if *self.should_error.lock().unwrap() {
return Err(io::Error::new(io::ErrorKind::NotFound, "command not found"));
}
let status = if *self.should_succeed.lock().unwrap() {
mock_exit_status(0)
} else {
mock_exit_status(1)
};
Ok(Output {
status,
stdout: self.stdout_data.lock().unwrap().clone(),
stderr: vec![],
})
}
}
#[test]
fn test_cargo_check_with_installed_tool_returns_true() {
let toolchain = ToolChain::default();
assert!(toolchain.cargo.is_installed());
}
#[test]
fn test_tool_check_with_installed_tool_returns_available_status_mock() {
let mock = MockCommandExecutor::new();
mock.set_success("cargo 1.86.0\n");
let tool = Tool::with_executor("Cargo", "cargo", "--version", true, mock);
let status = tool.check();
match status {
ToolStatus::Available(version) => {
assert!(version.contains("cargo"));
}
_ => panic!("cargo should be available"),
}
}
#[test]
fn test_tool_is_installed_with_nonexistent_binary_returns_false() {
let tool = Tool::new(
"nonexistent-tool-xyz-123",
"nonexistent-tool-xyz-123",
"--version",
false,
);
assert!(!tool.is_installed());
}
#[test]
fn test_tool_version_with_binary_no_version_flag_succeeds_gracefully() {
let tool = Tool::new(
"test-binary",
"true", "--version",
false,
);
assert!(tool.is_installed());
let result = tool.version();
assert!(result.is_ok());
}
#[test]
fn test_tool_check_with_nonexistent_binary_returns_missing_status() {
let tool = Tool::new(
"nonexistent-tool",
"nonexistent-xyz-binary",
"--version",
false,
);
let status = tool.check();
assert!(matches!(status, ToolStatus::Missing));
}
#[test]
fn test_check_required_with_missing_required_tool_returns_error() {
let mut toolchain = ToolChain::default();
toolchain.cargo.binary = "nonexistent-cargo-xyz";
let result = toolchain.check_required();
assert!(result.is_err());
let err = result.unwrap_err();
let err_msg = err.to_string();
assert!(err_msg.contains("required") || err_msg.contains("not found"));
}
#[test]
fn test_check_required_with_all_required_tools_present_succeeds() {
let toolchain = ToolChain::default();
let result = toolchain.check_required();
match result {
Ok(_) => { }
Err(e) => {
let err_msg = e.to_string();
assert!(
err_msg.contains("Cargo") || err_msg.contains("wasm-bindgen"),
"Expected error to mention a required tool, got: {err_msg}"
);
}
}
}
#[test]
fn test_tool_version_with_valid_tool_returns_parseable_version_mock() {
let mock = MockCommandExecutor::new();
mock.set_success("cargo 1.86.0\n");
let tool = Tool::with_executor("Cargo", "cargo", "--version", true, mock);
let version_result = tool.version();
assert!(version_result.is_ok());
let version = version_result.unwrap();
assert!(!version.is_empty());
}
#[test]
fn test_check_all_with_missing_optional_tools_succeeds() {
let mut toolchain = ToolChain::default();
toolchain.wasm_opt.binary = "nonexistent-wasm-opt";
toolchain.wasm_snip.binary = "nonexistent-wasm-snip";
let result = toolchain.check_all();
match result {
Ok(_) => { }
Err(e) => {
let err_msg = e.to_string();
assert!(
err_msg.contains("Required tools") || err_msg.contains("missing"),
"Error should be about required tools, got: {}",
err_msg
);
}
}
}
#[test]
fn test_toolchain_new_creates_with_default_tools() {
let toolchain = ToolChain::new();
assert_eq!(toolchain.cargo.name, "Cargo");
assert_eq!(toolchain.wasm_bindgen.name, "wasm-bindgen-cli");
assert_eq!(toolchain.wasm_opt.name, "wasm-opt (Binaryen)");
assert_eq!(toolchain.wasm_snip.name, "wasm-snip");
}
#[test]
fn test_toolchain_required_tools_are_marked_correctly() {
let toolchain = ToolChain::new();
assert!(toolchain.cargo.required);
assert!(toolchain.wasm_bindgen.required);
assert!(!toolchain.wasm_opt.required);
assert!(!toolchain.wasm_snip.required);
}
#[test]
fn test_tool_version_with_mocked_executor_returns_version() {
let mock = MockCommandExecutor::new();
mock.set_success("cargo 1.75.0 (1d8b05cdd 2024-01-18)\n");
let tool = Tool::with_executor("Cargo", "cargo", "--version", true, mock);
let version = tool.version().unwrap();
assert_eq!(version, "cargo 1.75.0 (1d8b05cdd 2024-01-18)");
}
#[test]
fn test_tool_version_with_failed_command_returns_error() {
let mock = MockCommandExecutor::new();
mock.set_failure();
let tool = Tool::with_executor("TestTool", "test-tool", "--version", false, mock);
let result = tool.version();
assert!(result.is_err());
if let Err(ToolError::VersionFailed(name)) = result {
assert_eq!(name, "TestTool");
} else {
panic!("Expected VersionFailed error");
}
}
#[test]
fn test_tool_version_with_io_error_returns_error() {
let mock = MockCommandExecutor::new();
mock.set_error();
let tool = Tool::with_executor("TestTool", "test-tool", "--version", false, mock);
let result = tool.version();
assert!(result.is_err());
}
#[test]
fn test_tool_version_extracts_first_line_only() {
let mock = MockCommandExecutor::new();
mock.set_success("version 1.0.0\nSecond line\nThird line");
let tool = Tool::with_executor("TestTool", "test-tool", "--version", false, mock);
let version = tool.version().unwrap();
assert_eq!(version, "version 1.0.0");
assert!(!version.contains("Second"));
}
#[test]
fn test_check_all_with_all_tools_available() {
let mock = MockCommandExecutor::new();
mock.set_success("version 1.0.0");
let toolchain = ToolChain::with_executor(mock);
let result = toolchain.check_all();
match result {
Ok(_) => { }
Err(e) => {
assert!(e.to_string().contains("missing") || e.to_string().contains("Required"));
}
}
}
#[test]
fn test_check_all_with_missing_required_returns_error() {
let mock = MockCommandExecutor::new();
mock.set_error();
let mut toolchain = ToolChain::with_executor(mock.clone());
toolchain.cargo.binary = "nonexistent-cargo-binary-xyz";
let result = toolchain.check_all();
assert!(result.is_err());
if let Err(ToolError::MissingTool(msg)) = result {
assert!(msg.contains("Required"));
} else {
panic!("Expected MissingTool error");
}
}
}