//! Manifest::validate covers the contract between authors (humans or AI) //! and Nakui. Each test inline-builds a manifest with one specific defect //! and asserts the right diagnostic fires. //! //! Most tests point at `modules/treasury/` so the schema/script paths //! resolve. Two tests need a synthetic tempdir to express their defect //! (missing schema file, duplicate schema across files). use std::fs; use std::path::{Path, PathBuf}; use nakui_core::executor::Executor; use nakui_core::manifest::{ ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec, ValidationError, }; use uuid::Uuid; fn workspace_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .expect("workspace root above core/") .to_path_buf() } fn treasury_dir() -> PathBuf { workspace_root().join("modules/treasury") } fn caja_input() -> MorphismInput { MorphismInput { role: "caja".into(), entity: "Caja".into(), } } fn baseline_morphism() -> MorphismSpec { MorphismSpec { name: "test_op".into(), inputs: vec![caja_input()], reads: vec!["caja.saldo".into()], writes: vec!["caja.saldo".into()], invariants: Invariants::default(), depends_on: vec![], script: "morphisms/register_cash_move.rhai".into(), } } fn baseline_manifest() -> Manifest { Manifest { module: "test".into(), schemas: vec![], morphisms: vec![baseline_morphism()], } } #[test] fn production_modules_validate_clean() { for name in ["treasury", "inventory", "sales"] { let dir = workspace_root().join("modules").join(name); let manifest = Manifest::load(&dir.join("nsmc.json")) .unwrap_or_else(|e| panic!("load {}: {}", name, e)); manifest .validate(&dir) .unwrap_or_else(|e| panic!("validate {}: {}", name, e)); } } #[test] fn rejects_duplicate_morphism_name() { let mut m = baseline_manifest(); m.morphisms.push(baseline_morphism()); // same name as the first match m.validate(&treasury_dir()) { Err(ValidationError::DuplicateMorphism(name)) => assert_eq!(name, "test_op"), other => panic!("expected DuplicateMorphism, got {:?}", other), } } #[test] fn rejects_duplicate_role_within_morphism() { let mut m = baseline_manifest(); m.morphisms[0].inputs.push(caja_input()); // same role twice match m.validate(&treasury_dir()) { Err(ValidationError::DuplicateRole { morphism, role }) => { assert_eq!(morphism, "test_op"); assert_eq!(role, "caja"); } other => panic!("expected DuplicateRole, got {:?}", other), } } #[test] fn rejects_input_unknown_entity() { let mut m = baseline_manifest(); m.morphisms[0].inputs[0].entity = "Banana".into(); match m.validate(&treasury_dir()) { Err(ValidationError::InputUnknownEntity { morphism, entity, known, }) => { assert_eq!(morphism, "test_op"); assert_eq!(entity, "Banana"); assert!(known.contains(&"Caja".to_string())); } other => panic!("expected InputUnknownEntity, got {:?}", other), } } #[test] fn rejects_writes_unknown_role() { let mut m = baseline_manifest(); m.morphisms[0].writes = vec!["ghost.saldo".into()]; match m.validate(&treasury_dir()) { Err(ValidationError::WritesUnknownRole { morphism, token, role, .. }) => { assert_eq!(morphism, "test_op"); assert_eq!(token, "ghost.saldo"); assert_eq!(role, "ghost"); } other => panic!("expected WritesUnknownRole, got {:?}", other), } } #[test] fn rejects_writes_unknown_entity() { let mut m = baseline_manifest(); m.morphisms[0].writes = vec!["BananaSplit".into()]; match m.validate(&treasury_dir()) { Err(ValidationError::WritesUnknownEntity { morphism, token }) => { assert_eq!(morphism, "test_op"); assert_eq!(token, "BananaSplit"); } other => panic!("expected WritesUnknownEntity, got {:?}", other), } } #[test] fn rejects_conserve_unknown_entity() { let mut m = baseline_manifest(); m.morphisms[0].invariants.conserve = vec![ConserveRule { entity: "Banana".into(), field: "x".into(), group_by: None, }]; match m.validate(&treasury_dir()) { Err(ValidationError::ConserveUnknownEntity { morphism, entity }) => { assert_eq!(morphism, "test_op"); assert_eq!(entity, "Banana"); } other => panic!("expected ConserveUnknownEntity, got {:?}", other), } } #[test] fn rejects_depends_on_unknown_morphism() { let mut m = baseline_manifest(); m.morphisms[0].depends_on = vec!["ghost_morphism".into()]; match m.validate(&treasury_dir()) { Err(ValidationError::DependsOnUnknown { morphism, dep }) => { assert_eq!(morphism, "test_op"); assert_eq!(dep, "ghost_morphism"); } other => panic!("expected DependsOnUnknown, got {:?}", other), } } #[test] fn rejects_missing_script() { let mut m = baseline_manifest(); m.morphisms[0].script = "morphisms/ghost.rhai".into(); match m.validate(&treasury_dir()) { Err(ValidationError::ScriptMissing { morphism, script, .. }) => { assert_eq!(morphism, "test_op"); assert_eq!(script, "morphisms/ghost.rhai"); } other => panic!("expected ScriptMissing, got {:?}", other), } } #[test] fn rejects_missing_schema_file() { let mut m = baseline_manifest(); m.schemas = vec!["nonexistent.k".into()]; match m.validate(&treasury_dir()) { Err(ValidationError::SchemaFileMissing { path, .. }) => { assert_eq!(path, "nonexistent.k"); } other => panic!("expected SchemaFileMissing, got {:?}", other), } } #[test] fn rejects_duplicate_schema_across_files() { // Synthesize a tempdir with two .ncl files that both declare // `Caja` en el record top-level. let tmp = std::env::temp_dir().join(format!("nakui_dup_{}", Uuid::new_v4())); fs::create_dir_all(&tmp).unwrap(); fs::create_dir_all(tmp.join("morphisms")).unwrap(); fs::write( tmp.join("a.ncl"), "{\n Caja = { saldo | Number },\n}\n", ) .unwrap(); fs::write( tmp.join("b.ncl"), "{\n Caja = { monto | Number },\n}\n", ) .unwrap(); fs::write(tmp.join("morphisms/op.rhai"), "[]").unwrap(); let m = Manifest { module: "dup".into(), schemas: vec!["a.ncl".into(), "b.ncl".into()], morphisms: vec![MorphismSpec { name: "op".into(), inputs: vec![MorphismInput { role: "caja".into(), entity: "Caja".into(), }], reads: vec![], writes: vec![], invariants: Invariants::default(), depends_on: vec![], script: "morphisms/op.rhai".into(), }], }; match m.validate(&tmp) { Err(ValidationError::DuplicateSchema { name, files }) => { assert_eq!(name, "Caja"); assert!(files.contains(&"a.ncl".to_string())); assert!(files.contains(&"b.ncl".to_string())); } other => panic!("expected DuplicateSchema, got {:?}", other), } let _ = fs::remove_dir_all(&tmp); } #[test] fn executor_load_module_runs_validation() { // Synthesize a module dir whose manifest references a missing script — // load_module must surface ManifestValidation, not a runtime kernel error. let tmp = std::env::temp_dir().join(format!("nakui_bad_{}", Uuid::new_v4())); fs::create_dir_all(&tmp).unwrap(); fs::write( tmp.join("schema.ncl"), "{\n Caja = {\n saldo | std.contract.from_predicate (fun n => std.is_number n && n >= 0),\n },\n}\n", ) .unwrap(); fs::write( tmp.join("nsmc.json"), r#"{ "module": "bad", "morphisms": [{ "name": "op", "inputs": [{"role": "caja", "entity": "Caja"}], "reads": [], "writes": ["caja.saldo"], "depends_on": [], "script": "morphisms/missing.rhai" }] }"#, ) .unwrap(); let err = match Executor::load_module(&tmp) { Ok(_) => panic!("must fail validation"), Err(e) => e, }; let msg = err.to_string(); assert!( msg.contains("validation") && msg.contains("missing.rhai"), "expected validation diagnostic naming the missing script, got `{}`", msg ); let _ = fs::remove_dir_all(&tmp); }