use std::path::{Path, PathBuf};
use std::process::Command;
use sha2::{Digest, Sha256};
use crate::codegen::{
CodegenConfig, ManifestBuilder, generate_invariant_module, sanitize_module_name,
};
use crate::gherkin::extract_scenario_meta;
use crate::predicate::parse_steps;
use crate::truths::parse_truth_document;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WasmTarget {
#[default]
Wasm32UnknownUnknown,
Wasm32Wasip1,
}
impl WasmTarget {
fn as_str(self) -> &'static str {
match self {
Self::Wasm32UnknownUnknown => "wasm32-unknown-unknown",
Self::Wasm32Wasip1 => "wasm32-wasip1",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OptLevel {
Debug,
Release,
#[default]
Size,
}
#[derive(Debug, Clone, Default)]
pub struct CompileConfig {
pub target: WasmTarget,
pub opt_level: OptLevel,
}
#[derive(Debug, Clone)]
pub struct CompiledModule {
pub wasm_bytes: Vec<u8>,
pub manifest_json: String,
pub source_hash: String,
pub module_name: String,
}
#[derive(Debug)]
pub enum CompileError {
MissingTarget(String),
BuildFailed { stdout: String, stderr: String },
WasmNotFound(PathBuf),
Io(std::io::Error),
GherkinParse(String),
ManifestBuild(String),
NoScenarios,
}
impl std::fmt::Display for CompileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingTarget(target) => write!(
f,
"WASM target '{target}' not installed. Run: rustup target add {target}"
),
Self::BuildFailed { stderr, .. } => write!(f, "cargo build failed:\n{stderr}"),
Self::WasmNotFound(path) => {
write!(f, "compiled .wasm not found at: {}", path.display())
}
Self::Io(e) => write!(f, "IO error: {e}"),
Self::GherkinParse(msg) => write!(f, "Gherkin parse error: {msg}"),
Self::ManifestBuild(msg) => write!(f, "manifest build error: {msg}"),
Self::NoScenarios => write!(f, "no compilable scenarios found in truth file"),
}
}
}
impl std::error::Error for CompileError {}
impl From<std::io::Error> for CompileError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct WasmCompiler;
impl WasmCompiler {
pub fn compile(source: &str, config: &CompileConfig) -> Result<Vec<u8>, CompileError> {
Self::check_target(config)?;
let tmp_dir = std::env::temp_dir().join(format!("converge-wasm-{}", std::process::id()));
std::fs::create_dir_all(&tmp_dir)?;
let result = Self::compile_in_dir(source, config, &tmp_dir);
let _ = std::fs::remove_dir_all(&tmp_dir);
result
}
pub fn compile_truth_file(path: &Path) -> Result<CompiledModule, CompileError> {
let content = std::fs::read_to_string(path)?;
let source_hash = content_hash(content.as_bytes());
let document = parse_truth_document(&content)
.map_err(|e| CompileError::GherkinParse(e.to_string()))?;
let feature = gherkin::Feature::parse(&document.gherkin, gherkin::GherkinEnv::default())
.map_err(|e| CompileError::GherkinParse(format!("{e}")))?;
let metas: Vec<_> = feature
.scenarios
.iter()
.map(|s| extract_scenario_meta(&s.name, &s.tags))
.collect();
let compilable_idx = metas
.iter()
.enumerate()
.find(|(_, m)| !m.is_test && m.kind.is_some())
.map(|(i, _)| i)
.ok_or(CompileError::NoScenarios)?;
let meta = &metas[compilable_idx];
let scenario = &feature.scenarios[compilable_idx];
let step_tuples = steps_to_tuples(&scenario.steps);
let step_refs: Vec<(&str, &str, Vec<Vec<String>>)> = step_tuples
.iter()
.map(|(kw, text, table)| (kw.as_str(), text.as_str(), table.clone()))
.collect();
let predicates = parse_steps(&step_refs)
.map_err(|e| CompileError::GherkinParse(format!("predicate parse: {e}")))?;
let truth_id = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let manifest_json = ManifestBuilder::new()
.from_scenario_meta(meta)
.from_predicates(&predicates)
.from_truth_governance(&document.governance)
.with_truth_id(&truth_id)
.with_source_hash(&source_hash)
.build()
.map_err(|e| CompileError::ManifestBuild(e.to_string()))?;
let module_name = meta
.id
.clone()
.unwrap_or_else(|| sanitize_module_name(&meta.name));
let codegen_config = CodegenConfig {
manifest_json: manifest_json.clone(),
module_name: module_name.clone(),
};
let rust_source = generate_invariant_module(&codegen_config, &predicates);
let wasm_bytes = Self::compile(&rust_source, &CompileConfig::default())?;
Ok(CompiledModule {
wasm_bytes,
manifest_json,
source_hash,
module_name,
})
}
#[must_use]
pub fn content_hash_wasm(bytes: &[u8]) -> String {
content_hash(bytes)
}
fn compile_in_dir(
source: &str,
config: &CompileConfig,
dir: &Path,
) -> Result<Vec<u8>, CompileError> {
let src_dir = dir.join("src");
std::fs::create_dir_all(&src_dir)?;
std::fs::write(dir.join("Cargo.toml"), generate_cargo_toml(config))?;
std::fs::write(src_dir.join("lib.rs"), source)?;
let target = config.target.as_str();
let mut cmd = Command::new("cargo");
cmd.arg("build")
.arg("--target")
.arg(target)
.arg("--lib")
.current_dir(dir);
if config.opt_level != OptLevel::Debug {
cmd.arg("--release");
}
let output = cmd.output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
CompileError::MissingTarget("cargo not found in PATH".to_string())
} else {
CompileError::Io(e)
}
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
if stderr.contains("target may not be installed")
|| stderr.contains("can't find crate for `std`")
{
return Err(CompileError::MissingTarget(target.to_string()));
}
return Err(CompileError::BuildFailed { stdout, stderr });
}
let profile = if config.opt_level == OptLevel::Debug {
"debug"
} else {
"release"
};
let wasm_path = dir
.join("target")
.join(target)
.join(profile)
.join("converge_wasm_module.wasm");
if !wasm_path.exists() {
return Err(CompileError::WasmNotFound(wasm_path));
}
Ok(std::fs::read(&wasm_path)?)
}
fn check_target(config: &CompileConfig) -> Result<(), CompileError> {
let target = config.target.as_str();
let output = Command::new("rustup")
.args(["target", "list", "--installed"])
.output();
match output {
Ok(out) if out.status.success() => {
let installed = String::from_utf8_lossy(&out.stdout);
if !installed.lines().any(|line| line.trim() == target) {
return Err(CompileError::MissingTarget(target.to_string()));
}
Ok(())
}
_ => Ok(()),
}
}
}
fn generate_cargo_toml(config: &CompileConfig) -> String {
let opt_level = match config.opt_level {
OptLevel::Debug => "0",
OptLevel::Release => "2",
OptLevel::Size => "s",
};
format!(
r#"[package]
name = "converge-wasm-module"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
[lib]
crate-type = ["cdylib"]
[dependencies]
serde = {{ version = "1", features = ["derive"] }}
serde_json = "1"
[profile.release]
opt-level = "{opt_level}"
lto = true
strip = true
codegen-units = 1
"#
)
}
fn steps_to_tuples(steps: &[gherkin::Step]) -> Vec<(String, String, Vec<Vec<String>>)> {
steps
.iter()
.map(|step| {
let keyword = step.keyword.trim().to_string();
let table = step
.table
.as_ref()
.map(|t| {
if t.rows.len() > 1 {
t.rows[1..].to_vec()
} else {
Vec::new()
}
})
.unwrap_or_default();
(keyword, step.value.clone(), table)
})
.collect()
}
pub fn content_hash(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let hash = Sha256::digest(bytes);
let hash_bytes: &[u8] = hash.as_ref();
let mut encoded = String::with_capacity("sha256:".len() + (hash_bytes.len() * 2));
encoded.push_str("sha256:");
for &byte in hash_bytes {
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn content_hash_produces_sha256_prefix() {
let hash = content_hash(b"hello world");
assert!(hash.starts_with("sha256:"));
assert_eq!(hash.len(), 7 + 64);
}
#[test]
fn content_hash_is_deterministic() {
let h1 = content_hash(b"test data");
let h2 = content_hash(b"test data");
assert_eq!(h1, h2);
}
#[test]
fn content_hash_differs_for_different_input() {
let h1 = content_hash(b"hello");
let h2 = content_hash(b"world");
assert_ne!(h1, h2);
}
#[test]
fn cargo_toml_includes_required_deps() {
let toml = generate_cargo_toml(&CompileConfig::default());
assert!(toml.contains("serde"));
assert!(toml.contains("serde_json"));
assert!(toml.contains("cdylib"));
assert!(toml.contains("edition = \"2024\""));
}
#[test]
fn cargo_toml_uses_size_opt_for_default() {
let toml = generate_cargo_toml(&CompileConfig::default());
assert!(toml.contains(r#"opt-level = "s""#));
}
#[test]
fn cargo_toml_respects_opt_level() {
let release = CompileConfig {
opt_level: OptLevel::Release,
..Default::default()
};
assert!(generate_cargo_toml(&release).contains(r#"opt-level = "2""#));
let debug = CompileConfig {
opt_level: OptLevel::Debug,
..Default::default()
};
assert!(generate_cargo_toml(&debug).contains(r#"opt-level = "0""#));
}
#[test]
fn wasm_target_as_str() {
assert_eq!(
WasmTarget::Wasm32UnknownUnknown.as_str(),
"wasm32-unknown-unknown"
);
assert_eq!(WasmTarget::Wasm32Wasip1.as_str(), "wasm32-wasip1");
}
#[test]
fn default_config() {
let config = CompileConfig::default();
assert_eq!(config.target, WasmTarget::Wasm32UnknownUnknown);
assert_eq!(config.opt_level, OptLevel::Size);
}
#[test]
fn compile_error_display_missing_target() {
let err = CompileError::MissingTarget("wasm32-unknown-unknown".to_string());
let msg = err.to_string();
assert!(msg.contains("rustup target add"));
assert!(msg.contains("wasm32-unknown-unknown"));
}
#[test]
fn compile_error_display_build_failed() {
let err = CompileError::BuildFailed {
stdout: String::new(),
stderr: "error[E0432]: unresolved import".to_string(),
};
assert!(err.to_string().contains("unresolved import"));
}
#[test]
fn compile_error_from_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
let err = CompileError::from(io_err);
assert!(matches!(err, CompileError::Io(_)));
}
#[test]
#[ignore = "requires wasm32-unknown-unknown target"]
fn compile_minimal_invariant() {
use crate::predicate::Predicate;
let config = CodegenConfig {
manifest_json: r#"{"name":"test","version":"0.1.0","kind":"Invariant","invariant_class":"Structural","dependencies":["Strategies"],"capabilities":["ReadContext"],"requires_human_approval":false}"#.to_string(),
module_name: "test_invariant".to_string(),
};
let source = generate_invariant_module(
&config,
&[Predicate::CountAtLeast {
key: "Strategies".to_string(),
min: 2,
}],
);
let wasm_bytes = WasmCompiler::compile(&source, &CompileConfig::default()).unwrap();
assert!(wasm_bytes.len() > 8);
assert_eq!(&wasm_bytes[0..4], b"\0asm");
let hash = content_hash(&wasm_bytes);
assert!(hash.starts_with("sha256:"));
}
#[test]
#[ignore = "requires wasm32-unknown-unknown target"]
fn compile_truth_file_end_to_end() {
let truth_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples")
.join("specs")
.join("growth-strategy.truth");
assert!(
truth_path.exists(),
"Test truth file not found: {}",
truth_path.display()
);
let module = WasmCompiler::compile_truth_file(&truth_path).unwrap();
assert!(module.wasm_bytes.len() > 8);
assert_eq!(&module.wasm_bytes[0..4], b"\0asm");
assert_eq!(module.module_name, "brand_safety");
assert!(module.manifest_json.contains("brand_safety"));
assert!(module.source_hash.starts_with("sha256:"));
}
#[test]
#[ignore = "requires wasm32-unknown-unknown target"]
fn compile_invalid_rust_returns_build_error() {
let result = WasmCompiler::compile("this is not valid rust", &CompileConfig::default());
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
CompileError::BuildFailed { .. }
));
}
#[test]
fn compile_truth_file_nonexistent_path() {
let result = WasmCompiler::compile_truth_file(Path::new("/nonexistent/file.truth"));
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), CompileError::Io(_)));
}
}