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 | ScenarioKind::EndToEnd => "Invariant",
190                    ScenarioKind::Agent => "Agent",
191                }
192                .to_string(),
193            );
194        }
195
196        if let Some(class) = meta.invariant_class {
197            self.invariant_class = Some(
198                match class {
199                    InvariantClassTag::Structural => "Structural",
200                    InvariantClassTag::Semantic => "Semantic",
201                    InvariantClassTag::Acceptance => "Acceptance",
202                }
203                .to_string(),
204            );
205        }
206
207        if let Some(ref id) = meta.id {
208            self.name = Some(id.clone());
209        } else {
210            self.name = Some(sanitize_module_name(&meta.name));
211        }
212
213        if meta.provider.is_some() && !self.capabilities.contains(&"Log".to_string()) {
214            self.capabilities.push("Log".to_string());
215        }
216
217        self
218    }
219
220    /// Populate JTBD metadata from a parsed JTBD block.
221    #[must_use]
222    pub fn from_jtbd(mut self, jtbd: &JTBDMetadata) -> Self {
223        self.jtbd_actor = Some(jtbd.actor.clone());
224        self.jtbd_job_functional = Some(jtbd.job_functional.clone());
225        self
226    }
227
228    /// Infer dependencies and capabilities from parsed predicates.
229    ///
230    /// Extracts context key references from predicates as dependencies.
231    /// Automatically adds `ReadContext` capability when dependencies exist.
232    #[must_use]
233    pub fn from_predicates(mut self, predicates: &[Predicate]) -> Self {
234        self.dependencies = extract_dependencies(predicates);
235        if !self.dependencies.is_empty() && !self.capabilities.contains(&"ReadContext".to_string())
236        {
237            self.capabilities.insert(0, "ReadContext".to_string());
238        }
239        self
240    }
241
242    /// Set the module version.
243    #[must_use]
244    pub fn with_version(mut self, version: &str) -> Self {
245        self.version = version.to_string();
246        self
247    }
248
249    /// Set the source hash (SHA-256 of the `.truth` file content).
250    #[must_use]
251    pub fn with_source_hash(mut self, hash: &str) -> Self {
252        self.jtbd_source_hash = Some(hash.to_string());
253        self
254    }
255
256    /// Set the truth file ID.
257    #[must_use]
258    pub fn with_truth_id(mut self, id: &str) -> Self {
259        self.jtbd_truth_id = Some(id.to_string());
260        self
261    }
262
263    /// Build the manifest JSON string.
264    ///
265    /// # Errors
266    ///
267    /// Returns `ManifestError::MissingInvariantClass` if kind is Invariant
268    /// but no class tag was provided.
269    ///
270    /// Returns `ManifestError::MissingDependencies` if kind is Agent but
271    /// no context key dependencies were found.
272    ///
273    /// Returns `ManifestError::MissingName` if no name could be determined.
274    pub fn build(self) -> Result<String, ManifestError> {
275        let name = self.name.ok_or(ManifestError::MissingName)?;
276        let kind = self.kind.unwrap_or_else(|| "Invariant".to_string());
277
278        if kind == "Invariant" && self.invariant_class.is_none() {
279            return Err(ManifestError::MissingInvariantClass);
280        }
281        if kind == "Agent" && self.dependencies.is_empty() {
282            return Err(ManifestError::MissingDependencies);
283        }
284
285        let jtbd = if self.jtbd_actor.is_some() || self.jtbd_truth_id.is_some() {
286            Some(JtbdRefJson {
287                truth_id: self.jtbd_truth_id.unwrap_or_default(),
288                actor: self.jtbd_actor,
289                job_functional: self.jtbd_job_functional,
290                source_hash: self.jtbd_source_hash,
291            })
292        } else {
293            None
294        };
295
296        let manifest = ManifestJson {
297            name,
298            version: self.version,
299            kind,
300            invariant_class: self.invariant_class,
301            dependencies: self.dependencies,
302            capabilities: self.capabilities,
303            requires_human_approval: self.requires_human_approval,
304            jtbd,
305            metadata: self.metadata,
306        };
307
308        Ok(serde_json::to_string(&manifest).expect("ManifestJson serialization cannot fail"))
309    }
310}
311
312impl Default for ManifestBuilder {
313    fn default() -> Self {
314        Self::new()
315    }
316}
317
318/// Sanitize a scenario name into a valid Rust/module identifier.
319///
320/// Converts to lowercase, replaces non-alphanumeric characters with
321/// underscores, and trims leading/trailing underscores.
322pub(crate) fn sanitize_module_name(name: &str) -> String {
323    let sanitized: String = name
324        .chars()
325        .map(|c| {
326            if c.is_alphanumeric() {
327                c.to_ascii_lowercase()
328            } else {
329                '_'
330            }
331        })
332        .collect();
333    sanitized.trim_matches('_').to_string()
334}
335
336// ============================================================================
337// Code Generation (Task #3)
338// ============================================================================
339
340/// Configuration for code generation.
341pub struct CodegenConfig {
342    /// Manifest JSON string to embed in the generated module.
343    pub manifest_json: String,
344    /// Module name (used in doc comments).
345    pub module_name: String,
346}
347
348/// Generate a complete Rust source file for a WASM invariant module.
349///
350/// The generated source includes:
351/// - Inline guest types (`GuestContext`, `GuestFact`, `GuestInvariantResult`)
352/// - Bump allocator (`alloc`/`dealloc` exports)
353/// - ABI version export (`converge_abi_version`)
354/// - Manifest export (`converge_manifest` with embedded JSON)
355/// - `check_invariant` export with generated predicate checks
356///
357/// # Examples
358///
359/// ```
360/// use converge_tool::codegen::{generate_invariant_module, CodegenConfig};
361/// use converge_tool::predicate::Predicate;
362///
363/// let config = CodegenConfig {
364///     manifest_json: r#"{"name":"test","version":"1.0.0","kind":"Invariant","invariant_class":"Structural","dependencies":[],"capabilities":[],"requires_human_approval":false}"#.to_string(),
365///     module_name: "test_invariant".to_string(),
366/// };
367///
368/// let source = generate_invariant_module(&config, &[
369///     Predicate::CountAtLeast { key: "Strategies".to_string(), min: 2 },
370/// ]);
371///
372/// assert!(source.contains("check_invariant"));
373/// assert!(source.contains("count < 2"));
374/// ```
375pub fn generate_invariant_module(config: &CodegenConfig, predicates: &[Predicate]) -> String {
376    let checks = generate_check_body(predicates);
377    let manifest_literal = format_raw_string(&config.manifest_json);
378
379    let mut s = String::with_capacity(4096);
380
381    // Module header
382    s.push_str("//! Auto-generated Converge WASM invariant module.\n");
383    s.push_str(&format!("//! Module: {}\n", config.module_name));
384    s.push_str("//!\n");
385    s.push_str("//! Generated by converge-tool. Do not edit manually.\n\n");
386
387    // Inline guest types
388    s.push_str(GUEST_TYPES);
389
390    // Manifest constant
391    s.push_str("const MANIFEST_JSON: &str = ");
392    s.push_str(&manifest_literal);
393    s.push_str(";\n\n");
394
395    // Allocator
396    s.push_str(ALLOCATOR_CODE);
397
398    // ABI exports
399    s.push_str(ABI_EXPORTS);
400
401    // Check function with generated predicates
402    s.push_str("fn check(ctx: &GuestContext) -> GuestInvariantResult {\n");
403    s.push_str(&checks);
404    s.push_str("    GuestInvariantResult {\n");
405    s.push_str("        ok: true,\n");
406    s.push_str("        reason: None,\n");
407    s.push_str("        fact_ids: Vec::new(),\n");
408    s.push_str("    }\n");
409    s.push_str("}\n\n");
410
411    // check_invariant export wrapper
412    s.push_str(CHECK_INVARIANT_WRAPPER);
413
414    s
415}
416
417// ---------------------------------------------------------------------------
418// Static template fragments
419// ---------------------------------------------------------------------------
420
421const GUEST_TYPES: &str = r#"use std::collections::HashMap;
422
423#[derive(serde::Deserialize)]
424struct GuestContext {
425    facts: HashMap<String, Vec<GuestFact>>,
426    #[allow(dead_code)]
427    version: u64,
428    #[allow(dead_code)]
429    cycle: u32,
430}
431
432#[derive(serde::Deserialize)]
433struct GuestFact {
434    id: String,
435    content: String,
436}
437
438#[derive(serde::Serialize)]
439struct GuestInvariantResult {
440    ok: bool,
441    #[serde(skip_serializing_if = "Option::is_none")]
442    reason: Option<String>,
443    #[serde(default, skip_serializing_if = "Vec::is_empty")]
444    fact_ids: Vec<String>,
445}
446
447"#;
448
449const ALLOCATOR_CODE: &str = r#"static BUMP: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
450
451#[no_mangle]
452pub extern "C" fn alloc(size: i32) -> i32 {
453    let prev = BUMP.fetch_add(size as usize, std::sync::atomic::Ordering::SeqCst);
454    prev as i32
455}
456
457#[no_mangle]
458pub extern "C" fn dealloc(_ptr: i32, _len: i32) {
459    // Bump allocator: dealloc is a no-op
460}
461
462"#;
463
464const ABI_EXPORTS: &str = r#"#[no_mangle]
465pub extern "C" fn converge_abi_version() -> i32 {
466    1
467}
468
469#[no_mangle]
470pub extern "C" fn converge_manifest() -> (i32, i32) {
471    (MANIFEST_JSON.as_ptr() as i32, MANIFEST_JSON.len() as i32)
472}
473
474"#;
475
476const CHECK_INVARIANT_WRAPPER: &str = r#"#[no_mangle]
477pub extern "C" fn check_invariant(ctx_ptr: i32, ctx_len: i32) -> (i32, i32) {
478    let ctx_bytes = unsafe {
479        core::slice::from_raw_parts(ctx_ptr as *const u8, ctx_len as usize)
480    };
481    let ctx: GuestContext = match serde_json::from_slice(ctx_bytes) {
482        Ok(c) => c,
483        Err(e) => {
484            return write_result(&GuestInvariantResult {
485                ok: false,
486                reason: Some(format!("failed to parse context: {}", e)),
487                fact_ids: Vec::new(),
488            });
489        }
490    };
491
492    let result = check(&ctx);
493    write_result(&result)
494}
495
496fn write_result(result: &GuestInvariantResult) -> (i32, i32) {
497    let json = serde_json::to_vec(result).expect("serialize result");
498    let ptr = alloc(json.len() as i32);
499    unsafe {
500        core::ptr::copy_nonoverlapping(json.as_ptr(), ptr as *mut u8, json.len());
501    }
502    (ptr, json.len() as i32)
503}
504"#;
505
506// ---------------------------------------------------------------------------
507// Check body generation
508// ---------------------------------------------------------------------------
509
510/// Generate the body of the `check()` function from predicates.
511fn generate_check_body(predicates: &[Predicate]) -> String {
512    if predicates.is_empty() {
513        return "    // No predicates — invariant always holds\n".to_string();
514    }
515
516    let mut body = String::new();
517    for (i, pred) in predicates.iter().enumerate() {
518        body.push_str(&format!(
519            "    // Check {}: {}\n",
520            i + 1,
521            predicate_summary(pred)
522        ));
523        body.push_str(&predicate_to_rust(pred));
524        body.push('\n');
525    }
526    body
527}
528
529/// One-line summary of a predicate for code comments.
530fn predicate_summary(pred: &Predicate) -> String {
531    match pred {
532        Predicate::CountAtLeast { key, min } => {
533            format!("{key} must have at least {min} facts")
534        }
535        Predicate::CountAtMost { key, max } => {
536            format!("{key} must have at most {max} facts")
537        }
538        Predicate::ContentMustNotContain { key, forbidden } => {
539            format!("{key} must not contain {} forbidden terms", forbidden.len())
540        }
541        Predicate::ContentMustContain {
542            key,
543            required_field,
544        } => {
545            format!("{key} facts must contain field '{required_field}'")
546        }
547        Predicate::CrossReference {
548            source_key,
549            target_key,
550        } => {
551            format!("each {source_key} must be referenced by a {target_key}")
552        }
553        Predicate::HasFacts { key } => format!("{key} must have facts"),
554        Predicate::RequiredFields { key, fields } => {
555            format!("{key} facts must have {} required fields", fields.len())
556        }
557        Predicate::Custom { description } => {
558            let short = if description.len() > 60 {
559                format!("{}...", &description[..60])
560            } else {
561                description.clone()
562            };
563            format!("custom: {short}")
564        }
565    }
566}
567
568/// Generate Rust code for a single predicate check.
569///
570/// Returns a block of Rust code (with leading indentation) that evaluates
571/// the predicate against `ctx: &GuestContext` and returns early with a
572/// `GuestInvariantResult` violation if the check fails.
573fn predicate_to_rust(pred: &Predicate) -> String {
574    match pred {
575        Predicate::CountAtLeast { key, min } => {
576            let key_e = esc(key);
577            let mut c = String::new();
578            c.push_str("    {\n");
579            c.push_str(&format!(
580                "        let count = ctx.facts.get(\"{key_e}\").map(|v| v.len()).unwrap_or(0);\n"
581            ));
582            c.push_str(&format!("        if count < {min} {{\n"));
583            c.push_str("            return GuestInvariantResult {\n");
584            c.push_str("                ok: false,\n");
585            c.push_str(&format!(
586                "                reason: Some(format!(\"{key_e} contains {{}} facts, need at least {min}\", count)),\n"
587            ));
588            c.push_str("                fact_ids: Vec::new(),\n");
589            c.push_str("            };\n");
590            c.push_str("        }\n");
591            c.push_str("    }\n");
592            c
593        }
594
595        Predicate::CountAtMost { key, max } => {
596            let key_e = esc(key);
597            let mut c = String::new();
598            c.push_str("    {\n");
599            c.push_str(&format!(
600                "        let count = ctx.facts.get(\"{key_e}\").map(|v| v.len()).unwrap_or(0);\n"
601            ));
602            c.push_str(&format!("        if count > {max} {{\n"));
603            c.push_str("            return GuestInvariantResult {\n");
604            c.push_str("                ok: false,\n");
605            c.push_str(&format!(
606                "                reason: Some(format!(\"{key_e} contains {{}} facts, max is {max}\", count)),\n"
607            ));
608            c.push_str("                fact_ids: Vec::new(),\n");
609            c.push_str("            };\n");
610            c.push_str("        }\n");
611            c.push_str("    }\n");
612            c
613        }
614
615        Predicate::ContentMustNotContain { key, forbidden } => {
616            let key_e = esc(key);
617            let mut c = String::new();
618            c.push_str(&format!(
619                "    if let Some(facts) = ctx.facts.get(\"{key_e}\") {{\n"
620            ));
621            c.push_str("        for fact in facts {\n");
622            c.push_str("            let content_lower = fact.content.to_lowercase();\n");
623            for term in forbidden {
624                let term_lower = esc(&term.term.to_lowercase());
625                let term_display = esc(&term.term);
626                let reason_display = esc(&term.reason);
627                c.push_str(&format!(
628                    "            if content_lower.contains(\"{term_lower}\") {{\n"
629                ));
630                c.push_str("                return GuestInvariantResult {\n");
631                c.push_str("                    ok: false,\n");
632                c.push_str(&format!(
633                    "                    reason: Some(format!(\"{key_e} fact '{{}}' contains forbidden term: {term_display} ({reason_display})\", fact.id)),\n"
634                ));
635                c.push_str("                    fact_ids: vec![fact.id.clone()],\n");
636                c.push_str("                };\n");
637                c.push_str("            }\n");
638            }
639            c.push_str("        }\n");
640            c.push_str("    }\n");
641            c
642        }
643
644        Predicate::ContentMustContain {
645            key,
646            required_field,
647        } => {
648            let key_e = esc(key);
649            let field_e = esc(required_field);
650            let mut c = String::new();
651            c.push_str(&format!(
652                "    if let Some(facts) = ctx.facts.get(\"{key_e}\") {{\n"
653            ));
654            c.push_str("        for fact in facts {\n");
655            c.push_str(&format!(
656                "            if !fact.content.contains(\"{field_e}\") {{\n"
657            ));
658            c.push_str("                return GuestInvariantResult {\n");
659            c.push_str("                    ok: false,\n");
660            c.push_str(&format!(
661                "                    reason: Some(format!(\"{key_e} fact '{{}}' missing required field: {field_e}\", fact.id)),\n"
662            ));
663            c.push_str("                    fact_ids: vec![fact.id.clone()],\n");
664            c.push_str("                };\n");
665            c.push_str("            }\n");
666            c.push_str("        }\n");
667            c.push_str("    }\n");
668            c
669        }
670
671        Predicate::CrossReference {
672            source_key,
673            target_key,
674        } => {
675            let src_e = esc(source_key);
676            let tgt_e = esc(target_key);
677            let mut c = String::new();
678            c.push_str(&format!(
679                "    if let Some(source_facts) = ctx.facts.get(\"{src_e}\") {{\n"
680            ));
681            c.push_str(&format!(
682                "        let target_facts = ctx.facts.get(\"{tgt_e}\");\n"
683            ));
684            c.push_str("        let empty = Vec::new();\n");
685            c.push_str("        let targets = target_facts.unwrap_or(&empty);\n");
686            c.push_str("        for source in source_facts {\n");
687            c.push_str(
688                "            let referenced = targets.iter().any(|t| t.content.contains(&source.id));\n",
689            );
690            c.push_str("            if !referenced {\n");
691            c.push_str("                return GuestInvariantResult {\n");
692            c.push_str("                    ok: false,\n");
693            c.push_str(&format!(
694                "                    reason: Some(format!(\"{src_e} fact '{{}}' has no corresponding {tgt_e}\", source.id)),\n"
695            ));
696            c.push_str("                    fact_ids: vec![source.id.clone()],\n");
697            c.push_str("                };\n");
698            c.push_str("            }\n");
699            c.push_str("        }\n");
700            c.push_str("    }\n");
701            c
702        }
703
704        Predicate::HasFacts { key } => {
705            let key_e = esc(key);
706            let mut c = String::new();
707            c.push_str(&format!(
708                "    if ctx.facts.get(\"{key_e}\").map(|v| v.is_empty()).unwrap_or(true) {{\n"
709            ));
710            c.push_str("        return GuestInvariantResult {\n");
711            c.push_str("            ok: false,\n");
712            c.push_str(&format!(
713                "            reason: Some(\"{key_e} must contain at least one fact\".to_string()),\n"
714            ));
715            c.push_str("            fact_ids: Vec::new(),\n");
716            c.push_str("        };\n");
717            c.push_str("    }\n");
718            c
719        }
720
721        Predicate::RequiredFields { key, fields } => {
722            let key_e = esc(key);
723            let mut c = String::new();
724            c.push_str(&format!(
725                "    if let Some(facts) = ctx.facts.get(\"{key_e}\") {{\n"
726            ));
727            c.push_str("        for fact in facts {\n");
728            for field in fields {
729                let field_e = esc(&field.field);
730                let rule_e = esc(&field.rule);
731                c.push_str(&format!(
732                    "            if !fact.content.contains(\"{field_e}\") {{\n"
733                ));
734                c.push_str("                return GuestInvariantResult {\n");
735                c.push_str("                    ok: false,\n");
736                c.push_str(&format!(
737                    "                    reason: Some(format!(\"{key_e} fact '{{}}' missing required field: {field_e} ({rule_e})\", fact.id)),\n"
738                ));
739                c.push_str("                    fact_ids: vec![fact.id.clone()],\n");
740                c.push_str("                };\n");
741                c.push_str("            }\n");
742            }
743            c.push_str("        }\n");
744            c.push_str("    }\n");
745            c
746        }
747
748        Predicate::Custom { description } => {
749            let safe = description.replace("*/", "* /").replace('\\', "\\\\");
750            let mut c = String::new();
751            c.push_str("    // TODO: Custom predicate — manual implementation needed\n");
752            c.push_str(&format!("    // Original step: \"{safe}\"\n"));
753            c
754        }
755    }
756}
757
758/// Escape a string for use inside a Rust string literal.
759fn esc(s: &str) -> String {
760    s.replace('\\', "\\\\")
761        .replace('"', "\\\"")
762        .replace('\n', "\\n")
763        .replace('\r', "\\r")
764        .replace('\t', "\\t")
765}
766
767/// Format a string as a Rust raw string literal `r#"..."#`.
768///
769/// Automatically determines the minimum number of `#` delimiters needed
770/// to safely wrap the content.
771fn format_raw_string(s: &str) -> String {
772    // Find the longest run of consecutive '#' in the string
773    let mut max_hashes = 0;
774    let mut current = 0;
775    for c in s.chars() {
776        if c == '#' {
777            current += 1;
778            if current > max_hashes {
779                max_hashes = current;
780            }
781        } else {
782            current = 0;
783        }
784    }
785
786    // Also check for `"` followed by '#' runs that could close the raw string
787    let hashes_needed = if s.contains('"') { max_hashes + 1 } else { 1 };
788
789    let delim = "#".repeat(hashes_needed);
790    format!("r{delim}\"{s}\"{delim}")
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796    use crate::predicate::{FieldRequirement, ForbiddenTerm};
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}