1use std::path::{Path, PathBuf};
24use std::process::Command;
25
26use sha2::{Digest, Sha256};
27
28use crate::codegen::{
29 CodegenConfig, ManifestBuilder, generate_invariant_module, sanitize_module_name,
30};
31use crate::gherkin::{extract_scenario_meta, preprocess_truths};
32use crate::predicate::parse_steps;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40#[derive(Default)]
41pub enum WasmTarget {
42 #[default]
44 Wasm32UnknownUnknown,
45 Wasm32Wasip1,
47}
48
49impl WasmTarget {
50 fn as_str(self) -> &'static str {
51 match self {
52 Self::Wasm32UnknownUnknown => "wasm32-unknown-unknown",
53 Self::Wasm32Wasip1 => "wasm32-wasip1",
54 }
55 }
56}
57
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61#[derive(Default)]
62pub enum OptLevel {
63 Debug,
65 Release,
67 #[default]
69 Size,
70}
71
72
73#[derive(Debug, Clone)]
75#[derive(Default)]
76pub struct CompileConfig {
77 pub target: WasmTarget,
79 pub opt_level: OptLevel,
81}
82
83
84#[derive(Debug)]
86pub struct CompiledModule {
87 pub wasm_bytes: Vec<u8>,
89 pub manifest_json: String,
91 pub source_hash: String,
93 pub module_name: String,
95}
96
97#[derive(Debug)]
99pub enum CompileError {
100 MissingTarget(String),
102 BuildFailed { stdout: String, stderr: String },
104 WasmNotFound(PathBuf),
106 Io(std::io::Error),
108 GherkinParse(String),
110 ManifestBuild(String),
112 NoScenarios,
114}
115
116impl std::fmt::Display for CompileError {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 match self {
119 Self::MissingTarget(target) => write!(
120 f,
121 "WASM target '{target}' not installed. Run: rustup target add {target}"
122 ),
123 Self::BuildFailed { stderr, .. } => write!(f, "cargo build failed:\n{stderr}"),
124 Self::WasmNotFound(path) => {
125 write!(f, "compiled .wasm not found at: {}", path.display())
126 }
127 Self::Io(e) => write!(f, "IO error: {e}"),
128 Self::GherkinParse(msg) => write!(f, "Gherkin parse error: {msg}"),
129 Self::ManifestBuild(msg) => write!(f, "manifest build error: {msg}"),
130 Self::NoScenarios => write!(f, "no compilable scenarios found in truth file"),
131 }
132 }
133}
134
135impl std::error::Error for CompileError {}
136
137impl From<std::io::Error> for CompileError {
138 fn from(e: std::io::Error) -> Self {
139 Self::Io(e)
140 }
141}
142
143pub struct WasmCompiler;
162
163impl WasmCompiler {
164 pub fn compile(source: &str, config: &CompileConfig) -> Result<Vec<u8>, CompileError> {
174 Self::check_target(config)?;
175
176 let tmp_dir = std::env::temp_dir().join(format!("converge-wasm-{}", std::process::id()));
177 std::fs::create_dir_all(&tmp_dir)?;
178
179 let result = Self::compile_in_dir(source, config, &tmp_dir);
180
181 let _ = std::fs::remove_dir_all(&tmp_dir);
183
184 result
185 }
186
187 pub fn compile_truth_file(path: &Path) -> Result<CompiledModule, CompileError> {
202 let content = std::fs::read_to_string(path)?;
203 let source_hash = content_hash(content.as_bytes());
204
205 let processed = preprocess_truths(&content);
207 let feature = gherkin::Feature::parse(&processed, gherkin::GherkinEnv::default())
208 .map_err(|e| CompileError::GherkinParse(format!("{e}")))?;
209
210 let metas: Vec<_> = feature
212 .scenarios
213 .iter()
214 .map(|s| extract_scenario_meta(&s.name, &s.tags))
215 .collect();
216
217 let compilable_idx = metas
219 .iter()
220 .enumerate()
221 .find(|(_, m)| !m.is_test && m.kind.is_some())
222 .map(|(i, _)| i)
223 .ok_or(CompileError::NoScenarios)?;
224
225 let meta = &metas[compilable_idx];
226 let scenario = &feature.scenarios[compilable_idx];
227
228 let step_tuples = steps_to_tuples(&scenario.steps);
230 let step_refs: Vec<(&str, &str, Vec<Vec<String>>)> = step_tuples
231 .iter()
232 .map(|(kw, text, table)| (kw.as_str(), text.as_str(), table.clone()))
233 .collect();
234
235 let predicates = parse_steps(&step_refs)
236 .map_err(|e| CompileError::GherkinParse(format!("predicate parse: {e}")))?;
237
238 let truth_id = path
240 .file_name()
241 .unwrap_or_default()
242 .to_string_lossy()
243 .to_string();
244 let manifest_json = ManifestBuilder::new()
245 .from_scenario_meta(meta)
246 .from_predicates(&predicates)
247 .with_truth_id(&truth_id)
248 .with_source_hash(&source_hash)
249 .build()
250 .map_err(|e| CompileError::ManifestBuild(e.to_string()))?;
251
252 let module_name = meta
253 .id
254 .clone()
255 .unwrap_or_else(|| sanitize_module_name(&meta.name));
256
257 let codegen_config = CodegenConfig {
259 manifest_json: manifest_json.clone(),
260 module_name: module_name.clone(),
261 };
262 let rust_source = generate_invariant_module(&codegen_config, &predicates);
263
264 let wasm_bytes = Self::compile(&rust_source, &CompileConfig::default())?;
266
267 Ok(CompiledModule {
268 wasm_bytes,
269 manifest_json,
270 source_hash,
271 module_name,
272 })
273 }
274
275 #[must_use]
277 pub fn content_hash_wasm(bytes: &[u8]) -> String {
278 content_hash(bytes)
279 }
280
281 fn compile_in_dir(
282 source: &str,
283 config: &CompileConfig,
284 dir: &Path,
285 ) -> Result<Vec<u8>, CompileError> {
286 let src_dir = dir.join("src");
287 std::fs::create_dir_all(&src_dir)?;
288
289 std::fs::write(dir.join("Cargo.toml"), generate_cargo_toml(config))?;
291
292 std::fs::write(src_dir.join("lib.rs"), source)?;
294
295 let target = config.target.as_str();
297 let mut cmd = Command::new("cargo");
298 cmd.arg("build")
299 .arg("--target")
300 .arg(target)
301 .arg("--lib")
302 .current_dir(dir);
303
304 if config.opt_level != OptLevel::Debug {
305 cmd.arg("--release");
306 }
307
308 let output = cmd.output().map_err(|e| {
309 if e.kind() == std::io::ErrorKind::NotFound {
310 CompileError::MissingTarget("cargo not found in PATH".to_string())
311 } else {
312 CompileError::Io(e)
313 }
314 })?;
315
316 if !output.status.success() {
317 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
318 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
319
320 if stderr.contains("target may not be installed")
321 || stderr.contains("can't find crate for `std`")
322 {
323 return Err(CompileError::MissingTarget(target.to_string()));
324 }
325
326 return Err(CompileError::BuildFailed { stdout, stderr });
327 }
328
329 let profile = if config.opt_level == OptLevel::Debug {
331 "debug"
332 } else {
333 "release"
334 };
335 let wasm_path = dir
336 .join("target")
337 .join(target)
338 .join(profile)
339 .join("converge_wasm_module.wasm");
340
341 if !wasm_path.exists() {
342 return Err(CompileError::WasmNotFound(wasm_path));
343 }
344
345 Ok(std::fs::read(&wasm_path)?)
346 }
347
348 fn check_target(config: &CompileConfig) -> Result<(), CompileError> {
349 let target = config.target.as_str();
350 let output = Command::new("rustup")
351 .args(["target", "list", "--installed"])
352 .output();
353
354 match output {
355 Ok(out) if out.status.success() => {
356 let installed = String::from_utf8_lossy(&out.stdout);
357 if !installed.lines().any(|line| line.trim() == target) {
358 return Err(CompileError::MissingTarget(target.to_string()));
359 }
360 Ok(())
361 }
362 _ => Ok(()),
364 }
365 }
366}
367
368fn generate_cargo_toml(config: &CompileConfig) -> String {
374 let opt_level = match config.opt_level {
375 OptLevel::Debug => "0",
376 OptLevel::Release => "2",
377 OptLevel::Size => "s",
378 };
379
380 format!(
381 r#"[package]
382name = "converge-wasm-module"
383version = "0.1.0"
384edition = "2024"
385rust-version = "1.85"
386
387[lib]
388crate-type = ["cdylib"]
389
390[dependencies]
391serde = {{ version = "1", features = ["derive"] }}
392serde_json = "1"
393
394[profile.release]
395opt-level = "{opt_level}"
396lto = true
397strip = true
398codegen-units = 1
399"#
400 )
401}
402
403fn steps_to_tuples(steps: &[gherkin::Step]) -> Vec<(String, String, Vec<Vec<String>>)> {
414 steps
415 .iter()
416 .map(|step| {
417 let keyword = step.keyword.trim().to_string();
419 let table = step
420 .table
421 .as_ref()
422 .map(|t| {
423 if t.rows.len() > 1 {
425 t.rows[1..].to_vec()
426 } else {
427 Vec::new()
428 }
429 })
430 .unwrap_or_default();
431 (keyword, step.value.clone(), table)
432 })
433 .collect()
434}
435
436pub fn content_hash(bytes: &[u8]) -> String {
438 let hash = Sha256::digest(bytes);
439 format!("sha256:{hash:x}")
440}
441
442#[cfg(test)]
447mod tests {
448 use super::*;
449
450 #[test]
455 fn content_hash_produces_sha256_prefix() {
456 let hash = content_hash(b"hello world");
457 assert!(hash.starts_with("sha256:"));
458 assert_eq!(hash.len(), 7 + 64);
460 }
461
462 #[test]
463 fn content_hash_is_deterministic() {
464 let h1 = content_hash(b"test data");
465 let h2 = content_hash(b"test data");
466 assert_eq!(h1, h2);
467 }
468
469 #[test]
470 fn content_hash_differs_for_different_input() {
471 let h1 = content_hash(b"hello");
472 let h2 = content_hash(b"world");
473 assert_ne!(h1, h2);
474 }
475
476 #[test]
481 fn cargo_toml_includes_required_deps() {
482 let toml = generate_cargo_toml(&CompileConfig::default());
483 assert!(toml.contains("serde"));
484 assert!(toml.contains("serde_json"));
485 assert!(toml.contains("cdylib"));
486 assert!(toml.contains("edition = \"2024\""));
487 }
488
489 #[test]
490 fn cargo_toml_uses_size_opt_for_default() {
491 let toml = generate_cargo_toml(&CompileConfig::default());
492 assert!(toml.contains(r#"opt-level = "s""#));
493 }
494
495 #[test]
496 fn cargo_toml_respects_opt_level() {
497 let release = CompileConfig {
498 opt_level: OptLevel::Release,
499 ..Default::default()
500 };
501 assert!(generate_cargo_toml(&release).contains(r#"opt-level = "2""#));
502
503 let debug = CompileConfig {
504 opt_level: OptLevel::Debug,
505 ..Default::default()
506 };
507 assert!(generate_cargo_toml(&debug).contains(r#"opt-level = "0""#));
508 }
509
510 #[test]
515 fn wasm_target_as_str() {
516 assert_eq!(
517 WasmTarget::Wasm32UnknownUnknown.as_str(),
518 "wasm32-unknown-unknown"
519 );
520 assert_eq!(WasmTarget::Wasm32Wasip1.as_str(), "wasm32-wasip1");
521 }
522
523 #[test]
524 fn default_config() {
525 let config = CompileConfig::default();
526 assert_eq!(config.target, WasmTarget::Wasm32UnknownUnknown);
527 assert_eq!(config.opt_level, OptLevel::Size);
528 }
529
530 #[test]
535 fn compile_error_display_missing_target() {
536 let err = CompileError::MissingTarget("wasm32-unknown-unknown".to_string());
537 let msg = err.to_string();
538 assert!(msg.contains("rustup target add"));
539 assert!(msg.contains("wasm32-unknown-unknown"));
540 }
541
542 #[test]
543 fn compile_error_display_build_failed() {
544 let err = CompileError::BuildFailed {
545 stdout: String::new(),
546 stderr: "error[E0432]: unresolved import".to_string(),
547 };
548 assert!(err.to_string().contains("unresolved import"));
549 }
550
551 #[test]
552 fn compile_error_from_io() {
553 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
554 let err = CompileError::from(io_err);
555 assert!(matches!(err, CompileError::Io(_)));
556 }
557
558 #[test]
567 #[ignore] fn compile_minimal_invariant() {
569 use crate::predicate::Predicate;
570
571 let config = CodegenConfig {
572 manifest_json: r#"{"name":"test","version":"0.1.0","kind":"Invariant","invariant_class":"Structural","dependencies":["Strategies"],"capabilities":["ReadContext"],"requires_human_approval":false}"#.to_string(),
573 module_name: "test_invariant".to_string(),
574 };
575
576 let source = generate_invariant_module(
577 &config,
578 &[Predicate::CountAtLeast {
579 key: "Strategies".to_string(),
580 min: 2,
581 }],
582 );
583
584 let wasm_bytes = WasmCompiler::compile(&source, &CompileConfig::default()).unwrap();
585
586 assert!(wasm_bytes.len() > 8);
588 assert_eq!(&wasm_bytes[0..4], b"\0asm");
589
590 let hash = content_hash(&wasm_bytes);
592 assert!(hash.starts_with("sha256:"));
593 }
594
595 #[test]
596 #[ignore] fn compile_truth_file_end_to_end() {
598 let truth_path = Path::new(env!("CARGO_MANIFEST_DIR"))
599 .join("examples")
600 .join("specs")
601 .join("growth-strategy.truth");
602
603 assert!(truth_path.exists(), "Test truth file not found: {}", truth_path.display());
604
605 let module = WasmCompiler::compile_truth_file(&truth_path).unwrap();
606
607 assert!(module.wasm_bytes.len() > 8);
609 assert_eq!(&module.wasm_bytes[0..4], b"\0asm");
610
611 assert_eq!(module.module_name, "brand_safety");
613 assert!(module.manifest_json.contains("brand_safety"));
614 assert!(module.source_hash.starts_with("sha256:"));
615 }
616
617 #[test]
618 #[ignore] fn compile_invalid_rust_returns_build_error() {
620 let result = WasmCompiler::compile("this is not valid rust", &CompileConfig::default());
621 assert!(result.is_err());
622 assert!(matches!(
623 result.unwrap_err(),
624 CompileError::BuildFailed { .. }
625 ));
626 }
627
628 #[test]
629 fn compile_truth_file_nonexistent_path() {
630 let result = WasmCompiler::compile_truth_file(Path::new("/nonexistent/file.truth"));
631 assert!(result.is_err());
632 assert!(matches!(result.unwrap_err(), CompileError::Io(_)));
633 }
634}