use console::style;
use std::path::PathBuf;
use crate::fmt::{format_bytes, CHECKMARK, HAMMER, SPARKLES};
use crate::infra::{CommandExecutor, FileSystem};
use crate::tools::ToolChain;
use super::config::PipelineConfig;
use super::error::PipelineError;
use super::metrics::SizeMetrics;
use super::result_formatter::ResultFormatter;
use super::tool_runner::ToolRunner;
pub struct BuildOrchestrator<FS: FileSystem, CE: CommandExecutor> {
project_root: PathBuf,
config: PipelineConfig,
toolchain: ToolChain<CE>,
tool_runner: ToolRunner<FS, CE>,
fs: FS,
}
impl<FS: FileSystem + Clone, CE: CommandExecutor + Clone> BuildOrchestrator<FS, CE> {
pub fn new(
project_root: PathBuf,
config: PipelineConfig,
toolchain: ToolChain<CE>,
fs: FS,
cmd_executor: CE,
) -> Self {
let tool_runner = ToolRunner::new(
project_root.clone(),
config.clone(),
fs.clone(),
cmd_executor.clone(),
);
Self {
project_root,
config,
toolchain,
tool_runner,
fs,
}
}
pub fn execute(&self) -> Result<SizeMetrics, PipelineError> {
println!(
"\n{} {} WASM Build Pipeline",
HAMMER,
style("Running").bold()
);
let cargo_toml = self.project_root.join("Cargo.toml");
if self.fs.metadata(&cargo_toml).is_err() {
return Err(PipelineError::FileNotFound(format!(
"Cargo.toml not found in {}",
self.project_root.display()
)));
}
self.toolchain.check_required()?;
println!("\n{} Step 1: Building with cargo...", SPARKLES);
let wasm_file = self.tool_runner.cargo_build()?;
let before_size = self
.fs
.metadata(&wasm_file)
.map_err(PipelineError::Io)?
.len();
println!(
" {} Built: {} ({})",
CHECKMARK,
style(wasm_file.display()).cyan(),
style(format_bytes(before_size)).yellow()
);
println!("\n{} Step 2: Running wasm-bindgen...", SPARKLES);
let bindgen_output = self.tool_runner.run_wasm_bindgen(&wasm_file)?;
println!(" {} wasm-bindgen complete", CHECKMARK);
let mut current_size = self
.fs
.metadata(&bindgen_output)
.map_err(PipelineError::Io)?
.len();
if self.config.run_wasm_opt && self.toolchain.wasm_opt.is_installed() {
println!(
"\n{} Step 3: Running wasm-opt {}...",
SPARKLES,
self.config.opt_level.as_arg()
);
self.tool_runner.run_wasm_opt(&bindgen_output)?;
current_size = self
.fs
.metadata(&bindgen_output)
.map_err(PipelineError::Io)?
.len();
println!(" {} wasm-opt complete", CHECKMARK);
} else if self.config.run_wasm_opt {
println!(
"\n{} Step 3: Skipping wasm-opt (not installed)",
style("ℹ️")
);
}
if self.config.run_wasm_snip && self.toolchain.wasm_snip.is_installed() {
println!("\n{} Step 4: Running wasm-snip...", SPARKLES);
self.tool_runner.run_wasm_snip(&bindgen_output)?;
current_size = self
.fs
.metadata(&bindgen_output)
.map_err(PipelineError::Io)?
.len();
println!(" {} wasm-snip complete", CHECKMARK);
}
let metrics = SizeMetrics {
before_bytes: before_size,
after_bytes: current_size,
};
ResultFormatter::print_summary(&metrics);
Ok(metrics)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infra::{RealCommandExecutor, RealFileSystem};
#[test]
fn test_orchestrator_stores_config() {
let config = PipelineConfig::default();
let orchestrator = BuildOrchestrator::new(
PathBuf::from("/test"),
config.clone(),
ToolChain::default(),
RealFileSystem,
RealCommandExecutor,
);
assert_eq!(orchestrator.config.target.as_str(), config.target.as_str());
}
#[test]
fn test_orchestrator_creates_with_toolchain() {
let config = PipelineConfig::default();
let toolchain = ToolChain::default();
let _ = toolchain.check_all();
let orchestrator = BuildOrchestrator::new(
PathBuf::from("/test"),
config,
toolchain,
RealFileSystem,
RealCommandExecutor,
);
assert_eq!(orchestrator.toolchain.cargo.name, "Cargo");
}
#[test]
fn test_orchestrator_respects_config_flags() {
let config = PipelineConfig {
run_wasm_opt: false,
run_wasm_snip: false,
..Default::default()
};
let orchestrator = BuildOrchestrator::new(
PathBuf::from("/test"),
config.clone(),
ToolChain::default(),
RealFileSystem,
RealCommandExecutor,
);
assert!(!orchestrator.config.run_wasm_opt);
assert!(!orchestrator.config.run_wasm_snip);
}
#[test]
fn test_build_with_toolchain_structure() {
let config = PipelineConfig::default();
let toolchain = ToolChain::default();
let orchestrator = BuildOrchestrator::new(
PathBuf::from("/test"),
config,
toolchain,
RealFileSystem,
RealCommandExecutor,
);
assert_eq!(orchestrator.toolchain.cargo.name, "Cargo");
assert!(orchestrator.toolchain.cargo.required);
}
#[test]
fn test_concurrent_builds() {
let config1 = PipelineConfig::default();
let config2 = PipelineConfig {
run_wasm_opt: false,
..Default::default()
};
let orchestrator1 = BuildOrchestrator::new(
PathBuf::from("/test1"),
config1,
ToolChain::default(),
RealFileSystem,
RealCommandExecutor,
);
let orchestrator2 = BuildOrchestrator::new(
PathBuf::from("/test2"),
config2,
ToolChain::default(),
RealFileSystem,
RealCommandExecutor,
);
assert_ne!(
orchestrator1.config.run_wasm_opt,
orchestrator2.config.run_wasm_opt
);
}
}
#[cfg(test)]
mod integration_tests {
use super::*;
use crate::infra::{mock_exit_status, RealCommandExecutor, RealFileSystem};
use std::io;
use std::path::Path;
use std::process::Command;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct MockFileSystem {
metadata_size: Arc<Mutex<u64>>,
operations: Arc<Mutex<Vec<String>>>,
}
impl MockFileSystem {
fn new(size: u64) -> Self {
Self {
metadata_size: Arc::new(Mutex::new(size)),
operations: Arc::new(Mutex::new(Vec::new())),
}
}
fn operations(&self) -> Vec<String> {
self.operations
.lock()
.expect("MockFileSystem operations lock should never be poisoned in tests")
.clone()
}
}
impl FileSystem for MockFileSystem {
fn metadata(&self, path: &Path) -> io::Result<std::fs::Metadata> {
self.operations
.lock()
.expect("MockFileSystem operations lock should never be poisoned in tests")
.push(format!("metadata: {}", path.display()));
let temp = tempfile::NamedTempFile::new()?;
let size = *self
.metadata_size
.lock()
.expect("MockFileSystem size lock should never be poisoned in tests");
std::fs::write(temp.path(), vec![0u8; size as usize])?;
std::fs::metadata(temp.path())
}
fn read_dir(&self, path: &Path) -> io::Result<std::fs::ReadDir> {
self.operations
.lock()
.expect("MockFileSystem operations lock should never be poisoned in tests")
.push(format!("read_dir: {}", path.display()));
let temp_dir = std::env::temp_dir().join(format!("mock_{}", path.display()));
std::fs::create_dir_all(&temp_dir)?;
std::fs::read_dir(&temp_dir)
}
fn read_to_string(&self, _path: &Path) -> io::Result<String> {
unimplemented!()
}
fn write(&self, _path: &Path, _contents: impl AsRef<[u8]>) -> io::Result<()> {
unimplemented!()
}
fn create_dir_all(&self, _path: &Path) -> io::Result<()> {
unimplemented!()
}
fn copy(&self, _from: &Path, _to: &Path) -> io::Result<u64> {
unimplemented!()
}
}
#[derive(Clone)]
struct MockCommandExecutor {
fail_at_step: Arc<Mutex<Option<String>>>,
operations: Arc<Mutex<Vec<String>>>,
}
impl MockCommandExecutor {
fn new() -> Self {
Self {
fail_at_step: Arc::new(Mutex::new(None)),
operations: Arc::new(Mutex::new(Vec::new())),
}
}
fn set_fail_at_step(&self, step: &str) {
*self
.fail_at_step
.lock()
.expect("MockCommandExecutor lock should never be poisoned in tests") =
Some(step.to_string());
}
fn operations(&self) -> Vec<String> {
self.operations
.lock()
.expect("MockCommandExecutor operations lock should never be poisoned in tests")
.clone()
}
}
impl CommandExecutor for MockCommandExecutor {
fn status(&self, cmd: &mut Command) -> io::Result<std::process::ExitStatus> {
let program = cmd.get_program().to_string_lossy().to_string();
self.operations
.lock()
.expect("MockCommandExecutor operations lock should never be poisoned in tests")
.push(format!("execute: {}", program));
if let Some(ref fail_step) = *self
.fail_at_step
.lock()
.expect("MockCommandExecutor fail_at_step lock should never be poisoned in tests")
{
if program.contains(fail_step) {
return Ok(mock_exit_status(1));
}
}
Ok(mock_exit_status(0))
}
fn output(&self, cmd: &mut Command) -> io::Result<std::process::Output> {
let program = cmd.get_program().to_string_lossy().to_string();
self.operations
.lock()
.expect("MockCommandExecutor operations lock should never be poisoned in tests")
.push(format!("output: {}", program));
if let Some(ref fail_step) = *self
.fail_at_step
.lock()
.expect("MockCommandExecutor fail_at_step lock should never be poisoned in tests")
{
if program.contains(fail_step) {
return Ok(std::process::Output {
status: mock_exit_status(1),
stdout: Vec::new(),
stderr: b"mock failure".to_vec(),
});
}
}
Ok(std::process::Output {
status: mock_exit_status(0),
stdout: b"mock-version 1.0.0\n".to_vec(),
stderr: Vec::new(),
})
}
}
#[test]
fn test_orchestrator_with_failed_cargo_stops_pipeline() {
let config = PipelineConfig::default();
let fs = MockFileSystem::new(1000);
let cmd_executor = MockCommandExecutor::new();
cmd_executor.set_fail_at_step("cargo");
let orchestrator = BuildOrchestrator::new(
PathBuf::from("/test"),
config,
ToolChain::with_executor(cmd_executor.clone()),
fs.clone(),
cmd_executor.clone(),
);
let result = orchestrator.execute();
assert!(result.is_err());
let ops = cmd_executor.operations();
assert!(
ops.iter().any(|op| op.contains("cargo")),
"Expected cargo in operations: {:?}",
ops
);
}
#[test]
fn test_orchestrator_tracks_size_changes_through_pipeline() {
let config = PipelineConfig {
run_wasm_opt: false,
run_wasm_snip: false,
..Default::default()
};
let fs = MockFileSystem::new(1000);
let cmd_executor = MockCommandExecutor::new();
let orchestrator = BuildOrchestrator::new(
PathBuf::from("/test"),
config,
ToolChain::with_executor(cmd_executor.clone()),
fs.clone(),
cmd_executor,
);
let _ = orchestrator.execute();
let ops = fs.operations();
assert!(!ops.is_empty());
}
#[test]
fn test_orchestrator_skips_wasm_opt_when_disabled() {
let config = PipelineConfig {
run_wasm_opt: false,
run_wasm_snip: false,
..Default::default()
};
let fs = MockFileSystem::new(1000);
let cmd_executor = MockCommandExecutor::new();
let orchestrator = BuildOrchestrator::new(
PathBuf::from("/test"),
config,
ToolChain::with_executor(cmd_executor.clone()),
fs,
cmd_executor.clone(),
);
let _ = orchestrator.execute();
let ops = cmd_executor.operations();
assert!(!ops.iter().any(|op| op.contains("wasm-opt")));
}
#[test]
fn test_orchestrator_runs_wasm_opt_when_enabled_and_available() {
let config = PipelineConfig {
run_wasm_opt: true,
run_wasm_snip: false,
..Default::default()
};
let orchestrator = BuildOrchestrator::new(
PathBuf::from("/test"),
config.clone(),
ToolChain::default(),
RealFileSystem,
RealCommandExecutor,
);
assert!(orchestrator.config.run_wasm_opt);
}
#[test]
fn test_orchestrator_skips_wasm_snip_when_disabled() {
let config = PipelineConfig {
run_wasm_opt: false,
run_wasm_snip: false,
..Default::default()
};
let fs = MockFileSystem::new(1000);
let cmd_executor = MockCommandExecutor::new();
let orchestrator = BuildOrchestrator::new(
PathBuf::from("/test"),
config,
ToolChain::with_executor(cmd_executor.clone()),
fs,
cmd_executor.clone(),
);
let _ = orchestrator.execute();
let ops = cmd_executor.operations();
assert!(!ops.iter().any(|op| op.contains("wasm-snip")));
}
#[test]
fn test_orchestrator_runs_wasm_snip_when_enabled_and_available() {
let config = PipelineConfig {
run_wasm_opt: false,
run_wasm_snip: true,
..Default::default()
};
let orchestrator = BuildOrchestrator::new(
PathBuf::from("/test"),
config.clone(),
ToolChain::default(),
RealFileSystem,
RealCommandExecutor,
);
assert!(orchestrator.config.run_wasm_snip);
}
}