Skip to main content

converge_tool/
codegen.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//! Code generation for WASM invariant modules from Gherkin predicates.
8//!
9//! Converts parsed Gherkin predicates and scenario metadata into Rust source
10//! code for WASM modules conforming to the Converge WASM ABI v1.
11//!
12//! # Pipeline
13//!
14//! ```text
15//! ScenarioMeta + JTBDMetadata + Vec<Predicate>
16//!     │
17//!     ▼
18//! ManifestBuilder.build() → manifest JSON string
19//!     │
20//!     ▼
21//! generate_invariant_module() → Rust source code string
22//!     │
23//!     ▼
24//! [Task #5: compilation] → .wasm bytes
25//! ```
26//!
27//! # Manifest Builder
28//!
29//! [`ManifestBuilder`] assembles a WasmManifest-compatible JSON string from
30//! Gherkin scenario tags ([`ScenarioMeta`]), JTBD metadata, and parsed
31//! predicates. The JSON conforms to the `converge-runtime` WasmManifest
32//! schema without requiring a direct crate dependency.
33//!
34//! # Code Generator
35//!
36//! [`generate_invariant_module`] produces a complete Rust source file that
37//! exports the WASM ABI v1 functions (`converge_abi_version`, `converge_manifest`,
38//! `alloc`, `dealloc`, `check_invariant`). Each [`Predicate`] is compiled to
39//! a Rust check expression inside the generated `check()` function.
40
41use std::collections::HashMap;
42
43use serde::Serialize;
44
45use crate::gherkin::{InvariantClassTag, ScenarioKind, ScenarioMeta};
46use crate::jtbd::JTBDMetadata;
47use crate::predicate::{Predicate, extract_dependencies};
48
49// ============================================================================
50// Manifest Builder (Task #4)
51// ============================================================================
52
53/// Error during manifest construction.
54#[derive(Debug, Clone)]
55pub enum ManifestError {
56    /// Invariant module must declare an invariant class.
57    MissingInvariantClass,
58    /// Agent module must have at least one dependency.
59    MissingDependencies,
60    /// Module name could not be determined from tags or scenario name.
61    MissingName,
62}
63
64impl std::fmt::Display for ManifestError {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            Self::MissingInvariantClass => write!(
68                f,
69                "invariant module must declare an invariant class tag \
70                 (@structural, @semantic, @acceptance)"
71            ),
72            Self::MissingDependencies => {
73                write!(f, "agent module must reference at least one context key")
74            }
75            Self::MissingName => {
76                write!(f, "module name could not be determined (use @id:name tag)")
77            }
78        }
79    }
80}
81
82impl std::error::Error for ManifestError {}
83
84/// JSON structure matching the `converge-runtime` WasmManifest schema.
85///
86/// Serialized with serde to produce JSON that the runtime's contract
87/// types can deserialize without modification.
88#[derive(Debug, Clone, Serialize)]
89struct ManifestJson {
90    name: String,
91    version: String,
92    kind: String,
93    invariant_class: Option<String>,
94    dependencies: Vec<String>,
95    capabilities: Vec<String>,
96    requires_human_approval: bool,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    jtbd: Option<JtbdRefJson>,
99    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
100    metadata: HashMap<String, String>,
101}
102
103/// JTBD reference matching the `converge-runtime` JtbdRef schema.
104#[derive(Debug, Clone, Serialize)]
105struct JtbdRefJson {
106    truth_id: String,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    actor: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    job_functional: Option<String>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    source_hash: Option<String>,
113}
114
115/// Builder for constructing a WasmManifest-compatible JSON string.
116///
117/// Collects metadata from scenario tags, JTBD blocks, and parsed predicates
118/// to produce a complete manifest for embedding in generated WASM modules.
119///
120/// # Examples
121///
122/// ```
123/// use converge_tool::codegen::ManifestBuilder;
124/// use converge_tool::gherkin::{ScenarioMeta, ScenarioKind, InvariantClassTag};
125///
126/// let meta = ScenarioMeta {
127///     name: "Brand Safety".to_string(),
128///     kind: Some(ScenarioKind::Invariant),
129///     invariant_class: Some(InvariantClassTag::Structural),
130///     id: Some("brand_safety".to_string()),
131///     provider: None,
132///     is_test: false,
133///     raw_tags: vec![],
134/// };
135///
136/// let json = ManifestBuilder::new()
137///     .from_scenario_meta(&meta)
138///     .build()
139///     .unwrap();
140///
141/// assert!(json.contains("brand_safety"));
142/// assert!(json.contains("Structural"));
143/// ```
144pub struct ManifestBuilder {
145    name: Option<String>,
146    version: String,
147    kind: Option<String>,
148    invariant_class: Option<String>,
149    dependencies: Vec<String>,
150    capabilities: Vec<String>,
151    requires_human_approval: bool,
152    jtbd_truth_id: Option<String>,
153    jtbd_actor: Option<String>,
154    jtbd_job_functional: Option<String>,
155    jtbd_source_hash: Option<String>,
156    metadata: HashMap<String, String>,
157}
158
159impl ManifestBuilder {
160    /// Create a new empty manifest builder.
161    #[must_use]
162    pub fn new() -> Self {
163        Self {
164            name: None,
165            version: "0.1.0".to_string(),
166            kind: None,
167            invariant_class: None,
168            dependencies: Vec::new(),
169            capabilities: Vec::new(),
170            requires_human_approval: false,
171            jtbd_truth_id: None,
172            jtbd_actor: None,
173            jtbd_job_functional: None,
174            jtbd_source_hash: None,
175            metadata: HashMap::new(),
176        }
177    }
178
179    /// Populate from extracted scenario metadata (tags).
180    ///
181    /// Sets kind, invariant class, and name from the scenario's parsed tags.
182    /// The `@id:<value>` tag becomes the module name; if absent, the scenario
183    /// name is sanitized to a valid identifier.
184    #[must_use]
185    pub fn from_scenario_meta(mut self, meta: &ScenarioMeta) -> Self {
186        if let Some(kind) = meta.kind {
187            self.kind = Some(
188                match kind {
189                    ScenarioKind::Invariant | ScenarioKind::Validation => "Invariant",
190                    ScenarioKind::Agent => "Agent",
191                    ScenarioKind::EndToEnd => "Invariant",
192                }
193                .to_string(),
194            );
195        }
196
197        if let Some(class) = meta.invariant_class {
198            self.invariant_class = Some(
199                match class {
200                    InvariantClassTag::Structural => "Structural",
201                    InvariantClassTag::Semantic => "Semantic",
202                    InvariantClassTag::Acceptance => "Acceptance",
203                }
204                .to_string(),
205            );
206        }
207
208        if let Some(ref id) = meta.id {
209            self.name = Some(id.clone());
210        } else {
211            self.name = Some(sanitize_module_name(&meta.name));
212        }
213
214        if meta.provider.is_some() && !self.capabilities.contains(&"Log".to_string()) {
215            self.capabilities.push("Log".to_string());
216        }
217
218        self
219    }
220
221    /// Populate JTBD metadata from a parsed JTBD block.
222    #[must_use]
223    pub fn from_jtbd(mut self, jtbd: &JTBDMetadata) -> Self {
224        self.jtbd_actor = Some(jtbd.actor.clone());
225        self.jtbd_job_functional = Some(jtbd.job_functional.clone());
226        self
227    }
228
229    /// Infer dependencies and capabilities from parsed predicates.
230    ///
231    /// Extracts context key references from predicates as dependencies.
232    /// Automatically adds `ReadContext` capability when dependencies exist.
233    #[must_use]
234    pub fn from_predicates(mut self, predicates: &[Predicate]) -> Self {
235        self.dependencies = extract_dependencies(predicates);
236        if !self.dependencies.is_empty() && !self.capabilities.contains(&"ReadContext".to_string())
237        {
238            self.capabilities.insert(0, "ReadContext".to_string());
239        }
240        self
241    }
242
243    /// Set the module version.
244    #[must_use]
245    pub fn with_version(mut self, version: &str) -> Self {
246        self.version = version.to_string();
247        self
248    }
249
250    /// Set the source hash (SHA-256 of the `.truth` file content).
251    #[must_use]
252    pub fn with_source_hash(mut self, hash: &str) -> Self {
253        self.jtbd_source_hash = Some(hash.to_string());
254        self
255    }
256
257    /// Set the truth file ID.
258    #[must_use]
259    pub fn with_truth_id(mut self, id: &str) -> Self {
260        self.jtbd_truth_id = Some(id.to_string());
261        self
262    }
263
264    /// Build the manifest JSON string.
265    ///
266    /// # Errors
267    ///
268    /// Returns `ManifestError::MissingInvariantClass` if kind is Invariant
269    /// but no class tag was provided.
270    ///
271    /// Returns `ManifestError::MissingDependencies` if kind is Agent but
272    /// no context key dependencies were found.
273    ///
274    /// Returns `ManifestError::MissingName` if no name could be determined.
275    pub fn build(self) -> Result<String, ManifestError> {
276        let name = self.name.ok_or(ManifestError::MissingName)?;
277        let kind = self.kind.unwrap_or_else(|| "Invariant".to_string());
278
279        if kind == "Invariant" && self.invariant_class.is_none() {
280            return Err(ManifestError::MissingInvariantClass);
281        }
282        if kind == "Agent" && self.dependencies.is_empty() {
283            return Err(ManifestError::MissingDependencies);
284        }
285
286        let jtbd = if self.jtbd_actor.is_some() || self.jtbd_truth_id.is_some() {
287            Some(JtbdRefJson {
288                truth_id: self.jtbd_truth_id.unwrap_or_default(),
289                actor: self.jtbd_actor,
290                job_functional: self.jtbd_job_functional,
291                source_hash: self.jtbd_source_hash,
292            })
293        } else {
294            None
295        };
296
297        let manifest = ManifestJson {
298            name,
299            version: self.version,
300            kind,
301            invariant_class: self.invariant_class,
302            dependencies: self.dependencies,
303            capabilities: self.capabilities,
304            requires_human_approval: self.requires_human_approval,
305            jtbd,
306            metadata: self.metadata,
307        };
308
309        Ok(serde_json::to_string(&manifest).expect("ManifestJson serialization cannot fail"))
310    }
311}
312
313impl Default for ManifestBuilder {
314    fn default() -> Self {
315        Self::new()
316    }
317}
318
319/// Sanitize a scenario name into a valid Rust/module identifier.
320///
321/// Converts to lowercase, replaces non-alphanumeric characters with
322/// underscores, and trims leading/trailing underscores.
323pub(crate) fn sanitize_module_name(name: &str) -> String {
324    let sanitized: String = name
325        .chars()
326        .map(|c| {
327            if c.is_alphanumeric() {
328                c.to_ascii_lowercase()
329            } else {
330                '_'
331            }
332        })
333        .collect();
334    sanitized.trim_matches('_').to_string()
335}
336
337// ============================================================================
338// Code Generation (Task #3)
339// ============================================================================
340
341/// Configuration for code generation.
342pub struct CodegenConfig {
343    /// Manifest JSON string to embed in the generated module.
344    pub manifest_json: String,
345    /// Module name (used in doc comments).
346    pub module_name: String,
347}
348
349/// Generate a complete Rust source file for a WASM invariant module.
350///
351/// The generated source includes:
352/// - Inline guest types (`GuestContext`, `GuestFact`, `GuestInvariantResult`)
353/// - Bump allocator (`alloc`/`dealloc` exports)
354/// - ABI version export (`converge_abi_version`)
355/// - Manifest export (`converge_manifest` with embedded JSON)
356/// - `check_invariant` export with generated predicate checks
357///
358/// # Examples
359///
360/// ```
361/// use converge_tool::codegen::{generate_invariant_module, CodegenConfig};
362/// use converge_tool::predicate::Predicate;
363///
364/// let config = CodegenConfig {
365///     manifest_json: r#"{"name":"test","version":"1.0.0","kind":"Invariant","invariant_class":"Structural","dependencies":[],"capabilities":[],"requires_human_approval":false}"#.to_string(),
366///     module_name: "test_invariant".to_string(),
367/// };
368///
369/// let source = generate_invariant_module(&config, &[
370///     Predicate::CountAtLeast { key: "Strategies".to_string(), min: 2 },
371/// ]);
372///
373/// assert!(source.contains("check_invariant"));
374/// assert!(source.contains("count < 2"));
375/// ```
376pub fn generate_invariant_module(config: &CodegenConfig, predicates: &[Predicate]) -> String {
377    let checks = generate_check_body(predicates);
378    let manifest_literal = format_raw_string(&config.manifest_json);
379
380    let mut s = String::with_capacity(4096);
381
382    // Module header
383    s.push_str("//! Auto-generated Converge WASM invariant module.\n");
384    s.push_str(&format!("//! Module: {}\n", config.module_name));
385    s.push_str("//!\n");
386    s.push_str("//! Generated by converge-tool. Do not edit manually.\n\n");
387
388    // Inline guest types
389    s.push_str(GUEST_TYPES);
390
391    // Manifest constant
392    s.push_str("const MANIFEST_JSON: &str = ");
393    s.push_str(&manifest_literal);
394    s.push_str(";\n\n");
395
396    // Allocator
397    s.push_str(ALLOCATOR_CODE);
398
399    // ABI exports
400    s.push_str(ABI_EXPORTS);
401
402    // Check function with generated predicates
403    s.push_str("fn check(ctx: &GuestContext) -> GuestInvariantResult {\n");
404    s.push_str(&checks);
405    s.push_str("    GuestInvariantResult {\n");
406    s.push_str("        ok: true,\n");
407    s.push_str("        reason: None,\n");
408    s.push_str("        fact_ids: Vec::new(),\n");
409    s.push_str("    }\n");
410    s.push_str("}\n\n");
411
412    // check_invariant export wrapper
413    s.push_str(CHECK_INVARIANT_WRAPPER);
414
415    s
416}
417
418// ---------------------------------------------------------------------------
419// Static template fragments
420// ---------------------------------------------------------------------------
421
422const GUEST_TYPES: &str = r#"use std::collections::HashMap;
423
424#[derive(serde::Deserialize)]
425struct GuestContext {
426    facts: HashMap<String, Vec<GuestFact>>,
427    #[allow(dead_code)]
428    version: u64,
429    #[allow(dead_code)]
430    cycle: u32,
431}
432
433#[derive(serde::Deserialize)]
434struct GuestFact {
435    id: String,
436    content: String,
437}
438
439#[derive(serde::Serialize)]
440struct GuestInvariantResult {
441    ok: bool,
442    #[serde(skip_serializing_if = "Option::is_none")]
443    reason: Option<String>,
444    #[serde(default, skip_serializing_if = "Vec::is_empty")]
445    fact_ids: Vec<String>,
446}
447
448"#;
449
450const ALLOCATOR_CODE: &str = r#"static BUMP: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
451
452#[no_mangle]
453pub extern "C" fn alloc(size: i32) -> i32 {
454    let prev = BUMP.fetch_add(size as usize, std::sync::atomic::Ordering::SeqCst);
455    prev as i32
456}
457
458#[no_mangle]
459pub extern "C" fn dealloc(_ptr: i32, _len: i32) {
460    // Bump allocator: dealloc is a no-op
461}
462
463"#;
464
465const ABI_EXPORTS: &str = r#"#[no_mangle]
466pub extern "C" fn converge_abi_version() -> i32 {
467    1
468}
469
470#[no_mangle]
471pub extern "C" fn converge_manifest() -> (i32, i32) {
472    (MANIFEST_JSON.as_ptr() as i32, MANIFEST_JSON.len() as i32)
473}
474
475"#;
476
477const CHECK_INVARIANT_WRAPPER: &str = r#"#[no_mangle]
478pub extern "C" fn check_invariant(ctx_ptr: i32, ctx_len: i32) -> (i32, i32) {
479    let ctx_bytes = unsafe {
480        core::slice::from_raw_parts(ctx_ptr as *const u8, ctx_len as usize)
481    };
482    let ctx: GuestContext = match serde_json::from_slice(ctx_bytes) {
483        Ok(c) => c,
484        Err(e) => {
485            return write_result(&GuestInvariantResult {
486                ok: false,
487                reason: Some(format!("failed to parse context: {}", e)),
488                fact_ids: Vec::new(),
489            });
490        }
491    };
492
493    let result = check(&ctx);
494    write_result(&result)
495}
496
497fn write_result(result: &GuestInvariantResult) -> (i32, i32) {
498    let json = serde_json::to_vec(result).expect("serialize result");
499    let ptr = alloc(json.len() as i32);
500    unsafe {
501        core::ptr::copy_nonoverlapping(json.as_ptr(), ptr as *mut u8, json.len());
502    }
503    (ptr, json.len() as i32)
504}
505"#;
506
507// ---------------------------------------------------------------------------
508// Check body generation
509// ---------------------------------------------------------------------------
510
511/// Generate the body of the `check()` function from predicates.
512fn generate_check_body(predicates: &[Predicate]) -> String {
513    if predicates.is_empty() {
514        return "    // No predicates — invariant always holds\n".to_string();
515    }
516
517    let mut body = String::new();
518    for (i, pred) in predicates.iter().enumerate() {
519        body.push_str(&format!(
520            "    // Check {}: {}\n",
521            i + 1,
522            predicate_summary(pred)
523        ));
524        body.push_str(&predicate_to_rust(pred));
525        body.push('\n');
526    }
527    body
528}
529
530/// One-line summary of a predicate for code comments.
531fn predicate_summary(pred: &Predicate) -> String {
532    match pred {
533        Predicate::CountAtLeast { key, min } => {
534            format!("{key} must have at least {min} facts")
535        }
536        Predicate::CountAtMost { key, max } => {
537            format!("{key} must have at most {max} facts")
538        }
539        Predicate::ContentMustNotContain { key, forbidden } => {
540            format!("{key} must not contain {} forbidden terms", forbidden.len())
541        }
542        Predicate::ContentMustContain {
543            key,
544            required_field,
545        } => {
546            format!("{key} facts must contain field '{required_field}'")
547        }
548        Predicate::CrossReference {
549            source_key,
550            target_key,
551        } => {
552            format!("each {source_key} must be referenced by a {target_key}")
553        }
554        Predicate::HasFacts { key } => format!("{key} must have facts"),
555        Predicate::RequiredFields { key, fields } => {
556            format!("{key} facts must have {} required fields", fields.len())
557        }
558        Predicate::Custom { description } => {
559            let short = if description.len() > 60 {
560                format!("{}...", &description[..60])
561            } else {
562                description.clone()
563            };
564            format!("custom: {short}")
565        }
566    }
567}
568
569/// Generate Rust code for a single predicate check.
570///
571/// Returns a block of Rust code (with leading indentation) that evaluates
572/// the predicate against `ctx: &GuestContext` and returns early with a
573/// `GuestInvariantResult` violation if the check fails.
574fn predicate_to_rust(pred: &Predicate) -> String {
575    match pred {
576        Predicate::CountAtLeast { key, min } => {
577            let key_e = esc(key);
578            let mut c = String::new();
579            c.push_str("    {\n");
580            c.push_str(&format!(
581                "        let count = ctx.facts.get(\"{key_e}\").map(|v| v.len()).unwrap_or(0);\n"
582            ));
583            c.push_str(&format!("        if count < {min} {{\n"));
584            c.push_str("            return GuestInvariantResult {\n");
585            c.push_str("                ok: false,\n");
586            c.push_str(&format!(
587                "                reason: Some(format!(\"{key_e} contains {{}} facts, need at least {min}\", count)),\n"
588            ));
589            c.push_str("                fact_ids: Vec::new(),\n");
590            c.push_str("            };\n");
591            c.push_str("        }\n");
592            c.push_str("    }\n");
593            c
594        }
595
596        Predicate::CountAtMost { key, max } => {
597            let key_e = esc(key);
598            let mut c = String::new();
599            c.push_str("    {\n");
600            c.push_str(&format!(
601                "        let count = ctx.facts.get(\"{key_e}\").map(|v| v.len()).unwrap_or(0);\n"
602            ));
603            c.push_str(&format!("        if count > {max} {{\n"));
604            c.push_str("            return GuestInvariantResult {\n");
605            c.push_str("                ok: false,\n");
606            c.push_str(&format!(
607                "                reason: Some(format!(\"{key_e} contains {{}} facts, max is {max}\", count)),\n"
608            ));
609            c.push_str("                fact_ids: Vec::new(),\n");
610            c.push_str("            };\n");
611            c.push_str("        }\n");
612            c.push_str("    }\n");
613            c
614        }
615
616        Predicate::ContentMustNotContain { key, forbidden } => {
617            let key_e = esc(key);
618            let mut c = String::new();
619            c.push_str(&format!(
620                "    if let Some(facts) = ctx.facts.get(\"{key_e}\") {{\n"
621            ));
622            c.push_str("        for fact in facts {\n");
623            c.push_str("            let content_lower = fact.content.to_lowercase();\n");
624            for term in forbidden {
625                let term_lower = esc(&term.term.to_lowercase());
626                let term_display = esc(&term.term);
627                let reason_display = esc(&term.reason);
628                c.push_str(&format!(
629                    "            if content_lower.contains(\"{term_lower}\") {{\n"
630                ));
631                c.push_str("                return GuestInvariantResult {\n");
632                c.push_str("                    ok: false,\n");
633                c.push_str(&format!(
634                    "                    reason: Some(format!(\"{key_e} fact '{{}}' contains forbidden term: {term_display} ({reason_display})\", fact.id)),\n"
635                ));
636                c.push_str("                    fact_ids: vec![fact.id.clone()],\n");
637                c.push_str("                };\n");
638                c.push_str("            }\n");
639            }
640            c.push_str("        }\n");
641            c.push_str("    }\n");
642            c
643        }
644
645        Predicate::ContentMustContain {
646            key,
647            required_field,
648        } => {
649            let key_e = esc(key);
650            let field_e = esc(required_field);
651            let mut c = String::new();
652            c.push_str(&format!(
653                "    if let Some(facts) = ctx.facts.get(\"{key_e}\") {{\n"
654            ));
655            c.push_str("        for fact in facts {\n");
656            c.push_str(&format!(
657                "            if !fact.content.contains(\"{field_e}\") {{\n"
658            ));
659            c.push_str("                return GuestInvariantResult {\n");
660            c.push_str("                    ok: false,\n");
661            c.push_str(&format!(
662                "                    reason: Some(format!(\"{key_e} fact '{{}}' missing required field: {field_e}\", fact.id)),\n"
663            ));
664            c.push_str("                    fact_ids: vec![fact.id.clone()],\n");
665            c.push_str("                };\n");
666            c.push_str("            }\n");
667            c.push_str("        }\n");
668            c.push_str("    }\n");
669            c
670        }
671
672        Predicate::CrossReference {
673            source_key,
674            target_key,
675        } => {
676            let src_e = esc(source_key);
677            let tgt_e = esc(target_key);
678            let mut c = String::new();
679            c.push_str(&format!(
680                "    if let Some(source_facts) = ctx.facts.get(\"{src_e}\") {{\n"
681            ));
682            c.push_str(&format!(
683                "        let target_facts = ctx.facts.get(\"{tgt_e}\");\n"
684            ));
685            c.push_str("        let empty = Vec::new();\n");
686            c.push_str("        let targets = target_facts.unwrap_or(&empty);\n");
687            c.push_str("        for source in source_facts {\n");
688            c.push_str(
689                "            let referenced = targets.iter().any(|t| t.content.contains(&source.id));\n",
690            );
691            c.push_str("            if !referenced {\n");
692            c.push_str("                return GuestInvariantResult {\n");
693            c.push_str("                    ok: false,\n");
694            c.push_str(&format!(
695                "                    reason: Some(format!(\"{src_e} fact '{{}}' has no corresponding {tgt_e}\", source.id)),\n"
696            ));
697            c.push_str("                    fact_ids: vec![source.id.clone()],\n");
698            c.push_str("                };\n");
699            c.push_str("            }\n");
700            c.push_str("        }\n");
701            c.push_str("    }\n");
702            c
703        }
704
705        Predicate::HasFacts { key } => {
706            let key_e = esc(key);
707            let mut c = String::new();
708            c.push_str(&format!(
709                "    if ctx.facts.get(\"{key_e}\").map(|v| v.is_empty()).unwrap_or(true) {{\n"
710            ));
711            c.push_str("        return GuestInvariantResult {\n");
712            c.push_str("            ok: false,\n");
713            c.push_str(&format!(
714                "            reason: Some(\"{key_e} must contain at least one fact\".to_string()),\n"
715            ));
716            c.push_str("            fact_ids: Vec::new(),\n");
717            c.push_str("        };\n");
718            c.push_str("    }\n");
719            c
720        }
721
722        Predicate::RequiredFields { key, fields } => {
723            let key_e = esc(key);
724            let mut c = String::new();
725            c.push_str(&format!(
726                "    if let Some(facts) = ctx.facts.get(\"{key_e}\") {{\n"
727            ));
728            c.push_str("        for fact in facts {\n");
729            for field in fields {
730                let field_e = esc(&field.field);
731                let rule_e = esc(&field.rule);
732                c.push_str(&format!(
733                    "            if !fact.content.contains(\"{field_e}\") {{\n"
734                ));
735                c.push_str("                return GuestInvariantResult {\n");
736                c.push_str("                    ok: false,\n");
737                c.push_str(&format!(
738                    "                    reason: Some(format!(\"{key_e} fact '{{}}' missing required field: {field_e} ({rule_e})\", fact.id)),\n"
739                ));
740                c.push_str("                    fact_ids: vec![fact.id.clone()],\n");
741                c.push_str("                };\n");
742                c.push_str("            }\n");
743            }
744            c.push_str("        }\n");
745            c.push_str("    }\n");
746            c
747        }
748
749        Predicate::Custom { description } => {
750            let safe = description.replace("*/", "* /").replace('\\', "\\\\");
751            let mut c = String::new();
752            c.push_str("    // TODO: Custom predicate — manual implementation needed\n");
753            c.push_str(&format!("    // Original step: \"{safe}\"\n"));
754            c
755        }
756    }
757}
758
759/// Escape a string for use inside a Rust string literal.
760fn esc(s: &str) -> String {
761    s.replace('\\', "\\\\")
762        .replace('"', "\\\"")
763        .replace('\n', "\\n")
764        .replace('\r', "\\r")
765        .replace('\t', "\\t")
766}
767
768/// Format a string as a Rust raw string literal `r#"..."#`.
769///
770/// Automatically determines the minimum number of `#` delimiters needed
771/// to safely wrap the content.
772fn format_raw_string(s: &str) -> String {
773    // Find the longest run of consecutive '#' in the string
774    let mut max_hashes = 0;
775    let mut current = 0;
776    for c in s.chars() {
777        if c == '#' {
778            current += 1;
779            if current > max_hashes {
780                max_hashes = current;
781            }
782        } else {
783            current = 0;
784        }
785    }
786
787    // Also check for `"` followed by '#' runs that could close the raw string
788    let hashes_needed = if s.contains('"') { max_hashes + 1 } else { 1 };
789
790    let delim = "#".repeat(hashes_needed);
791    format!("r{delim}\"{s}\"{delim}")
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    fn test_config() -> CodegenConfig {
799        CodegenConfig {
800            manifest_json: r#"{"name":"test","version":"1.0.0","kind":"Invariant","invariant_class":"Structural","dependencies":["Strategies"],"capabilities":["ReadContext"],"requires_human_approval":false}"#.to_string(),
801            module_name: "test_invariant".to_string(),
802        }
803    }
804
805    // =========================================================================
806    // Codegen tests (Task #3)
807    // =========================================================================
808
809    #[test]
810    fn codegen_count_at_least() {
811        let source = generate_invariant_module(
812            &test_config(),
813            &[Predicate::CountAtLeast {
814                key: "Strategies".to_string(),
815                min: 2,
816            }],
817        );
818        assert!(source.contains("count < 2"));
819        assert!(source.contains(r#"ctx.facts.get("Strategies")"#));
820    }
821
822    #[test]
823    fn codegen_count_at_most() {
824        let source = generate_invariant_module(
825            &test_config(),
826            &[Predicate::CountAtMost {
827                key: "Seeds".to_string(),
828                max: 5,
829            }],
830        );
831        assert!(source.contains("count > 5"));
832        assert!(source.contains(r#"ctx.facts.get("Seeds")"#));
833    }
834
835    #[test]
836    fn codegen_content_must_not_contain() {
837        let source = generate_invariant_module(
838            &test_config(),
839            &[Predicate::ContentMustNotContain {
840                key: "Strategies".to_string(),
841                forbidden: vec![ForbiddenTerm {
842                    term: "spam".to_string(),
843                    reason: "illegal marketing".to_string(),
844                }],
845            }],
846        );
847        assert!(source.contains("content_lower.contains("));
848        assert!(source.contains("spam"));
849        assert!(source.contains("illegal marketing"));
850    }
851
852    #[test]
853    fn codegen_content_must_contain() {
854        let source = generate_invariant_module(
855            &test_config(),
856            &[Predicate::ContentMustContain {
857                key: "Strategies".to_string(),
858                required_field: "compliance_ref".to_string(),
859            }],
860        );
861        assert!(source.contains(r#"fact.content.contains("compliance_ref")"#));
862    }
863
864    #[test]
865    fn codegen_cross_reference() {
866        let source = generate_invariant_module(
867            &test_config(),
868            &[Predicate::CrossReference {
869                source_key: "Strategy".to_string(),
870                target_key: "Evaluation".to_string(),
871            }],
872        );
873        assert!(source.contains(r#"ctx.facts.get("Strategy")"#));
874        assert!(source.contains(r#"ctx.facts.get("Evaluation")"#));
875        assert!(source.contains("t.content.contains(&source.id)"));
876    }
877
878    #[test]
879    fn codegen_has_facts() {
880        let source = generate_invariant_module(
881            &test_config(),
882            &[Predicate::HasFacts {
883                key: "Signals".to_string(),
884            }],
885        );
886        assert!(source.contains(r#"ctx.facts.get("Signals")"#));
887        assert!(source.contains("v.is_empty()"));
888    }
889
890    #[test]
891    fn codegen_required_fields() {
892        let source = generate_invariant_module(
893            &test_config(),
894            &[Predicate::RequiredFields {
895                key: "Evaluations".to_string(),
896                fields: vec![
897                    FieldRequirement {
898                        field: "score".to_string(),
899                        rule: "integer 0..100".to_string(),
900                    },
901                    FieldRequirement {
902                        field: "rationale".to_string(),
903                        rule: "non-empty".to_string(),
904                    },
905                ],
906            }],
907        );
908        assert!(source.contains(r#"fact.content.contains("score")"#));
909        assert!(source.contains(r#"fact.content.contains("rationale")"#));
910    }
911
912    #[test]
913    fn codegen_custom_predicate_is_todo() {
914        let source = generate_invariant_module(
915            &test_config(),
916            &[Predicate::Custom {
917                description: "something special happens".to_string(),
918            }],
919        );
920        assert!(source.contains("TODO"));
921        assert!(source.contains("something special happens"));
922    }
923
924    #[test]
925    fn codegen_includes_manifest_json() {
926        let config = test_config();
927        let source = generate_invariant_module(&config, &[]);
928        assert!(source.contains(&config.manifest_json));
929    }
930
931    #[test]
932    fn codegen_includes_alloc_dealloc() {
933        let source = generate_invariant_module(&test_config(), &[]);
934        assert!(source.contains("fn alloc("));
935        assert!(source.contains("fn dealloc("));
936    }
937
938    #[test]
939    fn codegen_includes_abi_exports() {
940        let source = generate_invariant_module(&test_config(), &[]);
941        assert!(source.contains("fn converge_abi_version()"));
942        assert!(source.contains("fn converge_manifest()"));
943        assert!(source.contains("fn check_invariant("));
944    }
945
946    #[test]
947    fn codegen_empty_predicates_returns_ok() {
948        let source = generate_invariant_module(&test_config(), &[]);
949        assert!(source.contains("ok: true"));
950        assert!(source.contains("No predicates"));
951    }
952
953    #[test]
954    fn codegen_multiple_predicates() {
955        let source = generate_invariant_module(
956            &test_config(),
957            &[
958                Predicate::HasFacts {
959                    key: "Strategies".to_string(),
960                },
961                Predicate::CountAtLeast {
962                    key: "Strategies".to_string(),
963                    min: 2,
964                },
965            ],
966        );
967        assert!(source.contains("Check 1:"));
968        assert!(source.contains("Check 2:"));
969    }
970
971    // =========================================================================
972    // ManifestBuilder tests (Task #4)
973    // =========================================================================
974
975    #[test]
976    fn manifest_from_invariant_tags_and_jtbd() {
977        let meta = ScenarioMeta {
978            name: "Brand Safety Check".to_string(),
979            kind: Some(ScenarioKind::Invariant),
980            invariant_class: Some(InvariantClassTag::Acceptance),
981            id: Some("brand_safety".to_string()),
982            provider: None,
983            is_test: false,
984            raw_tags: vec![],
985        };
986
987        let jtbd = JTBDMetadata {
988            actor: "Ops Manager".to_string(),
989            job_functional: "Ensure brand safety".to_string(),
990            job_emotional: None,
991            job_relational: None,
992            so_that: "Brand is protected".to_string(),
993            scope: None,
994            success_metrics: vec![],
995            failure_modes: vec![],
996            exceptions: vec![],
997            evidence_required: vec![],
998            audit_requirements: vec![],
999            links: vec![],
1000        };
1001
1002        let json = ManifestBuilder::new()
1003            .from_scenario_meta(&meta)
1004            .from_jtbd(&jtbd)
1005            .from_predicates(&[Predicate::CountAtLeast {
1006                key: "Strategies".to_string(),
1007                min: 2,
1008            }])
1009            .with_truth_id("growth-strategy.truth")
1010            .build()
1011            .unwrap();
1012
1013        assert!(json.contains("\"brand_safety\""));
1014        assert!(json.contains("\"Invariant\""));
1015        assert!(json.contains("\"Acceptance\""));
1016        assert!(json.contains("\"Strategies\""));
1017        assert!(json.contains("Ops Manager"));
1018        assert!(json.contains("Ensure brand safety"));
1019        assert!(json.contains("growth-strategy.truth"));
1020    }
1021
1022    #[test]
1023    fn manifest_from_agent_tags() {
1024        let meta = ScenarioMeta {
1025            name: "Market Signal Agent".to_string(),
1026            kind: Some(ScenarioKind::Agent),
1027            invariant_class: None,
1028            id: Some("market_signal".to_string()),
1029            provider: None,
1030            is_test: false,
1031            raw_tags: vec![],
1032        };
1033
1034        let json = ManifestBuilder::new()
1035            .from_scenario_meta(&meta)
1036            .from_predicates(&[Predicate::HasFacts {
1037                key: "Signals".to_string(),
1038            }])
1039            .build()
1040            .unwrap();
1041
1042        assert!(json.contains("\"Agent\""));
1043        assert!(json.contains("\"Signals\""));
1044        assert!(json.contains("\"market_signal\""));
1045    }
1046
1047    #[test]
1048    fn manifest_deps_inferred_from_predicates() {
1049        let meta = ScenarioMeta {
1050            name: "test".to_string(),
1051            kind: Some(ScenarioKind::Invariant),
1052            invariant_class: Some(InvariantClassTag::Semantic),
1053            id: Some("test".to_string()),
1054            provider: None,
1055            is_test: false,
1056            raw_tags: vec![],
1057        };
1058
1059        let json = ManifestBuilder::new()
1060            .from_scenario_meta(&meta)
1061            .from_predicates(&[
1062                Predicate::CountAtLeast {
1063                    key: "Strategies".to_string(),
1064                    min: 1,
1065                },
1066                Predicate::HasFacts {
1067                    key: "Evaluations".to_string(),
1068                },
1069            ])
1070            .build()
1071            .unwrap();
1072
1073        assert!(json.contains("\"Evaluations\""));
1074        assert!(json.contains("\"Strategies\""));
1075        assert!(json.contains("\"ReadContext\""));
1076    }
1077
1078    #[test]
1079    fn manifest_invariant_without_class_errors() {
1080        let meta = ScenarioMeta {
1081            name: "test".to_string(),
1082            kind: Some(ScenarioKind::Invariant),
1083            invariant_class: None,
1084            id: Some("test".to_string()),
1085            provider: None,
1086            is_test: false,
1087            raw_tags: vec![],
1088        };
1089
1090        let result = ManifestBuilder::new().from_scenario_meta(&meta).build();
1091        assert!(result.is_err());
1092        assert!(matches!(
1093            result.unwrap_err(),
1094            ManifestError::MissingInvariantClass
1095        ));
1096    }
1097
1098    #[test]
1099    fn manifest_agent_without_deps_errors() {
1100        let meta = ScenarioMeta {
1101            name: "test".to_string(),
1102            kind: Some(ScenarioKind::Agent),
1103            invariant_class: None,
1104            id: Some("test_agent".to_string()),
1105            provider: None,
1106            is_test: false,
1107            raw_tags: vec![],
1108        };
1109
1110        let result = ManifestBuilder::new().from_scenario_meta(&meta).build();
1111        assert!(result.is_err());
1112        assert!(matches!(
1113            result.unwrap_err(),
1114            ManifestError::MissingDependencies
1115        ));
1116    }
1117
1118    #[test]
1119    fn manifest_name_from_sanitized_scenario() {
1120        let meta = ScenarioMeta {
1121            name: "Brand Safety Check".to_string(),
1122            kind: Some(ScenarioKind::Invariant),
1123            invariant_class: Some(InvariantClassTag::Structural),
1124            id: None, // No @id tag — use sanitized name
1125            provider: None,
1126            is_test: false,
1127            raw_tags: vec![],
1128        };
1129
1130        let json = ManifestBuilder::new()
1131            .from_scenario_meta(&meta)
1132            .build()
1133            .unwrap();
1134        assert!(json.contains("\"brand_safety_check\""));
1135    }
1136
1137    #[test]
1138    fn manifest_with_source_hash() {
1139        let meta = ScenarioMeta {
1140            name: "test".to_string(),
1141            kind: Some(ScenarioKind::Invariant),
1142            invariant_class: Some(InvariantClassTag::Structural),
1143            id: Some("test".to_string()),
1144            provider: None,
1145            is_test: false,
1146            raw_tags: vec![],
1147        };
1148
1149        let json = ManifestBuilder::new()
1150            .from_scenario_meta(&meta)
1151            .with_truth_id("test.truth")
1152            .with_source_hash("sha256:abc123")
1153            .build()
1154            .unwrap();
1155        assert!(json.contains("sha256:abc123"));
1156        assert!(json.contains("test.truth"));
1157    }
1158
1159    // =========================================================================
1160    // Helper tests
1161    // =========================================================================
1162
1163    #[test]
1164    fn sanitize_name_handles_spaces_and_casing() {
1165        assert_eq!(
1166            sanitize_module_name("Brand Safety Check"),
1167            "brand_safety_check"
1168        );
1169        assert_eq!(sanitize_module_name("  test  "), "test");
1170        assert_eq!(sanitize_module_name("CamelCase"), "camelcase");
1171        assert_eq!(sanitize_module_name("hyphen-name"), "hyphen_name");
1172    }
1173
1174    #[test]
1175    fn format_raw_string_simple() {
1176        let result = format_raw_string("hello");
1177        assert_eq!(result, r##"r#"hello"#"##);
1178    }
1179
1180    #[test]
1181    fn format_raw_string_with_quotes() {
1182        let result = format_raw_string(r#"{"key":"value"}"#);
1183        assert_eq!(result, r###"r#"{"key":"value"}"#"###);
1184    }
1185
1186    #[test]
1187    fn esc_handles_special_chars() {
1188        assert_eq!(esc(r#"hello "world""#), r#"hello \"world\""#);
1189        assert_eq!(esc("back\\slash"), "back\\\\slash");
1190        assert_eq!(esc("new\nline"), "new\\nline");
1191    }
1192
1193    // =========================================================================
1194    // Property tests
1195    // =========================================================================
1196
1197    mod property_tests {
1198        use super::*;
1199        use proptest::prelude::*;
1200
1201        fn arb_predicate() -> impl Strategy<Value = Predicate> {
1202            prop_oneof![
1203                (1..100usize).prop_map(|min| Predicate::CountAtLeast {
1204                    key: "Strategies".to_string(),
1205                    min,
1206                }),
1207                (1..100usize).prop_map(|max| Predicate::CountAtMost {
1208                    key: "Seeds".to_string(),
1209                    max,
1210                }),
1211                Just(Predicate::HasFacts {
1212                    key: "Signals".to_string()
1213                }),
1214                Just(Predicate::CrossReference {
1215                    source_key: "Strategies".to_string(),
1216                    target_key: "Evaluations".to_string(),
1217                }),
1218                Just(Predicate::ContentMustContain {
1219                    key: "Strategies".to_string(),
1220                    required_field: "compliance_ref".to_string(),
1221                }),
1222                "[a-z ]{1,50}".prop_map(|desc| Predicate::Custom { description: desc }),
1223            ]
1224        }
1225
1226        proptest! {
1227            #[test]
1228            fn generated_code_is_syntactically_valid_rust(
1229                predicates in proptest::collection::vec(arb_predicate(), 0..5)
1230            ) {
1231                let config = CodegenConfig {
1232                    manifest_json: r#"{"name":"t","version":"1.0.0","kind":"Invariant","invariant_class":"Structural","dependencies":[],"capabilities":[],"requires_human_approval":false}"#.to_string(),
1233                    module_name: "test".to_string(),
1234                };
1235                let source = generate_invariant_module(&config, &predicates);
1236                syn::parse_file(&source).unwrap_or_else(|e| {
1237                    panic!("Generated code is not valid Rust:\n{source}\nError: {e}");
1238                });
1239            }
1240
1241            #[test]
1242            fn manifest_builder_never_panics(
1243                name in "[a-z]{3,10}",
1244                is_invariant in proptest::bool::ANY,
1245                has_class in proptest::bool::ANY,
1246            ) {
1247                let meta = ScenarioMeta {
1248                    name: name.clone(),
1249                    kind: Some(if is_invariant { ScenarioKind::Invariant } else { ScenarioKind::Agent }),
1250                    invariant_class: if has_class { Some(InvariantClassTag::Structural) } else { None },
1251                    id: Some(name),
1252                    provider: None,
1253                    is_test: false,
1254                    raw_tags: vec![],
1255                };
1256                // Should never panic — either Ok or Err
1257                let _ = ManifestBuilder::new()
1258                    .from_scenario_meta(&meta)
1259                    .build();
1260            }
1261        }
1262    }
1263}