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::{
builtin_info, 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,
},
Builtin {
#[arg(value_name = "NAME")]
name: Option<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),
Command::Builtin { name } => run_builtin(name),
}
}
fn run_builtin(name: Option<String>) -> Result<()> {
match name {
None => print_builtin_list(),
Some(n) => print_builtin_details(&n),
}
}
fn print_builtin_list() -> Result<()> {
let entries = builtin_info::list_with_manifests();
let name_width = entries.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
let tier_width = entries
.iter()
.map(|(_, r)| tier_badge(r.as_ref()).len())
.max()
.unwrap_or(0);
for (name, result) in &entries {
let badge = tier_badge(result.as_ref());
let desc = match result {
Ok(Some(m)) => m.builtin.description.clone(),
Ok(None) => "(no embedded manifest)".to_string(),
Err(e) => format!("(manifest unavailable: {e})"),
};
println!(
" {:<nw$} {:<tw$} {}",
name,
badge,
desc,
nw = name_width,
tw = tier_width,
);
}
Ok(())
}
fn tier_badge(result: Result<&Option<builtin_info::Manifest>, &anyhow::Error>) -> String {
match result {
Ok(Some(m)) => format!(
"[tier-{} {}]",
u8::from(m.builtin.tier),
m.builtin.tier.label()
),
_ => "[??]".to_string(),
}
}
fn print_builtin_details(name: &str) -> Result<()> {
let manifest = builtin_info::resolve_manifest(name).with_context(|| {
let known = builtin_info::known_names();
format!(
"could not load manifest for builtin '{name}'. \
Known builtins: [{}]",
known.join(", ")
)
})?;
println!("{}", name.bold().bright_white());
println!(" {}", manifest.builtin.description.italic().white());
println!(
" {}",
format!(
"tier-{} ({})",
u8::from(manifest.builtin.tier),
manifest.builtin.tier.label()
)
.purple()
);
if manifest.keys.is_empty() {
println!();
println!("This builtin accepts no config keys.");
return Ok(());
}
println!();
println!(
"{}",
"Config keys and in-YAML defaults (overridable via `inject.builtin.config:`):\n"
.bold()
.bright_white()
);
for key in &manifest.keys {
for wrapped in wrap_doc(&key.doc, doc_wrap_width()) {
println!(" {}{}", "/// ".dimmed(), wrapped.italic().white().dimmed());
}
if key.case_insensitive {
println!(
" {}{}",
"/// ".dimmed(),
"(matched case-insensitively)".italic().white().dimmed()
);
}
println!(
" {}: {} = {};",
key.name.cyan(),
key.wit_type.yellow(),
key.default_display().green(),
);
println!();
}
Ok(())
}
fn doc_wrap_width() -> usize {
let cols: usize = std::env::var("COLUMNS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(80);
cols.saturating_sub(6).max(40)
}
fn wrap_doc(doc: &str, width: usize) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut first_para = true;
for paragraph in doc.split("\n\n") {
let collapsed: String = paragraph.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.is_empty() {
continue;
}
if !first_para {
out.push(String::new());
}
first_para = false;
let mut line = String::new();
for word in collapsed.split_whitespace() {
if line.is_empty() {
line.push_str(word);
} else if line.len() + 1 + word.len() > width {
out.push(std::mem::take(&mut line));
line.push_str(word);
} else {
line.push(' ');
line.push_str(word);
}
}
if !line.is_empty() {
out.push(line);
}
}
out
}
#[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);
if !plan && !bundle.any_rule_matched {
anyhow::bail!(
"no splice rules applied: every rule failed to match any nodes; see warnings above"
);
}
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()
),
}
}
}