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 | 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 #[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 #[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 #[must_use]
244 pub fn with_version(mut self, version: &str) -> Self {
245 self.version = version.to_string();
246 self
247 }
248
249 #[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 #[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 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
318pub(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
336pub struct CodegenConfig {
342 pub manifest_json: String,
344 pub module_name: String,
346}
347
348pub 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 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 s.push_str(GUEST_TYPES);
389
390 s.push_str("const MANIFEST_JSON: &str = ");
392 s.push_str(&manifest_literal);
393 s.push_str(";\n\n");
394
395 s.push_str(ALLOCATOR_CODE);
397
398 s.push_str(ABI_EXPORTS);
400
401 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 s.push_str(CHECK_INVARIANT_WRAPPER);
413
414 s
415}
416
417const 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
506fn 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
529fn 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
568fn 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
758fn esc(s: &str) -> String {
760 s.replace('\\', "\\\\")
761 .replace('"', "\\\"")
762 .replace('\n', "\\n")
763 .replace('\r', "\\r")
764 .replace('\t', "\\t")
765}
766
767fn format_raw_string(s: &str) -> String {
772 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 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 #[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}