1use 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#[derive(Debug, Clone)]
55pub enum ManifestError {
56 MissingInvariantClass,
58 MissingDependencies,
60 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#[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#[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
115pub 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 #[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 #[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 #[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 #[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 #[must_use]
245 pub fn with_version(mut self, version: &str) -> Self {
246 self.version = version.to_string();
247 self
248 }
249
250 #[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 #[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 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
319pub(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
337pub struct CodegenConfig {
343 pub manifest_json: String,
345 pub module_name: String,
347}
348
349pub 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 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 s.push_str(GUEST_TYPES);
390
391 s.push_str("const MANIFEST_JSON: &str = ");
393 s.push_str(&manifest_literal);
394 s.push_str(";\n\n");
395
396 s.push_str(ALLOCATOR_CODE);
398
399 s.push_str(ABI_EXPORTS);
401
402 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 s.push_str(CHECK_INVARIANT_WRAPPER);
414
415 s
416}
417
418const 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
507fn 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
530fn 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
569fn 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
759fn esc(s: &str) -> String {
761 s.replace('\\', "\\\\")
762 .replace('"', "\\\"")
763 .replace('\n', "\\n")
764 .replace('\r', "\\r")
765 .replace('\t', "\\t")
766}
767
768fn format_raw_string(s: &str) -> String {
773 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 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 #[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 #[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, 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 #[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 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 let _ = ManifestBuilder::new()
1258 .from_scenario_meta(&meta)
1259 .build();
1260 }
1261 }
1262 }
1263}