use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use colored::Colorize;
use std::fs;
use std::path::{Path, PathBuf};
use splicer::types::ContractResult;
use splicer::{compose, splice, Bundle, ComponentInput, ComposeRequest, SpliceRequest};
const DEFAULT_PKG: &str = "example:composition";
const DEFAULT_OUTPUT_WASM: &str = "composed.wasm";
const DEFAULT_OUTPUT_WAC: &str = "output.wac";
const DEFAULT_SPLITS_DIR: &str = "./splits";
#[derive(Parser, Debug)]
#[command(name = "splicer")]
#[command(
version,
about = "Plan and generate WebAssembly component compositions."
)]
struct Args {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
Splice {
#[arg(value_name = "SPLICE_CFG")]
splice_cfg_file: PathBuf,
#[arg(value_name = "COMP_WASM")]
comp_wasm: PathBuf,
#[arg(short = 'o', long = "output", value_name = "PATH")]
output: Option<PathBuf>,
#[arg(
long = "emit-wac",
value_name = "PATH",
num_args = 0..=1,
default_missing_value = DEFAULT_OUTPUT_WAC,
)]
emit_wac: Option<PathBuf>,
#[arg(long)]
plan: bool,
#[arg(short = 'd', long = "splits-dir", value_name = "DIR")]
splits_dir: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_PKG)]
package: String,
#[arg(long, default_value_t = false)]
skip_type_check: bool,
},
Compose {
#[arg(value_name = "COMP_WASM", num_args = 2..)]
wasms: Vec<String>,
#[arg(short = 'o', long = "output", value_name = "PATH")]
output: Option<PathBuf>,
#[arg(
long = "emit-wac",
value_name = "PATH",
num_args = 0..=1,
default_missing_value = DEFAULT_OUTPUT_WAC,
)]
emit_wac: Option<PathBuf>,
#[arg(long)]
plan: bool,
#[arg(long, default_value = DEFAULT_PKG)]
package: String,
},
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("off")),
)
.with_writer(std::io::stderr)
.init();
match Args::parse().command {
Command::Splice {
splice_cfg_file,
comp_wasm,
output,
emit_wac,
plan,
splits_dir,
package,
skip_type_check,
} => run_splice(
splice_cfg_file,
comp_wasm,
output,
emit_wac,
plan,
splits_dir,
package,
skip_type_check,
),
Command::Compose {
wasms,
output,
emit_wac,
plan,
package,
} => run_compose(wasms, output, emit_wac, plan, package),
}
}
#[allow(clippy::too_many_arguments)]
fn run_splice(
splice_cfg_file: PathBuf,
comp_wasm: PathBuf,
output: Option<PathBuf>,
emit_wac: Option<PathBuf>,
plan: bool,
splits_dir: Option<PathBuf>,
package: String,
skip_type_check: bool,
) -> Result<()> {
let rules_yaml = fs::read_to_string(&splice_cfg_file)
.with_context(|| format!("Failed to read: {}", splice_cfg_file.display()))?;
let needs_persist = plan || emit_wac.is_some() || splits_dir.is_some();
let splits = SplitsLocation::resolve(splits_dir, needs_persist)?;
let bundle = splice(SpliceRequest {
composition_wasm: comp_wasm,
rules_yaml,
package_name: package,
splits_dir: splits.path().to_path_buf(),
skip_type_check,
})?;
print_diagnostics(&bundle.diagnostics);
finish(bundle, output, emit_wac, plan, splits)
}
fn run_compose(
wasms: Vec<String>,
output: Option<PathBuf>,
emit_wac: Option<PathBuf>,
plan: bool,
package: String,
) -> Result<()> {
let components: Vec<ComponentInput> = wasms
.iter()
.map(|entry| {
if let Some((alias, rest)) = entry.split_once('=') {
ComponentInput {
alias: Some(alias.to_string()),
path: PathBuf::from(rest),
}
} else {
ComponentInput {
alias: None,
path: PathBuf::from(entry),
}
}
})
.collect();
let bundle = compose(ComposeRequest {
components,
package_name: package,
})?;
print_diagnostics(&bundle.diagnostics);
finish(bundle, output, emit_wac, plan, SplitsLocation::None)
}
fn finish(
bundle: Bundle,
output: Option<PathBuf>,
emit_wac: Option<PathBuf>,
plan: bool,
splits: SplitsLocation,
) -> Result<()> {
if plan {
let wac_path = emit_wac.unwrap_or_else(|| PathBuf::from(DEFAULT_OUTPUT_WAC));
write_wac(&wac_path, &bundle.wac)?;
let wac_path_str = path_str(&wac_path)?;
splits.persist();
println!("{}", bundle.wac_compose_cmd(wac_path_str));
eprintln!(
"{}",
format!("WAC saved to: {}", wac_path.display()).dimmed()
);
return Ok(());
}
if let Some(ref wac_path) = emit_wac {
write_wac(wac_path, &bundle.wac)?;
}
let composed = match bundle.to_wasm() {
Ok(b) => b,
Err(e) => return Err(handle_compose_failure(e, &bundle, emit_wac, splits)),
};
let output_path = output.unwrap_or_else(|| PathBuf::from(DEFAULT_OUTPUT_WASM));
fs::write(&output_path, &composed)
.with_context(|| format!("Failed to write composed wasm: {}", output_path.display()))?;
Ok(())
}
fn handle_compose_failure(
err: anyhow::Error,
bundle: &Bundle,
emit_wac: Option<PathBuf>,
splits: SplitsLocation,
) -> anyhow::Error {
let wac_path = match emit_wac {
Some(p) => p,
None => match persist_wac_on_failure(&bundle.wac) {
Ok(p) => p,
Err(write_err) => {
return err.context(format!(
"in-process compose failed and WAC could not be preserved: {write_err:#}"
));
}
},
};
let splits_path = splits.persist();
let wac_path_str = match wac_path.to_str() {
Some(s) => s,
None => {
return err.context(format!(
"in-process compose failed; WAC saved at non-UTF-8 path {}",
wac_path.display()
));
}
};
let repro = bundle.wac_compose_cmd(wac_path_str);
let mut msg = format!(
"in-process compose failed.\n\nWAC preserved at: {}",
wac_path.display()
);
if let Some(sp) = splits_path {
msg.push_str(&format!("\nSplits preserved at: {}", sp.display()));
}
msg.push_str("\n\nReproduce standalone with:\n");
msg.push_str(&repro);
err.context(msg)
}
fn persist_wac_on_failure(wac: &str) -> Result<PathBuf> {
let dir = tempfile::Builder::new()
.prefix("splicer-failed-")
.tempdir()
.context("Failed to create tempdir for WAC preservation")?;
let path = dir.keep().join("output.wac");
write_wac(&path, wac)?;
Ok(path)
}
fn write_wac(path: &Path, wac: &str) -> Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create directory for WAC: {}", parent.display())
})?;
}
}
fs::write(path, wac).with_context(|| format!("Failed to write WAC: {}", path.display()))
}
fn path_str(p: &Path) -> Result<&str> {
p.to_str()
.ok_or_else(|| anyhow::anyhow!("path contains non-UTF-8 bytes: {}", p.display()))
}
enum SplitsLocation {
None,
Persistent(PathBuf),
Temp(tempfile::TempDir),
}
impl SplitsLocation {
fn resolve(user_path: Option<PathBuf>, needs_persist: bool) -> Result<Self> {
if let Some(p) = user_path {
return Ok(Self::Persistent(p));
}
if needs_persist {
return Ok(Self::Persistent(PathBuf::from(DEFAULT_SPLITS_DIR)));
}
let dir = tempfile::Builder::new()
.prefix("splicer-splits-")
.tempdir()
.context("Failed to create tempdir for splits")?;
Ok(Self::Temp(dir))
}
fn path(&self) -> &Path {
match self {
Self::None => Path::new(""),
Self::Persistent(p) => p.as_path(),
Self::Temp(d) => d.path(),
}
}
fn persist(self) -> Option<PathBuf> {
match self {
Self::None => None,
Self::Persistent(p) => Some(p),
Self::Temp(d) => Some(d.keep()),
}
}
}
fn print_diagnostics(diagnostics: &[ContractResult]) {
for diag in diagnostics {
match diag {
ContractResult::Ok => {}
ContractResult::Tier1Compatible(_) => unreachable!(
"Tier1Compatible should not surface in the diagnostics list returned by splicer::splice"
),
ContractResult::Tier2Compatible(_) => unreachable!(
"Tier2Compatible should not surface in the diagnostics list returned by splicer::splice"
),
ContractResult::Warn(msg) => {
eprintln!("{}: {}", "WARN".yellow().bold(), msg.yellow())
}
ContractResult::Error(msg) => eprintln!(
"{}: type check skipped — {}",
"WARN".yellow().bold(),
msg.yellow()
),
}
}
}