Skip to main content

converge_tool/
compile.rs

1// Copyright 2024-2026 Aprio One AB, Sweden
2// Author: Kenneth Pernyer, kenneth@aprio.one
3// SPDX-License-Identifier: LicenseRef-Proprietary
4// All rights reserved. This source code is proprietary and confidential.
5// Unauthorized copying, modification, or distribution is strictly prohibited.
6
7//! WASM compilation pipeline for Converge truth files.
8//!
9//! Compiles generated Rust source code to `.wasm` binaries by creating a
10//! temporary Cargo project and invoking `cargo build --target wasm32-unknown-unknown`.
11//!
12//! # Pipeline
13//!
14//! ```text
15//! .truth file → parse → predicates → Rust source → cargo build → .wasm bytes
16//! ```
17//!
18//! # Requirements
19//!
20//! - Rust toolchain with `cargo` in PATH
21//! - `wasm32-unknown-unknown` target: `rustup target add wasm32-unknown-unknown`
22
23use 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// ============================================================================
35// Types
36// ============================================================================
37
38/// WASM compilation target triple.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40#[derive(Default)]
41pub enum WasmTarget {
42    /// Standard WASM target without WASI (default for Converge modules).
43    #[default]
44    Wasm32UnknownUnknown,
45    /// WASI target for modules needing system interface.
46    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/// Optimization level for WASM compilation.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61#[derive(Default)]
62pub enum OptLevel {
63    /// No optimization (debug build).
64    Debug,
65    /// Full optimization.
66    Release,
67    /// Optimize for binary size (default for WASM modules).
68    #[default]
69    Size,
70}
71
72
73/// Configuration for WASM compilation.
74#[derive(Debug, Clone)]
75#[derive(Default)]
76pub struct CompileConfig {
77    /// Target triple.
78    pub target: WasmTarget,
79    /// Optimization level.
80    pub opt_level: OptLevel,
81}
82
83
84/// Result of compiling a `.truth` file to WASM.
85#[derive(Debug)]
86pub struct CompiledModule {
87    /// Raw `.wasm` bytes.
88    pub wasm_bytes: Vec<u8>,
89    /// Manifest JSON embedded in the module.
90    pub manifest_json: String,
91    /// SHA-256 hash of the source `.truth` file content.
92    pub source_hash: String,
93    /// Module name derived from scenario tags or sanitized scenario name.
94    pub module_name: String,
95}
96
97/// Error during WASM compilation.
98#[derive(Debug)]
99pub enum CompileError {
100    /// WASM target not installed.
101    MissingTarget(String),
102    /// `cargo build` failed.
103    BuildFailed { stdout: String, stderr: String },
104    /// Compiled `.wasm` file not found in target directory.
105    WasmNotFound(PathBuf),
106    /// IO error during file operations.
107    Io(std::io::Error),
108    /// Gherkin parsing or predicate extraction error.
109    GherkinParse(String),
110    /// Manifest building error.
111    ManifestBuild(String),
112    /// No compilable scenarios found in truth file.
113    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
143// ============================================================================
144// Compiler
145// ============================================================================
146
147/// WASM module compiler.
148///
149/// Compiles generated Rust source code to `.wasm` binaries by creating a
150/// temporary Cargo project and invoking `cargo build`.
151///
152/// # Examples
153///
154/// ```ignore
155/// use converge_tool::compile::{WasmCompiler, CompileConfig};
156///
157/// let source = "/* generated Rust source */";
158/// let wasm_bytes = WasmCompiler::compile(source, &CompileConfig::default())?;
159/// assert_eq!(&wasm_bytes[0..4], b"\0asm");
160/// ```
161pub struct WasmCompiler;
162
163impl WasmCompiler {
164    /// Compile Rust source code to WASM bytes.
165    ///
166    /// Creates a temporary Cargo project, writes the source as `lib.rs`,
167    /// and runs `cargo build --target <target> --release`.
168    ///
169    /// # Errors
170    ///
171    /// Returns `CompileError::MissingTarget` if the WASM target is not installed.
172    /// Returns `CompileError::BuildFailed` if cargo compilation fails.
173    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        // Clean up temp directory
182        let _ = std::fs::remove_dir_all(&tmp_dir);
183
184        result
185    }
186
187    /// Compile a `.truth` file end-to-end: parse → codegen → compile → hash.
188    ///
189    /// Reads the truth file, parses Gherkin scenarios, extracts predicates,
190    /// generates Rust source, and compiles to WASM bytes. Returns a
191    /// `CompiledModule` containing the .wasm bytes, manifest, source hash,
192    /// and module name.
193    ///
194    /// Currently compiles the first compilable scenario (non-test, with a kind
195    /// tag). Future versions may compile all scenarios into a multi-invariant
196    /// module.
197    ///
198    /// # Errors
199    ///
200    /// Returns errors for any stage of the pipeline.
201    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        // Parse Gherkin
206        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        // Extract scenario metadata
211        let metas: Vec<_> = feature
212            .scenarios
213            .iter()
214            .map(|s| extract_scenario_meta(&s.name, &s.tags))
215            .collect();
216
217        // Find first compilable scenario (has kind, not a test)
218        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        // Convert gherkin steps to predicate parser format
229        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        // Build manifest
239        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        // Generate Rust source
258        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        // Compile to WASM
265        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    /// Compute SHA-256 content hash for WASM bytes (for `ModuleId`).
276    #[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        // Write Cargo.toml
290        std::fs::write(dir.join("Cargo.toml"), generate_cargo_toml(config))?;
291
292        // Write lib.rs
293        std::fs::write(src_dir.join("lib.rs"), source)?;
294
295        // Build
296        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        // Read .wasm output
330        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            // rustup not available — try compilation anyway
363            _ => Ok(()),
364        }
365    }
366}
367
368// ============================================================================
369// Helpers
370// ============================================================================
371
372/// Generate a minimal `Cargo.toml` for the temporary compilation crate.
373fn 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
403/// Convert `gherkin::Step` list to the tuple format expected by `parse_steps`.
404///
405/// The gherkin crate resolves `And`/`But` keywords into their parent
406/// `StepType` (Given/When/Then), but the `keyword` field preserves the
407/// original keyword. We use `keyword` to maintain And/But distinction
408/// since `parse_steps` handles them differently.
409///
410/// The gherkin crate includes header rows in `Table.rows`. The predicate
411/// parser expects data rows only, so we skip the first row (header) of
412/// each table.
413fn steps_to_tuples(steps: &[gherkin::Step]) -> Vec<(String, String, Vec<Vec<String>>)> {
414    steps
415        .iter()
416        .map(|step| {
417            // Use raw keyword (preserves And/But), trim trailing whitespace
418            let keyword = step.keyword.trim().to_string();
419            let table = step
420                .table
421                .as_ref()
422                .map(|t| {
423                    // Skip header row — predicate parser expects data rows only
424                    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
436/// Compute SHA-256 content hash.
437pub fn content_hash(bytes: &[u8]) -> String {
438    let hash = Sha256::digest(bytes);
439    format!("sha256:{hash:x}")
440}
441
442// ============================================================================
443// Tests
444// ============================================================================
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    // =========================================================================
451    // Content hashing
452    // =========================================================================
453
454    #[test]
455    fn content_hash_produces_sha256_prefix() {
456        let hash = content_hash(b"hello world");
457        assert!(hash.starts_with("sha256:"));
458        // "sha256:" (7 chars) + 64 hex chars
459        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    // =========================================================================
477    // Cargo.toml generation
478    // =========================================================================
479
480    #[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    // =========================================================================
511    // Target
512    // =========================================================================
513
514    #[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    // =========================================================================
531    // Error types
532    // =========================================================================
533
534    #[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    // =========================================================================
559    // Step conversion
560    // =========================================================================
561
562    // =========================================================================
563    // Integration tests (require wasm32 target and cargo)
564    // =========================================================================
565
566    #[test]
567    #[ignore] // Requires wasm32-unknown-unknown target installed
568    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        // Verify WASM magic bytes: \0asm
587        assert!(wasm_bytes.len() > 8);
588        assert_eq!(&wasm_bytes[0..4], b"\0asm");
589
590        // Content hash should work on the output
591        let hash = content_hash(&wasm_bytes);
592        assert!(hash.starts_with("sha256:"));
593    }
594
595    #[test]
596    #[ignore] // Requires wasm32-unknown-unknown target installed
597    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        // Verify WASM magic bytes
608        assert!(module.wasm_bytes.len() > 8);
609        assert_eq!(&module.wasm_bytes[0..4], b"\0asm");
610
611        // First compilable scenario should be brand_safety
612        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] // Requires wasm32-unknown-unknown target installed
619    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}