diff --git a/crates/modules/nakui/core/Cargo.toml b/crates/modules/nakui/core/Cargo.toml index 1dce843..48d3073 100644 --- a/crates/modules/nakui/core/Cargo.toml +++ b/crates/modules/nakui/core/Cargo.toml @@ -56,3 +56,7 @@ path = "src/bin/inventory_demo.rs" [[bin]] name = "sales_demo" path = "src/bin/sales_demo.rs" + +[[bin]] +name = "crm_demo" +path = "src/bin/crm_demo.rs" diff --git a/crates/modules/nakui/core/src/bin/crm_demo.rs b/crates/modules/nakui/core/src/bin/crm_demo.rs new file mode 100644 index 0000000..f56bd31 --- /dev/null +++ b/crates/modules/nakui/core/src/bin/crm_demo.rs @@ -0,0 +1,307 @@ +//! Demo del módulo `crm`: un escenario realista — tres clientes, sus +//! oportunidades recorriendo el pipeline de ventas, e interacciones. +//! +//! A diferencia de los otros demos, **no borra el event log**: lo deja +//! en disco para que `nakui-explorer` lo muestre. Al terminar imprime +//! el comando exacto para abrir el explorador sobre este log. +//! +//! ```sh +//! cargo run -p nakui-core --bin crm_demo +//! # …luego, con la ruta que imprime: +//! NAKUI_EVENT_LOG=/tmp/nakui-crm.jsonl cargo run -p nakui-explorer +//! ``` + +use std::path::{Path, PathBuf}; + +use nakui_core::event_log::{execute_and_log, seed_and_log, EventLog, ExecuteError, LogEntry}; +use nakui_core::executor::Executor; +use nakui_core::store::{MemoryStore, Store}; +use serde_json::json; +use uuid::Uuid; + +const TS: &str = "2026-05-21T12:00:00Z"; + +fn main() { + let module_dir = std::env::var("NAKUI_MODULE") + .map(PathBuf::from) + .unwrap_or_else(|_| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("dir del módulo nakui sobre core/") + .join("modules/crm") + }); + let exec = Executor::load_module(&module_dir).expect("cargar el módulo crm"); + + let log_path = std::env::var("NAKUI_EVENT_LOG") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir().join("nakui-crm.jsonl")); + let _ = std::fs::remove_file(&log_path); // empezar de cero + let mut log = EventLog::open(&log_path).expect("abrir el event log"); + let mut store = MemoryStore::new(); + + // --- Seed: tres clientes ------------------------------------------- + section("seed · 3 clientes"); + let acme = Uuid::new_v4(); + let beta = Uuid::new_v4(); + let gamma = Uuid::new_v4(); + seed_cliente( + &exec, + &mut store, + &mut log, + acme, + "Acme Corp", + "compras@acme.com", + ); + seed_cliente(&exec, &mut store, &mut log, beta, "Beta SA", "ti@beta.com"); + seed_cliente( + &exec, + &mut store, + &mut log, + gamma, + "Gamma Ltda", + "ceo@gamma.com", + ); + + // --- Acme: una oportunidad que se gana ----------------------------- + section("Acme · «Licencia anual» $12 000 — recorre el pipeline"); + let opp_acme = Uuid::new_v4(); + abrir( + &exec, + &mut store, + &mut log, + acme, + opp_acme, + "Licencia anual", + 12_000, + ); + interaccion( + &exec, + &mut store, + &mut log, + acme, + "llamada", + "Primer contacto, interés alto", + ); + for etapa in ["calificado", "propuesta", "negociacion", "ganada"] { + mover(&exec, &mut store, &mut log, opp_acme, etapa); + } + interaccion( + &exec, + &mut store, + &mut log, + acme, + "email", + "Contrato firmado recibido", + ); + + // --- Beta: una oportunidad que se pierde --------------------------- + section("Beta · «Piloto trimestral» $3 000 — se pierde"); + let opp_beta = Uuid::new_v4(); + abrir( + &exec, + &mut store, + &mut log, + beta, + opp_beta, + "Piloto trimestral", + 3_000, + ); + interaccion( + &exec, + &mut store, + &mut log, + beta, + "reunion", + "Demo en sus oficinas", + ); + mover(&exec, &mut store, &mut log, opp_beta, "calificado"); + mover(&exec, &mut store, &mut log, opp_beta, "propuesta"); + mover(&exec, &mut store, &mut log, opp_beta, "perdida"); + + // --- Gamma: una oportunidad en curso ------------------------------- + section("Gamma · «Expansión regional» $25 000 — en curso"); + let opp_gamma = Uuid::new_v4(); + abrir( + &exec, + &mut store, + &mut log, + gamma, + opp_gamma, + "Expansión regional", + 25_000, + ); + mover(&exec, &mut store, &mut log, opp_gamma, "calificado"); + interaccion( + &exec, + &mut store, + &mut log, + gamma, + "llamada", + "Pidieron referencias", + ); + + // --- Operaciones inválidas: el kernel las rechaza, no se loguean --- + section("validaciones · estas operaciones se rechazan"); + mover(&exec, &mut store, &mut log, opp_acme, "propuesta"); // ya cerrada + mover(&exec, &mut store, &mut log, opp_gamma, "prospecto"); // retroceso + abrir( + &exec, + &mut store, + &mut log, + gamma, + Uuid::new_v4(), + "Trato inválido", + -500, + ); + interaccion( + &exec, + &mut store, + &mut log, + gamma, + "paloma", + "canal inexistente", + ); + + // --- Estado final -------------------------------------------------- + section("estado final · oportunidades"); + print_oportunidad(&store, "Acme ", opp_acme); + print_oportunidad(&store, "Beta ", opp_beta); + print_oportunidad(&store, "Gamma", opp_gamma); + + let entries = log.entries().expect("leer el log"); + let seeds = entries + .iter() + .filter(|e| matches!(e, LogEntry::Seed { .. })) + .count(); + let morphs = entries.len() - seeds; + section(&format!( + "log · {} eventos ({seeds} seeds, {morphs} morfismos)", + entries.len() + )); + println!(" archivo: {}", log_path.display()); + println!(); + println!("para ver el módulo CRM en el explorador:"); + println!( + " NAKUI_EVENT_LOG={} cargo run -p nakui-explorer", + log_path.display() + ); +} + +fn seed_cliente( + exec: &Executor, + store: &mut MemoryStore, + log: &mut EventLog, + id: Uuid, + nombre: &str, + email: &str, +) { + seed_and_log( + exec, + store, + log, + "Cliente", + id, + json!({ + "id": id.to_string(), + "nombre": nombre, + "email": email, + "empresa": nombre, + }), + ) + .unwrap_or_else(|e| panic!("seed cliente {nombre}: {e}")); + println!(" ok · cliente {nombre}"); +} + +fn abrir( + exec: &Executor, + store: &mut MemoryStore, + log: &mut EventLog, + cliente: Uuid, + opp: Uuid, + titulo: &str, + monto: i64, +) { + report( + &format!("abrir_oportunidad «{titulo}»"), + execute_and_log( + exec, + store, + log, + "abrir_oportunidad", + &[("cliente", cliente)], + json!({ + "oportunidad_id": opp.to_string(), + "titulo": titulo, + "monto": monto, + "currency": "USD", + "timestamp": TS, + }), + ), + ); +} + +fn mover(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, opp: Uuid, destino: &str) { + report( + &format!("mover_oportunidad → {destino}"), + execute_and_log( + exec, + store, + log, + "mover_oportunidad", + &[("oportunidad", opp)], + json!({ "etapa": destino, "timestamp": TS }), + ), + ); +} + +fn interaccion( + exec: &Executor, + store: &mut MemoryStore, + log: &mut EventLog, + cliente: Uuid, + canal: &str, + nota: &str, +) { + report( + &format!("registrar_interaccion ({canal})"), + execute_and_log( + exec, + store, + log, + "registrar_interaccion", + &[("cliente", cliente)], + json!({ + "interaccion_id": Uuid::new_v4().to_string(), + "canal": canal, + "nota": nota, + "timestamp": TS, + }), + ), + ); +} + +/// Reporta el resultado de un morfismo. Genérico sobre el tipo de op +/// para no exponer el tipo interno del executor. +fn report(label: &str, result: Result, ExecuteError>) { + match result { + Ok(ops) => println!(" ok · {label} ({} ops)", ops.len()), + Err(ExecuteError::PreLog(e)) => println!(" rechazado · {label}: {e}"), + Err(e) => println!(" ERROR · {label}: {e:?}"), + } +} + +fn print_oportunidad(store: &MemoryStore, etiqueta: &str, id: Uuid) { + match store.load("Oportunidad", id) { + Some(v) => { + let titulo = v.get("titulo").and_then(|x| x.as_str()).unwrap_or("?"); + let etapa = v.get("etapa").and_then(|x| x.as_str()).unwrap_or("?"); + let monto = v.get("monto").and_then(|x| x.as_i64()).unwrap_or(0); + println!(" {etiqueta} · {titulo} — ${monto} — etapa: {etapa}"); + } + None => println!(" {etiqueta} · (sin oportunidad)"), + } +} + +fn section(title: &str) { + println!("\n— {title}"); +} diff --git a/crates/modules/nakui/core/src/bin/demo.rs b/crates/modules/nakui/core/src/bin/demo.rs index 93c9254..8abf9df 100644 --- a/crates/modules/nakui/core/src/bin/demo.rs +++ b/crates/modules/nakui/core/src/bin/demo.rs @@ -1,5 +1,5 @@ use nakui_core::event_log::{ - EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log, + execute_and_log, replay, seed_and_log, verify_log, EventLog, ExecuteError, }; use nakui_core::executor::Executor; use nakui_core::store::{MemoryStore, Store}; @@ -7,8 +7,7 @@ use serde_json::json; use uuid::Uuid; fn main() { - let module_dir = - std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/treasury".into()); + let module_dir = std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/treasury".into()); let exec = Executor::load_module(&module_dir).expect("load module"); let log_path = std::env::temp_dir().join(format!("nakui_demo_{}.jsonl", Uuid::new_v4())); @@ -162,10 +161,7 @@ fn main() { seq, entity, id, .. } => println!(" #{:02} seed {} {}", seq, entity, id), nakui_core::event_log::LogEntry::Morphism { - seq, - morphism, - ops, - .. + seq, morphism, ops, .. } => println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()), } } @@ -180,16 +176,17 @@ fn main() { section("== determinism verification (ops) =="); match verify_log(&log, &exec) { - Ok(()) => println!( - " ok: every logged morphism reproduced its ops on re-execution" - ), + Ok(()) => println!(" ok: every logged morphism reproduced its ops on re-execution"), Err(e) => println!(" nondeterminism detected: {}", e), } if std::env::var_os("NAKUI_DEMO_KEEP").is_none() { let _ = std::fs::remove_file(&log_path); } else { - println!("\n(NAKUI_DEMO_KEEP set — keeping log at {})", log_path.display()); + println!( + "\n(NAKUI_DEMO_KEEP set — keeping log at {})", + log_path.display() + ); } } @@ -202,7 +199,11 @@ fn run_and_report( params: serde_json::Value, ) { match execute_and_log(exec, store, log, morphism, inputs, params) { - Ok(ops) => println!(" ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1), + Ok(ops) => println!( + " ok ({} ops, logged at #{})", + ops.len(), + log.next_seq() - 1 + ), Err(ExecuteError::PreLog(e)) => println!(" rejected: {}", e), Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e), Err(ExecuteError::PostLogStore(e)) => println!( diff --git a/crates/modules/nakui/core/src/bin/inventory_demo.rs b/crates/modules/nakui/core/src/bin/inventory_demo.rs index 606a556..bdfa801 100644 --- a/crates/modules/nakui/core/src/bin/inventory_demo.rs +++ b/crates/modules/nakui/core/src/bin/inventory_demo.rs @@ -1,5 +1,5 @@ use nakui_core::event_log::{ - EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log, + execute_and_log, replay, seed_and_log, verify_log, EventLog, ExecuteError, }; use nakui_core::executor::Executor; use nakui_core::store::{MemoryStore, Store}; @@ -7,12 +7,10 @@ use serde_json::json; use uuid::Uuid; fn main() { - let module_dir = std::env::var("NAKUI_MODULE") - .unwrap_or_else(|_| "modules/inventory".into()); + let module_dir = std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/inventory".into()); let exec = Executor::load_module(&module_dir).expect("load module"); - let log_path = - std::env::temp_dir().join(format!("nakui_inv_{}.jsonl", Uuid::new_v4())); + let log_path = std::env::temp_dir().join(format!("nakui_inv_{}.jsonl", Uuid::new_v4())); let mut log = EventLog::open(&log_path).expect("open log"); let mut store = MemoryStore::new(); @@ -23,34 +21,46 @@ fn main() { let stock_c = Uuid::new_v4(); seed_and_log( &exec, - &mut store, &mut log, "Stock", stock_a, + &mut store, + &mut log, + "Stock", + stock_a, json!({ "id": stock_a.to_string(), "sku_id": "kg-cafe-honduras-2026", "ubicacion": "almacen-norte", "cantidad": 500_i64, }), - ).expect("seed A"); + ) + .expect("seed A"); seed_and_log( &exec, - &mut store, &mut log, "Stock", stock_b, + &mut store, + &mut log, + "Stock", + stock_b, json!({ "id": stock_b.to_string(), "sku_id": "kg-cafe-honduras-2026", "ubicacion": "almacen-sur", "cantidad": 100_i64, }), - ).expect("seed B"); + ) + .expect("seed B"); seed_and_log( &exec, - &mut store, &mut log, "Stock", stock_c, + &mut store, + &mut log, + "Stock", + stock_c, json!({ "id": stock_c.to_string(), "sku_id": "lt-aceite-girasol", "ubicacion": "almacen-sur", "cantidad": 200_i64, }), - ).expect("seed C"); + ) + .expect("seed C"); section("== seed =="); print_stock(&store, "A (cafe norte)", stock_a); @@ -58,7 +68,11 @@ fn main() { print_stock(&store, "C (aceite sur)", stock_c); section("== recibir 250 kg cafe en A =="); - run_and_report(&exec, &mut store, &mut log, "recibir_stock", + run_and_report( + &exec, + &mut store, + &mut log, + "recibir_stock", &[("stock", stock_a)], json!({ "cantidad": 250_i64, @@ -69,7 +83,11 @@ fn main() { print_stock(&store, "A", stock_a); section("== transferir 200 kg cafe A -> B (conserva por sku_id) =="); - run_and_report(&exec, &mut store, &mut log, "transferir_stock", + run_and_report( + &exec, + &mut store, + &mut log, + "transferir_stock", &[("source", stock_a), ("dest", stock_b)], json!({ "cantidad": 200_i64, @@ -81,7 +99,11 @@ fn main() { print_stock(&store, "B", stock_b); section("== transferir 999_999 kg cafe A -> B (reject: stock <= 0) =="); - run_and_report(&exec, &mut store, &mut log, "transferir_stock", + run_and_report( + &exec, + &mut store, + &mut log, + "transferir_stock", &[("source", stock_a), ("dest", stock_b)], json!({ "cantidad": 999_999_i64, @@ -91,7 +113,11 @@ fn main() { ); section("== transferir 50 cafe(A) -> aceite(C) (reject: rhai SKU mismatch) =="); - run_and_report(&exec, &mut store, &mut log, "transferir_stock", + run_and_report( + &exec, + &mut store, + &mut log, + "transferir_stock", &[("source", stock_a), ("dest", stock_c)], json!({ "cantidad": 50_i64, @@ -113,10 +139,12 @@ fn main() { )); for e in &entries { match e { - nakui_core::event_log::LogEntry::Seed { seq, entity, id, .. } => - println!(" #{:02} seed {} {}", seq, entity, id), - nakui_core::event_log::LogEntry::Morphism { seq, morphism, ops, .. } => - println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()), + nakui_core::event_log::LogEntry::Seed { + seq, entity, id, .. + } => println!(" #{:02} seed {} {}", seq, entity, id), + nakui_core::event_log::LogEntry::Morphism { + seq, morphism, ops, .. + } => println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()), } } @@ -130,9 +158,7 @@ fn main() { section("== determinism verification (ops) =="); match verify_log(&log, &exec) { - Ok(()) => println!( - " ok: every logged morphism reproduced its ops on re-execution" - ), + Ok(()) => println!(" ok: every logged morphism reproduced its ops on re-execution"), Err(e) => println!(" nondeterminism detected: {}", e), } @@ -148,11 +174,16 @@ fn run_and_report( params: serde_json::Value, ) { match execute_and_log(exec, store, log, morphism, inputs, params) { - Ok(ops) => println!(" ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1), + Ok(ops) => println!( + " ok ({} ops, logged at #{})", + ops.len(), + log.next_seq() - 1 + ), Err(ExecuteError::PreLog(e)) => println!(" rejected: {}", e), Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e), Err(ExecuteError::PostLogStore(e)) => println!( - " POST-LOG STORE FAILED (log canonical, store stale): {}", e + " POST-LOG STORE FAILED (log canonical, store stale): {}", + e ), } } @@ -162,7 +193,10 @@ fn print_stock(store: &MemoryStore, label: &str, id: Uuid) { let cantidad = v.get("cantidad").and_then(|v| v.as_i64()).unwrap_or(0); let sku = v.get("sku_id").and_then(|v| v.as_str()).unwrap_or("?"); let loc = v.get("ubicacion").and_then(|v| v.as_str()).unwrap_or("?"); - println!(" {}: cantidad={} sku={} ubic={}", label, cantidad, sku, loc); + println!( + " {}: cantidad={} sku={} ubic={}", + label, cantidad, sku, loc + ); } fn section(title: &str) { diff --git a/crates/modules/nakui/core/src/bin/nakui.rs b/crates/modules/nakui/core/src/bin/nakui.rs index a3fb212..0360a97 100644 --- a/crates/modules/nakui/core/src/bin/nakui.rs +++ b/crates/modules/nakui/core/src/bin/nakui.rs @@ -13,10 +13,8 @@ use std::collections::BTreeMap; use std::path::PathBuf; use std::process::ExitCode; -use nakui_core::drift::{DriftDiff, check_against_socket}; -use nakui_core::event_log::{ - EventLog, LogEntry, Snapshot, replay_with_snapshot_into, verify_log, -}; +use nakui_core::drift::{check_against_socket, DriftDiff}; +use nakui_core::event_log::{replay_with_snapshot_into, verify_log, EventLog, LogEntry, Snapshot}; use nakui_core::executor::Executor; use nakui_core::run::run_server; use nakui_core::store::MemoryStore; @@ -111,19 +109,16 @@ fn parse_flags(args: &[String], allowed: &[&str]) -> Result( - flags: &'a BTreeMap, - name: &str, -) -> Result<&'a String, CliError> { +fn require<'a>(flags: &'a BTreeMap, name: &str) -> Result<&'a String, CliError> { flags .get(name) .ok_or_else(|| CliError::BadArgs(format!("missing required flag `--{}`", name))) @@ -141,7 +136,11 @@ fn cmd_inspect(args: &[String]) -> Result<(), CliError> { if entries.is_empty() { return Ok(()); } - println!("seq range: {}..={}", entries[0].seq(), entries.last().unwrap().seq()); + println!( + "seq range: {}..={}", + entries[0].seq(), + entries.last().unwrap().seq() + ); println!(); for e in &entries { match e { @@ -195,18 +194,18 @@ fn cmd_replay(args: &[String]) -> Result<(), CliError> { let entries = log .entries() .map_err(|e| CliError::Op(format!("read log: {}", e)))?; - let last_seq = entries.last().map(|e| e.seq().to_string()).unwrap_or_else(|| "".into()); + let last_seq = entries + .last() + .map(|e| e.seq().to_string()) + .unwrap_or_else(|| "".into()); println!("replayed log: {}", log.path().display()); if let Some(snap) = &snapshot { println!("snapshot: seq {} (covers seq <= {})", snap.seq, snap.seq); } println!("last seq: {}", last_seq); println!("entities:"); - let mut by_entity: Vec<(&String, usize)> = store - .records() - .iter() - .map(|(k, v)| (k, v.len())) - .collect(); + let mut by_entity: Vec<(&String, usize)> = + store.records().iter().map(|(k, v)| (k, v.len())).collect(); by_entity.sort_by(|a, b| a.0.cmp(b.0)); if by_entity.is_empty() { println!(" (none)"); @@ -414,7 +413,8 @@ fn cmd_compact(args: &[String]) -> Result<(), CliError> { let snap = Snapshot::load(&snap_path) .map_err(|e| CliError::Op(format!("load snapshot: {}", e)))? .ok_or_else(|| CliError::Op(format!("snapshot not found: {}", snap_path.display())))?; - let mut log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?; + let mut log = + EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?; let before = log .entries() .map(|es| es.len()) diff --git a/crates/modules/nakui/core/src/bin/sales_demo.rs b/crates/modules/nakui/core/src/bin/sales_demo.rs index d7a3946..9bb6d9f 100644 --- a/crates/modules/nakui/core/src/bin/sales_demo.rs +++ b/crates/modules/nakui/core/src/bin/sales_demo.rs @@ -5,7 +5,7 @@ //! against all three. use nakui_core::event_log::{ - EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log, + execute_and_log, replay, seed_and_log, verify_log, EventLog, ExecuteError, }; use nakui_core::executor::Executor; use nakui_core::store::{MemoryStore, Store}; @@ -13,12 +13,10 @@ use serde_json::json; use uuid::Uuid; fn main() { - let module_dir = std::env::var("NAKUI_MODULE") - .unwrap_or_else(|_| "modules/sales".into()); + let module_dir = std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/sales".into()); let exec = Executor::load_module(&module_dir).expect("load module"); - let log_path = - std::env::temp_dir().join(format!("nakui_sales_{}.jsonl", Uuid::new_v4())); + let log_path = std::env::temp_dir().join(format!("nakui_sales_{}.jsonl", Uuid::new_v4())); let mut log = EventLog::open(&log_path).expect("open log"); let mut store = MemoryStore::new(); @@ -26,24 +24,32 @@ fn main() { let caja_id = Uuid::new_v4(); seed_and_log( &exec, - &mut store, &mut log, "Stock", stock_id, + &mut store, + &mut log, + "Stock", + stock_id, json!({ "id": stock_id.to_string(), "sku_id": "kg-cafe-honduras-2026", "ubicacion": "almacen-norte", "cantidad": 500_i64, }), - ).expect("seed stock"); + ) + .expect("seed stock"); seed_and_log( &exec, - &mut store, &mut log, "Caja", caja_id, + &mut store, + &mut log, + "Caja", + caja_id, json!({ "id": caja_id.to_string(), "name": "Caja Principal", "saldo": 1_000_000_i64, // $10_000.00 in cents "currency": "USD", }), - ).expect("seed caja"); + ) + .expect("seed caja"); section("== seed =="); print_stock(&store, "stock", stock_id); @@ -51,7 +57,11 @@ fn main() { // 1. Sell 100 kg cafe at $50.00 / kg = $5000.00 total. section("== vender 100 kg @ $50.00 c/u =="); - run_and_report(&exec, &mut store, &mut log, "vender", + run_and_report( + &exec, + &mut store, + &mut log, + "vender", &[("stock", stock_id), ("caja", caja_id)], json!({ "cantidad": 100_i64, @@ -65,7 +75,11 @@ fn main() { // 2. Try selling more than available stock — should fail Stock post-check. section("== vender 9999 kg (reject: stock <= 0) =="); - run_and_report(&exec, &mut store, &mut log, "vender", + run_and_report( + &exec, + &mut store, + &mut log, + "vender", &[("stock", stock_id), ("caja", caja_id)], json!({ "cantidad": 9999_i64, @@ -77,7 +91,11 @@ fn main() { // 3. Negative price — caught by Rhai. section("== vender con precio negativo (reject: rhai throw) =="); - run_and_report(&exec, &mut store, &mut log, "vender", + run_and_report( + &exec, + &mut store, + &mut log, + "vender", &[("stock", stock_id), ("caja", caja_id)], json!({ "cantidad": 10_i64, @@ -89,7 +107,11 @@ fn main() { // 4. Another good sale. section("== vender 50 kg @ $60.00 c/u =="); - run_and_report(&exec, &mut store, &mut log, "vender", + run_and_report( + &exec, + &mut store, + &mut log, + "vender", &[("stock", stock_id), ("caja", caja_id)], json!({ "cantidad": 50_i64, @@ -113,10 +135,12 @@ fn main() { )); for e in &entries { match e { - nakui_core::event_log::LogEntry::Seed { seq, entity, id, .. } => - println!(" #{:02} seed {} {}", seq, entity, id), - nakui_core::event_log::LogEntry::Morphism { seq, morphism, ops, .. } => - println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()), + nakui_core::event_log::LogEntry::Seed { + seq, entity, id, .. + } => println!(" #{:02} seed {} {}", seq, entity, id), + nakui_core::event_log::LogEntry::Morphism { + seq, morphism, ops, .. + } => println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()), } } @@ -130,9 +154,7 @@ fn main() { section("== determinism verification (ops) =="); match verify_log(&log, &exec) { - Ok(()) => println!( - " ok: every logged morphism reproduced its ops on re-execution" - ), + Ok(()) => println!(" ok: every logged morphism reproduced its ops on re-execution"), Err(e) => println!(" nondeterminism detected: {}", e), } @@ -148,11 +170,16 @@ fn run_and_report( params: serde_json::Value, ) { match execute_and_log(exec, store, log, morphism, inputs, params) { - Ok(ops) => println!(" ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1), + Ok(ops) => println!( + " ok ({} ops, logged at #{})", + ops.len(), + log.next_seq() - 1 + ), Err(ExecuteError::PreLog(e)) => println!(" rejected: {}", e), Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e), Err(ExecuteError::PostLogStore(e)) => println!( - " POST-LOG STORE FAILED (log canonical, store stale): {}", e + " POST-LOG STORE FAILED (log canonical, store stale): {}", + e ), } } diff --git a/crates/modules/nakui/core/src/delta.rs b/crates/modules/nakui/core/src/delta.rs index 2cdf6cc..54c07b9 100644 --- a/crates/modules/nakui/core/src/delta.rs +++ b/crates/modules/nakui/core/src/delta.rs @@ -150,10 +150,7 @@ pub fn simulate_on(state: &Value, entity: &str, id: Uuid, ops: &[FieldOp]) -> Op } if e == entity && *i == id => { s = Some(data.clone()); } - FieldOp::Delete { - entity: e, - id: i, - } if e == entity && *i == id => { + FieldOp::Delete { entity: e, id: i } if e == entity && *i == id => { s = None; } _ => {} diff --git a/crates/modules/nakui/core/src/drift.rs b/crates/modules/nakui/core/src/drift.rs index 884ec6f..92b60ef 100644 --- a/crates/modules/nakui/core/src/drift.rs +++ b/crates/modules/nakui/core/src/drift.rs @@ -19,7 +19,7 @@ use std::path::Path; use thiserror::Error; use uuid::Uuid; -use crate::event_log::{EventLog, replay}; +use crate::event_log::{replay, EventLog}; use crate::store::Store; /// A single record-level difference between two snapshots. Variants are @@ -333,13 +333,13 @@ fn parse_records(resp: &Value) -> Result, DriftError> field: "records[].entity".into(), })? .to_string(); - let id_str = item - .get("id") - .and_then(Value::as_str) - .ok_or_else(|| DriftError::MissingField { - op: "dump_records".into(), - field: "records[].id".into(), - })?; + let id_str = + item.get("id") + .and_then(Value::as_str) + .ok_or_else(|| DriftError::MissingField { + op: "dump_records".into(), + field: "records[].id".into(), + })?; let id = Uuid::parse_str(id_str).map_err(|_| DriftError::MissingField { op: "dump_records".into(), field: format!("records[].id (not uuid: {})", id_str), @@ -377,16 +377,8 @@ mod tests { // The function compares records, not hashes — hash equality is // the operator's fast-path, but the report's truth is the diffs. let a = Uuid::new_v4(); - let log = vec![( - "Caja".to_string(), - a, - json!({"saldo": 100}), - )]; - let server = vec![( - "Caja".to_string(), - a, - json!({"saldo": 100}), - )]; + let log = vec![("Caja".to_string(), a, json!({"saldo": 100}))]; + let server = vec![("Caja".to_string(), a, json!({"saldo": 100}))]; let report = compare_states(log, h(1), server, h(2)); assert!(report.diffs.is_empty(), "records equal → no diffs"); } @@ -395,11 +387,7 @@ mod tests { fn detects_only_on_server() { let a = Uuid::new_v4(); let b = Uuid::new_v4(); - let log = vec![( - "Caja".to_string(), - a, - json!({"saldo": 100}), - )]; + let log = vec![("Caja".to_string(), a, json!({"saldo": 100}))]; let server = vec![ ("Caja".to_string(), a, json!({"saldo": 100})), ("Caja".to_string(), b, json!({"saldo": 999})), @@ -457,17 +445,16 @@ mod tests { let id_caja = Uuid::nil(); // sorts first byte-wise let id_mov = Uuid::from_u128(u128::MAX); - let log = vec![ - ("Movimiento".to_string(), id_mov, json!({"x": 1})), - ]; - let server = vec![ - ("Caja".to_string(), id_caja, json!({"saldo": 0})), - ]; + let log = vec![("Movimiento".to_string(), id_mov, json!({"x": 1}))]; + let server = vec![("Caja".to_string(), id_caja, json!({"saldo": 0}))]; let report = compare_states(log, h(0), server, h(1)); assert_eq!(report.diffs.len(), 2); // Caja sorts before Movimiento. match (&report.diffs[0], &report.diffs[1]) { - (DriftDiff::OnlyOnServer { entity: e1, .. }, DriftDiff::OnlyInLog { entity: e2, .. }) => { + ( + DriftDiff::OnlyOnServer { entity: e1, .. }, + DriftDiff::OnlyInLog { entity: e2, .. }, + ) => { assert_eq!(e1, "Caja"); assert_eq!(e2, "Movimiento"); } diff --git a/crates/modules/nakui/core/src/event_log.rs b/crates/modules/nakui/core/src/event_log.rs index 1a3d2f5..dc4bc34 100644 --- a/crates/modules/nakui/core/src/event_log.rs +++ b/crates/modules/nakui/core/src/event_log.rs @@ -363,10 +363,7 @@ impl Snapshot { /// Verify the snapshot was produced under a bundle compatible with /// `executor`. Snapshots without a hash (legacy / `from_memory_store`) /// pass — the operator opted out of this check at capture time. - pub fn ensure_compatible_with( - &self, - executor: &Executor, - ) -> Result<(), SnapshotMismatchError> { + pub fn ensure_compatible_with(&self, executor: &Executor) -> Result<(), SnapshotMismatchError> { let Some(snap_hash) = self.schema_hash else { return Ok(()); }; @@ -405,10 +402,8 @@ impl Snapshot { return Ok(None); } let text = std::fs::read_to_string(path).map_err(LogError::Io)?; - let snap: Snapshot = serde_json::from_str(&text).map_err(|e| LogError::Parse { - line: 0, - source: e, - })?; + let snap: Snapshot = + serde_json::from_str(&text).map_err(|e| LogError::Parse { line: 0, source: e })?; Ok(Some(snap)) } } @@ -485,10 +480,7 @@ pub fn execute_and_log( let entry = LogEntry::Morphism { seq, morphism: morphism.to_string(), - inputs: inputs - .iter() - .map(|(r, id)| (r.to_string(), *id)) - .collect(), + inputs: inputs.iter().map(|(r, id)| (r.to_string(), *id)).collect(), params, ops: ops.clone(), schema_hash: executor.schema_hash(morphism), @@ -534,10 +526,7 @@ pub fn execute_and_log_with_recovery( let entry = LogEntry::Morphism { seq, morphism: morphism.to_string(), - inputs: inputs - .iter() - .map(|(r, id)| (r.to_string(), *id)) - .collect(), + inputs: inputs.iter().map(|(r, id)| (r.to_string(), *id)).collect(), params, ops: ops.clone(), schema_hash: executor.schema_hash(morphism), diff --git a/crates/modules/nakui/core/src/executor.rs b/crates/modules/nakui/core/src/executor.rs index 23ea7d9..ecac475 100644 --- a/crates/modules/nakui/core/src/executor.rs +++ b/crates/modules/nakui/core/src/executor.rs @@ -1,14 +1,14 @@ -use serde_json::{Value, json}; +use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; use thiserror::Error; use uuid::Uuid; -use crate::delta::{FieldOp, simulate_on}; +use crate::delta::{simulate_on, FieldOp}; use crate::graph::{GraphError, ManifestGraph}; -use crate::nickel_validator::{self, NickelError}; use crate::manifest::{ConserveRule, Manifest, ManifestError, MorphismSpec, ValidationError}; +use crate::nickel_validator::{self, NickelError}; use crate::rhai_executor::{RhaiError, RhaiExecutor}; use crate::store::{Store, StoreError}; @@ -141,7 +141,8 @@ impl Executor { // archivo apuntado se mueve). Sin esto, el bundle hash quedaba // pegado y la versión del seed nunca detectaba ediciones de // schema. Ver `verify_log_rejects_seed_after_schema_changes`. - let schema_bundle_bytes = read_schema_files_concat(&module_dir, &manifest.effective_schemas())?; + let schema_bundle_bytes = + read_schema_files_concat(&module_dir, &manifest.effective_schemas())?; let schema_bundle_hash = compute_schema_bundle_hash(&schema_bundle_bytes); let mut schema_hashes = HashMap::with_capacity(manifest.morphisms.len()); for spec in &manifest.morphisms { @@ -291,20 +292,14 @@ impl Executor { Some(_) => { return Err(ExecError::CapabilityViolation { morphism: morphism_name.to_string(), - token: format!( - ".{}.{}", - path.entity, path.field - ), + token: format!(".{}.{}", path.entity, path.field), declared: spec.writes.clone(), }); } None => { return Err(ExecError::CapabilityViolation { morphism: morphism_name.to_string(), - token: format!( - ".{}.{}", - path.entity, path.field - ), + token: format!(".{}.{}", path.entity, path.field), declared: spec.writes.clone(), }); } @@ -330,8 +325,7 @@ impl Executor { // 7. Per-input KCL post-check; skip Deleted inputs. for spec_in in &spec.inputs { let id = inputs_map[&spec_in.role]; - if let Some(new_state) = - simulate_on(&loaded[&spec_in.role], &spec_in.entity, id, &ops) + if let Some(new_state) = simulate_on(&loaded[&spec_in.role], &spec_in.entity, id, &ops) { self.validate_entity(&spec_in.entity, &new_state) .map_err(|e| ExecError::SchemaPost { @@ -598,23 +592,21 @@ fn check_conservation( ), })?; let old_state = &loaded[&binding.role]; - let old_val = - old_state - .get(&rule.field) - .and_then(Value::as_i64) - .ok_or_else(|| ExecError::ConservationMalformed { - entity: rule.entity.clone(), - field: rule.field.clone(), - message: format!("old value at role `{}` is not i64", binding.role), - })?; - let new_val = - value - .as_i64() - .ok_or_else(|| ExecError::ConservationMalformed { - entity: rule.entity.clone(), - field: rule.field.clone(), - message: format!("Set value at role `{}` is not i64", binding.role), - })?; + let old_val = old_state + .get(&rule.field) + .and_then(Value::as_i64) + .ok_or_else(|| ExecError::ConservationMalformed { + entity: rule.entity.clone(), + field: rule.field.clone(), + message: format!("old value at role `{}` is not i64", binding.role), + })?; + let new_val = value + .as_i64() + .ok_or_else(|| ExecError::ConservationMalformed { + entity: rule.entity.clone(), + field: rule.field.clone(), + message: format!("Set value at role `{}` is not i64", binding.role), + })?; let group_key = match &rule.group_by { Some(g) => old_state .get(g) @@ -623,8 +615,7 @@ fn check_conservation( .to_string(), None => String::new(), }; - *delta_by_group.entry(group_key).or_insert(0) += - (new_val as i128) - (old_val as i128); + *delta_by_group.entry(group_key).or_insert(0) += (new_val as i128) - (old_val as i128); } } @@ -673,7 +664,10 @@ let y = 2; // content, not cosmetic. let src = r#"let s = "hello // not a comment \"world\"";"#; let normalized = normalize_rhai_source(src); - assert_eq!(normalized, r#"let s = "hello // not a comment \"world\"";"#); + assert_eq!( + normalized, + r#"let s = "hello // not a comment \"world\"";"# + ); } #[test] diff --git a/crates/modules/nakui/core/src/graph.rs b/crates/modules/nakui/core/src/graph.rs index 21dece5..b61062a 100644 --- a/crates/modules/nakui/core/src/graph.rs +++ b/crates/modules/nakui/core/src/graph.rs @@ -189,7 +189,10 @@ fn build_data_flow( for r in &m.reads { if let Some(token) = canonicalize_token(r, &role_to_entity) { if seen_reads.insert(token.clone()) { - readers.entry(token.clone()).or_default().push(m.name.clone()); + readers + .entry(token.clone()) + .or_default() + .push(m.name.clone()); m_reads.entry(m.name.clone()).or_default().push(token); } } @@ -198,7 +201,10 @@ fn build_data_flow( for w in &m.writes { if let Some(token) = canonicalize_token(w, &role_to_entity) { if seen_writes.insert(token.clone()) { - writers.entry(token.clone()).or_default().push(m.name.clone()); + writers + .entry(token.clone()) + .or_default() + .push(m.name.clone()); m_writes.entry(m.name.clone()).or_default().push(token); } } diff --git a/crates/modules/nakui/core/src/manifest.rs b/crates/modules/nakui/core/src/manifest.rs index 9ec065f..ecafc81 100644 --- a/crates/modules/nakui/core/src/manifest.rs +++ b/crates/modules/nakui/core/src/manifest.rs @@ -150,11 +150,9 @@ impl Manifest { resolved: resolved.display().to_string(), }); } - let content = std::fs::read_to_string(&resolved).map_err(|e| { - ValidationError::Io { - path: s.clone(), - source: e, - } + let content = std::fs::read_to_string(&resolved).map_err(|e| ValidationError::Io { + path: s.clone(), + source: e, })?; for name in extract_schema_names(&content) { entity_to_files.entry(name).or_default().push(s.clone()); @@ -168,8 +166,7 @@ impl Manifest { }); } } - let known_entities: HashSet<&str> = - entity_to_files.keys().map(String::as_str).collect(); + let known_entities: HashSet<&str> = entity_to_files.keys().map(String::as_str).collect(); // 2. Manifest-level: morphism names must be unique. let mut seen: HashSet<&str> = HashSet::new(); diff --git a/crates/modules/nakui/core/src/nickel_validator.rs b/crates/modules/nakui/core/src/nickel_validator.rs index 06fcec0..ad6512b 100644 --- a/crates/modules/nakui/core/src/nickel_validator.rs +++ b/crates/modules/nakui/core/src/nickel_validator.rs @@ -60,8 +60,8 @@ pub fn vet(schema_path: &Path, state: &Value, schema_name: &str) -> Result<(), N (std.deserialize 'Json m%%\"{state_json}\"%%) | bundle.{schema_name}" ); - let mut ctx = nickel_lang::Context::new() - .with_source_name(format!("nakui-validate-{schema_name}")); + let mut ctx = + nickel_lang::Context::new().with_source_name(format!("nakui-validate-{schema_name}")); match ctx.eval_deep_for_export(&source) { Ok(_) => Ok(()), @@ -130,7 +130,9 @@ mod tests { let state = json!({"id": "abc"}); // falta cantidad let err = vet(&schema, &state, "Stock").unwrap_err(); assert!(matches!(err, NickelError::ValidationFailed(_))); - let NickelError::ValidationFailed(msg) = err else { panic!() }; + let NickelError::ValidationFailed(msg) = err else { + panic!() + }; assert!( msg.to_lowercase().contains("cantidad") || msg.to_lowercase().contains("missing"), "msg debe mencionar el field missing: {msg}" diff --git a/crates/modules/nakui/core/src/rhai_executor.rs b/crates/modules/nakui/core/src/rhai_executor.rs index be41439..8c762d2 100644 --- a/crates/modules/nakui/core/src/rhai_executor.rs +++ b/crates/modules/nakui/core/src/rhai_executor.rs @@ -2,7 +2,7 @@ use rhai::packages::{ ArithmeticPackage, BasicArrayPackage, BasicIteratorPackage, BasicMapPackage, BasicStringPackage, CorePackage, LogicPackage, Package, }; -use rhai::{AST, Dynamic, Engine, Scope}; +use rhai::{Dynamic, Engine, Scope, AST}; use serde_json::Value; use std::cell::RefCell; use std::collections::HashMap; @@ -76,8 +76,8 @@ impl RhaiExecutor { for item in arr { let json: Value = rhai::serde::from_dynamic(&item) .map_err(|e| RhaiError::BadOp(format!("dynamic -> json: {}", e)))?; - let op: FieldOp = serde_json::from_value(json) - .map_err(|e| RhaiError::BadOp(e.to_string()))?; + let op: FieldOp = + serde_json::from_value(json).map_err(|e| RhaiError::BadOp(e.to_string()))?; ops.push(op); } Ok(ops) diff --git a/crates/modules/nakui/core/src/run.rs b/crates/modules/nakui/core/src/run.rs index 672b309..9157aa1 100644 --- a/crates/modules/nakui/core/src/run.rs +++ b/crates/modules/nakui/core/src/run.rs @@ -24,13 +24,13 @@ use std::os::unix::net::{UnixListener, UnixStream}; use std::path::Path; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use thiserror::Error; use uuid::Uuid; use crate::event_log::{ - EventLog, RecoverableExecuteError, ReplayError, Snapshot, SnapshotMismatchError, - execute_and_log_with_recovery, replay_with_snapshot_into, verify_log, + execute_and_log_with_recovery, replay_with_snapshot_into, verify_log, EventLog, + RecoverableExecuteError, ReplayError, Snapshot, SnapshotMismatchError, }; use crate::executor::Executor; use crate::store::Store; @@ -206,7 +206,10 @@ fn handle_connection( } let (response, shutdown) = dispatch(&line, executor, store, log); let bytes = serde_json::to_vec(&response).expect("response serializes"); - if let Err(e) = writer.write_all(&bytes).and_then(|_| writer.write_all(b"\n")) { + if let Err(e) = writer + .write_all(&bytes) + .and_then(|_| writer.write_all(b"\n")) + { eprintln!("nakui run: write: {}", e); return false; } @@ -291,16 +294,10 @@ fn dispatch( } Request::Verify => match verify_log(log, executor) { Ok(()) => { - let entries = log - .entries() - .map(|es| es.len()) - .unwrap_or(0); + let entries = log.entries().map(|es| es.len()).unwrap_or(0); (json!({"ok": true, "entries": entries}), false) } - Err(e) => ( - json!({"ok": false, "error": e.to_string()}), - false, - ), + Err(e) => (json!({"ok": false, "error": e.to_string()}), false), }, Request::HashState => { let records: Vec<_> = match store.iter() { @@ -324,9 +321,7 @@ fn dispatch( Request::DumpRecords => match store.iter() { Ok(it) => { let records: Vec = it - .map(|(entity, id, value)| { - json!({"entity": entity, "id": id, "value": value}) - }) + .map(|(entity, id, value)| json!({"entity": entity, "id": id, "value": value})) .collect(); (json!({"ok": true, "records": records}), false) } @@ -349,4 +344,3 @@ fn hex_encode(bytes: &[u8]) -> String { } out } - diff --git a/crates/modules/nakui/core/src/store.rs b/crates/modules/nakui/core/src/store.rs index 592182f..01d7f4b 100644 --- a/crates/modules/nakui/core/src/store.rs +++ b/crates/modules/nakui/core/src/store.rs @@ -128,10 +128,7 @@ pub fn hash_value(hasher: &mut Sha256, v: &Value) { // else (fractions, NaN, infinities) hashes as the raw // f64 bit pattern — that's still deterministic, just // not normalized. - if f.is_finite() - && f.fract() == 0.0 - && f >= I128_MIN_AS_F64 - && f <= I128_MAX_AS_F64 + if f.is_finite() && f.fract() == 0.0 && f >= I128_MIN_AS_F64 && f <= I128_MAX_AS_F64 { hash_int(hasher, f as i128); } else { @@ -247,22 +244,12 @@ impl Store for MemoryStore { } } FieldOp::Create { entity, id, .. } => { - if self - .records - .get(entity) - .and_then(|m| m.get(id)) - .is_some() - { + if self.records.get(entity).and_then(|m| m.get(id)).is_some() { return Err(StoreError::Conflict(entity.clone(), *id)); } } FieldOp::Delete { entity, id } => { - if self - .records - .get(entity) - .and_then(|m| m.get(id)) - .is_none() - { + if self.records.get(entity).and_then(|m| m.get(id)).is_none() { return Err(StoreError::NotFound(entity.clone(), *id)); } } @@ -343,7 +330,10 @@ impl Store for MemoryStore { .map(move |(id, v)| (entity.clone(), *id, v.clone())) }) .collect(); - out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.as_bytes().cmp(b.1.as_bytes()))); + out.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.as_bytes().cmp(b.1.as_bytes())) + }); Ok(Box::new(out.into_iter())) } } @@ -433,7 +423,11 @@ mod tests { let after = store.load("Customer", id).unwrap(); let map = after.as_object().unwrap(); assert!(!map.contains_key("notes"), "notes debería estar borrado"); - assert_eq!(map.get("name"), Some(&json!("Acme")), "otros fields intactos"); + assert_eq!( + map.get("name"), + Some(&json!("Acme")), + "otros fields intactos" + ); } #[test] @@ -455,10 +449,7 @@ mod tests { // No debería errar: clear de un field ausente es benigno. store.apply(&[op]).unwrap(); let after = store.load("Customer", id).unwrap(); - assert_eq!( - after.as_object().unwrap().get("name"), - Some(&json!("Acme")) - ); + assert_eq!(after.as_object().unwrap().get("name"), Some(&json!("Acme"))); } #[test] @@ -680,9 +671,8 @@ mod tests { // The empty hash is the SHA-256 of an empty input — fix the // expected bytes so an accidental framing change in `hash_state` // can't silently sail through. - let expected = hex_decode( - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - ); + let expected = + hex_decode("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); assert_eq!(s1.hash_state().unwrap().to_vec(), expected); } diff --git a/crates/modules/nakui/core/src/surreal_store.rs b/crates/modules/nakui/core/src/surreal_store.rs index 6805bff..f2fbd8c 100644 --- a/crates/modules/nakui/core/src/surreal_store.rs +++ b/crates/modules/nakui/core/src/surreal_store.rs @@ -18,10 +18,10 @@ //! a stable shape. use serde_json::Value; -use surrealdb::Surreal; -use surrealdb::engine::local::{Db, Mem}; #[cfg(feature = "persistent")] use surrealdb::engine::local::SurrealKv; +use surrealdb::engine::local::{Db, Mem}; +use surrealdb::Surreal; use thiserror::Error; use tokio::runtime::Runtime; use uuid::Uuid; @@ -84,9 +84,7 @@ impl SurrealStore { /// canonical use is `let store = SurrealStore::new_persistent(path)?` /// at process startup, with the path stable across runs. #[cfg(feature = "persistent")] - pub fn new_persistent( - path: impl AsRef, - ) -> Result { + pub fn new_persistent(path: impl AsRef) -> Result { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build()?; @@ -175,10 +173,7 @@ impl Store for SurrealStore { // sobre un field ausente no falla). let exists = self.exists(&path.entity, path.id).await?; if !exists { - return Err(StoreError::NotFound( - path.entity.clone(), - path.id, - )); + return Err(StoreError::NotFound(path.entity.clone(), path.id)); } // We don't model NotAnObject for SurrealStore: every // record stored via this trait is map-shaped by @@ -278,9 +273,11 @@ impl Store for SurrealStore { out.push((table.clone(), id, Value::Object(map))); } } - out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.as_bytes().cmp(b.1.as_bytes()))); - Ok(Box::new(out.into_iter()) - as Box>) + out.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.as_bytes().cmp(b.1.as_bytes())) + }); + Ok(Box::new(out.into_iter()) as Box>) }) } diff --git a/crates/modules/nakui/core/tests/crm.rs b/crates/modules/nakui/core/tests/crm.rs new file mode 100644 index 0000000..9c9fa7d --- /dev/null +++ b/crates/modules/nakui/core/tests/crm.rs @@ -0,0 +1,240 @@ +//! Tests de integración del módulo `crm`. Mismo kernel que +//! inventory/sales/treasury, apuntado a `modules/crm`: clientes, +//! oportunidades que recorren un pipeline de ventas, e interacciones. + +use std::path::{Path, PathBuf}; + +use nakui_core::executor::{ExecError, Executor}; +use nakui_core::store::{MemoryStore, Store}; +use serde_json::{json, Value}; +use uuid::Uuid; + +fn crm_module() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("dir del módulo nakui sobre core/") + .join("modules/crm") +} + +fn seed_cliente(store: &mut MemoryStore, id: Uuid, nombre: &str) { + store.seed( + "Cliente", + id, + json!({ + "id": id.to_string(), + "nombre": nombre, + "email": "contacto@example.com", + "empresa": nombre, + }), + ); +} + +/// Abre una oportunidad y devuelve su id. Camino feliz (panica si falla). +fn abrir_opp(exec: &Executor, store: &mut MemoryStore, cliente: Uuid) -> Uuid { + let opp = Uuid::new_v4(); + exec.run( + store, + "abrir_oportunidad", + &[("cliente", cliente)], + json!({ + "oportunidad_id": opp.to_string(), + "titulo": "Licencia anual", + "monto": 12_000_i64, + "currency": "USD", + "timestamp": "2026-05-21T10:00:00Z", + }), + ) + .expect("abrir_oportunidad debe pasar"); + opp +} + +fn etapa(store: &MemoryStore, opp: Uuid) -> String { + store + .load("Oportunidad", opp) + .and_then(|v| v.get("etapa").and_then(Value::as_str).map(String::from)) + .expect("oportunidad con etapa") +} + +/// Corre `mover_oportunidad`; devuelve el conteo de ops en éxito. +// `ExecError` es un enum grande — el resto del crate convive con este +// lint; lo suprimimos local en vez de boxear sólo este helper. +#[allow(clippy::result_large_err)] +fn mover( + exec: &Executor, + store: &mut MemoryStore, + opp: Uuid, + destino: &str, +) -> Result { + exec.run( + store, + "mover_oportunidad", + &[("oportunidad", opp)], + json!({ "etapa": destino, "timestamp": "2026-05-21T11:00:00Z" }), + ) + .map(|ops| ops.len()) +} + +#[test] +fn abrir_crea_oportunidad_en_prospecto() { + let exec = Executor::load_module(crm_module()).expect("load module"); + let mut store = MemoryStore::new(); + let cliente = Uuid::new_v4(); + seed_cliente(&mut store, cliente, "Acme Corp"); + + let opp = abrir_opp(&exec, &mut store, cliente); + + assert_eq!(etapa(&store, opp), "prospecto", "nace en prospecto"); + let o = store.load("Oportunidad", opp).expect("oportunidad existe"); + let cid = cliente.to_string(); + assert_eq!( + o.get("cliente_id").and_then(Value::as_str), + Some(cid.as_str()) + ); + assert_eq!(o.get("monto").and_then(Value::as_i64), Some(12_000)); +} + +#[test] +fn pipeline_avanza_hasta_ganada() { + let exec = Executor::load_module(crm_module()).expect("load module"); + let mut store = MemoryStore::new(); + let cliente = Uuid::new_v4(); + seed_cliente(&mut store, cliente, "Acme Corp"); + let opp = abrir_opp(&exec, &mut store, cliente); + + for destino in ["calificado", "propuesta", "negociacion", "ganada"] { + mover(&exec, &mut store, opp, destino) + .unwrap_or_else(|e| panic!("mover a {destino} debe pasar: {e:?}")); + assert_eq!(etapa(&store, opp), destino); + } +} + +#[test] +fn no_se_retrocede_en_el_pipeline() { + let exec = Executor::load_module(crm_module()).expect("load module"); + let mut store = MemoryStore::new(); + let cliente = Uuid::new_v4(); + seed_cliente(&mut store, cliente, "Acme Corp"); + let opp = abrir_opp(&exec, &mut store, cliente); + + mover(&exec, &mut store, opp, "propuesta").expect("avanzar debe pasar"); + + // prospecto está antes de propuesta → retroceso, rechazado por el script. + let result = mover(&exec, &mut store, opp, "prospecto"); + match result { + Err(ExecError::Rhai(_)) => {} + other => panic!("esperaba Rhai (throw por retroceso), obtuve {other:?}"), + } + assert_eq!(etapa(&store, opp), "propuesta", "la etapa no cambió"); +} + +#[test] +fn oportunidad_cerrada_no_se_mueve() { + let exec = Executor::load_module(crm_module()).expect("load module"); + let mut store = MemoryStore::new(); + let cliente = Uuid::new_v4(); + seed_cliente(&mut store, cliente, "Acme Corp"); + let opp = abrir_opp(&exec, &mut store, cliente); + + // Cerrar es legal desde cualquier etapa abierta. + mover(&exec, &mut store, opp, "ganada").expect("cerrar debe pasar"); + + // Una oportunidad ganada ya no se mueve. + let result = mover(&exec, &mut store, opp, "negociacion"); + match result { + Err(ExecError::Rhai(_)) => {} + other => panic!("esperaba Rhai (throw por cerrada), obtuve {other:?}"), + } + assert_eq!(etapa(&store, opp), "ganada"); +} + +#[test] +fn etapa_destino_desconocida_es_rechazada() { + let exec = Executor::load_module(crm_module()).expect("load module"); + let mut store = MemoryStore::new(); + let cliente = Uuid::new_v4(); + seed_cliente(&mut store, cliente, "Acme Corp"); + let opp = abrir_opp(&exec, &mut store, cliente); + + let result = mover(&exec, &mut store, opp, "facturada"); + assert!(matches!(result, Err(ExecError::Rhai(_)))); + assert_eq!(etapa(&store, opp), "prospecto"); +} + +#[test] +fn monto_negativo_es_rechazado() { + let exec = Executor::load_module(crm_module()).expect("load module"); + let mut store = MemoryStore::new(); + let cliente = Uuid::new_v4(); + seed_cliente(&mut store, cliente, "Acme Corp"); + + let opp = Uuid::new_v4(); + let result = exec.run( + &mut store, + "abrir_oportunidad", + &[("cliente", cliente)], + json!({ + "oportunidad_id": opp.to_string(), + "titulo": "Trato inválido", + "monto": -500_i64, + "currency": "USD", + "timestamp": "2026-05-21T10:00:00Z", + }), + ); + assert!(matches!(result, Err(ExecError::Rhai(_)))); + assert!(store.load("Oportunidad", opp).is_none(), "no se creó nada"); +} + +#[test] +fn registrar_interaccion_crea_registro() { + let exec = Executor::load_module(crm_module()).expect("load module"); + let mut store = MemoryStore::new(); + let cliente = Uuid::new_v4(); + seed_cliente(&mut store, cliente, "Acme Corp"); + + let int_id = Uuid::new_v4(); + exec.run( + &mut store, + "registrar_interaccion", + &[("cliente", cliente)], + json!({ + "interaccion_id": int_id.to_string(), + "canal": "llamada", + "nota": "Primer contacto, interés alto", + "timestamp": "2026-05-21T09:00:00Z", + }), + ) + .expect("registrar_interaccion debe pasar"); + + let i = store + .load("Interaccion", int_id) + .expect("interacción existe"); + assert_eq!(i.get("canal").and_then(Value::as_str), Some("llamada")); + let cid = cliente.to_string(); + assert_eq!( + i.get("cliente_id").and_then(Value::as_str), + Some(cid.as_str()) + ); +} + +#[test] +fn canal_invalido_es_rechazado() { + let exec = Executor::load_module(crm_module()).expect("load module"); + let mut store = MemoryStore::new(); + let cliente = Uuid::new_v4(); + seed_cliente(&mut store, cliente, "Acme Corp"); + + let int_id = Uuid::new_v4(); + let result = exec.run( + &mut store, + "registrar_interaccion", + &[("cliente", cliente)], + json!({ + "interaccion_id": int_id.to_string(), + "canal": "paloma-mensajera", + "nota": "canal inexistente", + "timestamp": "2026-05-21T09:00:00Z", + }), + ); + assert!(matches!(result, Err(ExecError::Rhai(_)))); + assert!(store.load("Interaccion", int_id).is_none()); +} diff --git a/crates/modules/nakui/core/tests/drift.rs b/crates/modules/nakui/core/tests/drift.rs index 5c0c10a..5f59d32 100644 --- a/crates/modules/nakui/core/tests/drift.rs +++ b/crates/modules/nakui/core/tests/drift.rs @@ -11,8 +11,8 @@ use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::thread; -use nakui_core::drift::{DriftDiff, check_against_socket}; -use nakui_core::event_log::{EventLog, execute_and_log, seed_and_log}; +use nakui_core::drift::{check_against_socket, DriftDiff}; +use nakui_core::event_log::{execute_and_log, seed_and_log, EventLog}; use nakui_core::executor::Executor; use nakui_core::run::run_server; use nakui_core::store::MemoryStore; diff --git a/crates/modules/nakui/core/tests/event_log.rs b/crates/modules/nakui/core/tests/event_log.rs index d943c74..39b7602 100644 --- a/crates/modules/nakui/core/tests/event_log.rs +++ b/crates/modules/nakui/core/tests/event_log.rs @@ -5,13 +5,12 @@ use std::path::{Path, PathBuf}; use nakui_core::delta::FieldOp; use nakui_core::event_log::{ - EventLog, ExecuteError, LogEntry, RecoverableExecuteError, Snapshot, execute_and_log, - execute_and_log_with_recovery, reconcile, replay, replay_with_snapshot_into, seed_and_log, - verify_log, + execute_and_log, execute_and_log_with_recovery, reconcile, replay, replay_with_snapshot_into, + seed_and_log, verify_log, EventLog, ExecuteError, LogEntry, RecoverableExecuteError, Snapshot, }; use nakui_core::executor::Executor; use nakui_core::store::{MemoryStore, Store, StoreError}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use uuid::Uuid; fn workspace_root() -> PathBuf { @@ -247,11 +246,21 @@ fn post_log_store_failure_leaves_log_canonical() { // Live store is stale: apply was rejected, so saldos are unchanged. assert_eq!( - store.load("Caja", a).unwrap().get("saldo").unwrap().as_i64(), + store + .load("Caja", a) + .unwrap() + .get("saldo") + .unwrap() + .as_i64(), Some(200_000) ); assert_eq!( - store.load("Caja", b).unwrap().get("saldo").unwrap().as_i64(), + store + .load("Caja", b) + .unwrap() + .get("saldo") + .unwrap() + .as_i64(), Some(50_000) ); @@ -464,7 +473,10 @@ fn snapshot_then_compact_then_replay_equals_pre_compaction() { let loaded_snap = Snapshot::load(&snap_path).unwrap().unwrap(); let mut replayed = MemoryStore::new(); replay_with_snapshot_into(&log, Some(&loaded_snap), &mut replayed).expect("replay"); - assert_eq!(live, replayed, "snapshot + post-compact log must equal live"); + assert_eq!( + live, replayed, + "snapshot + post-compact log must equal live" + ); let _ = std::fs::remove_file(&log_path); let _ = std::fs::remove_file(&snap_path); @@ -520,8 +532,14 @@ fn reconcile_rebuilds_drifted_store_from_log() { assert_ne!(live, canonical, "drift was set up to differ from log"); reconcile(&mut live, &log).expect("reconcile"); - assert_eq!(live, canonical, "reconcile must restore log-canonical state"); - assert!(live.load("Caja", ghost).is_none(), "poison record must be wiped"); + assert_eq!( + live, canonical, + "reconcile must restore log-canonical state" + ); + assert!( + live.load("Caja", ghost).is_none(), + "poison record must be wiped" + ); let _ = std::fs::remove_file(&log_path); } diff --git a/crates/modules/nakui/core/tests/graph.rs b/crates/modules/nakui/core/tests/graph.rs index 079cf05..866cdd1 100644 --- a/crates/modules/nakui/core/tests/graph.rs +++ b/crates/modules/nakui/core/tests/graph.rs @@ -5,9 +5,7 @@ use std::path::{Path, PathBuf}; use nakui_core::executor::Executor; use nakui_core::graph::{DirtyTracker, GraphError, ManifestGraph}; -use nakui_core::manifest::{ - ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec, -}; +use nakui_core::manifest::{ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec}; fn workspace_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) @@ -114,14 +112,28 @@ fn treasury_data_flow_indexes_match_manifest() { let g = &exec.graph; // Both register_cash_move and transfer_between_cajas write Caja.saldo. - let mut writers: Vec<&str> = g.writers_of("Caja.saldo").iter().map(|s| s.as_str()).collect(); + let mut writers: Vec<&str> = g + .writers_of("Caja.saldo") + .iter() + .map(|s| s.as_str()) + .collect(); writers.sort(); - assert_eq!(writers, vec!["register_cash_move", "transfer_between_cajas"]); + assert_eq!( + writers, + vec!["register_cash_move", "transfer_between_cajas"] + ); // Both read Caja.saldo too. - let mut readers: Vec<&str> = g.readers_of("Caja.saldo").iter().map(|s| s.as_str()).collect(); + let mut readers: Vec<&str> = g + .readers_of("Caja.saldo") + .iter() + .map(|s| s.as_str()) + .collect(); readers.sort(); - assert_eq!(readers, vec!["register_cash_move", "transfer_between_cajas"]); + assert_eq!( + readers, + vec!["register_cash_move", "transfer_between_cajas"] + ); // Movimiento is written only by register_cash_move. assert_eq!( @@ -246,8 +258,11 @@ fn executor_load_module_rejects_cyclic_manifest() { Err(e) => e, }; let msg = err.to_string(); - assert!(msg.contains("graph") || msg.contains("cycle"), - "expected graph diagnostic, got `{}`", msg); + assert!( + msg.contains("graph") || msg.contains("cycle"), + "expected graph diagnostic, got `{}`", + msg + ); let _ = std::fs::remove_dir_all(&tmp); } diff --git a/crates/modules/nakui/core/tests/inventory.rs b/crates/modules/nakui/core/tests/inventory.rs index f5a61f2..1e4b52d 100644 --- a/crates/modules/nakui/core/tests/inventory.rs +++ b/crates/modules/nakui/core/tests/inventory.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use nakui_core::executor::{ExecError, Executor}; use nakui_core::store::{MemoryStore, Store}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use uuid::Uuid; fn workspace_root() -> PathBuf { @@ -104,7 +104,10 @@ fn transfer_across_different_skus_is_rejected_by_conservation() { match result { Err(ExecError::Rhai(_)) => {} - other => panic!("expected Rhai (script throw on sku mismatch), got {:?}", other), + other => panic!( + "expected Rhai (script throw on sku mismatch), got {:?}", + other + ), } assert_eq!(cantidad(&store, a), 500); assert_eq!(cantidad(&store, c), 200); diff --git a/crates/modules/nakui/core/tests/kernel_guards.rs b/crates/modules/nakui/core/tests/kernel_guards.rs index e2930d5..b443fe8 100644 --- a/crates/modules/nakui/core/tests/kernel_guards.rs +++ b/crates/modules/nakui/core/tests/kernel_guards.rs @@ -13,12 +13,10 @@ use std::path::{Path, PathBuf}; use nakui_core::executor::{ExecError, Executor}; use nakui_core::graph::ManifestGraph; -use nakui_core::manifest::{ - ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec, -}; +use nakui_core::manifest::{ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec}; use nakui_core::rhai_executor::RhaiExecutor; use nakui_core::store::{MemoryStore, Store}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use uuid::Uuid; fn workspace_root() -> PathBuf { @@ -207,7 +205,12 @@ fn capability_rejects_entity_mismatch_on_tracked_id() { let caja_id = Uuid::new_v4(); seed_caja(&mut store, caja_id, "tracked", 100_000, "USD"); - let result = exec.run(&mut store, "evil_entity_mismatch", &[("caja", caja_id)], json!({})); + let result = exec.run( + &mut store, + "evil_entity_mismatch", + &[("caja", caja_id)], + json!({}), + ); match result { Err(ExecError::CapabilityViolation { token, .. }) => { diff --git a/crates/modules/nakui/core/tests/manifest_validation.rs b/crates/modules/nakui/core/tests/manifest_validation.rs index 76769d3..2577bc3 100644 --- a/crates/modules/nakui/core/tests/manifest_validation.rs +++ b/crates/modules/nakui/core/tests/manifest_validation.rs @@ -173,7 +173,9 @@ 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, .. }) => { + Err(ValidationError::ScriptMissing { + morphism, script, .. + }) => { assert_eq!(morphism, "test_op"); assert_eq!(script, "morphisms/ghost.rhai"); } @@ -200,16 +202,8 @@ fn rejects_duplicate_schema_across_files() { 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("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 { diff --git a/crates/modules/nakui/core/tests/run.rs b/crates/modules/nakui/core/tests/run.rs index 94f7e8b..c03d249 100644 --- a/crates/modules/nakui/core/tests/run.rs +++ b/crates/modules/nakui/core/tests/run.rs @@ -14,11 +14,11 @@ use std::path::{Path, PathBuf}; use std::thread; use std::time::Duration; -use nakui_core::event_log::{EventLog, execute_and_log, seed_and_log}; +use nakui_core::event_log::{execute_and_log, seed_and_log, EventLog}; use nakui_core::executor::Executor; use nakui_core::run::run_server; use nakui_core::store::MemoryStore; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use uuid::Uuid; fn workspace_root() -> PathBuf { @@ -118,9 +118,7 @@ fn run_server_full_protocol_round_trip() { if resp["protocol"] != json!(1) { return Err(format!("protocol mismatch: {}", resp)); } - let morphisms = resp["morphisms"] - .as_array() - .ok_or("morphisms not array")?; + let morphisms = resp["morphisms"].as_array().ok_or("morphisms not array")?; if !morphisms.iter().any(|m| m["name"] == "register_cash_move") { return Err("register_cash_move missing from describe".into()); } @@ -183,9 +181,13 @@ fn run_server_full_protocol_round_trip() { } // Bad JSON — connection survives, server keeps serving. - conn.writer.write_all(b"not json\n").map_err(|e| e.to_string())?; + conn.writer + .write_all(b"not json\n") + .map_err(|e| e.to_string())?; let mut line = String::new(); - conn.reader.read_line(&mut line).map_err(|e| e.to_string())?; + conn.reader + .read_line(&mut line) + .map_err(|e| e.to_string())?; let parsed: Value = serde_json::from_str(line.trim()).map_err(|e| e.to_string())?; if parsed["ok"] != json!(false) { return Err(format!("bad request didn't get error: {}", parsed)); @@ -207,7 +209,10 @@ fn run_server_full_protocol_round_trip() { }); run_server(executor, log, store, None, &socket_path).expect("server clean exit"); - client.join().expect("client thread joined").expect("client assertions"); + client + .join() + .expect("client thread joined") + .expect("client assertions"); assert!( !socket_path.exists(), diff --git a/crates/modules/nakui/core/tests/run_persistent.rs b/crates/modules/nakui/core/tests/run_persistent.rs index 63bcfa5..2a74cb5 100644 --- a/crates/modules/nakui/core/tests/run_persistent.rs +++ b/crates/modules/nakui/core/tests/run_persistent.rs @@ -29,12 +29,12 @@ use std::path::{Path, PathBuf}; use std::thread; use std::time::Duration; -use nakui_core::event_log::{EventLog, seed_and_log}; +use nakui_core::event_log::{seed_and_log, EventLog}; use nakui_core::executor::Executor; use nakui_core::run::run_server; use nakui_core::store::Store; use nakui_core::surreal_store::SurrealStore; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use uuid::Uuid; fn workspace_root() -> PathBuf { @@ -232,8 +232,7 @@ fn run_server_skips_replay_when_persistent_store_is_in_sync() { // Out-of-band mutation: open the persistent store directly and // change the saldo. Marker stays at the same seq. { - let mut store = - SurrealStore::new_persistent(&store_path).expect("reopen for poison"); + let mut store = SurrealStore::new_persistent(&store_path).expect("reopen for poison"); store.seed( "Caja", caja, diff --git a/crates/modules/nakui/core/tests/sales.rs b/crates/modules/nakui/core/tests/sales.rs index 14b402d..e20ee4d 100644 --- a/crates/modules/nakui/core/tests/sales.rs +++ b/crates/modules/nakui/core/tests/sales.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use nakui_core::executor::{ExecError, Executor}; use nakui_core::store::{MemoryStore, Store}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use uuid::Uuid; fn workspace_root() -> PathBuf { @@ -95,10 +95,7 @@ fn sale_decreases_stock_and_increases_caja() { .expect("venta must be persisted"); assert_eq!(venta.get("total").and_then(Value::as_i64), Some(500_000)); assert_eq!(venta.get("cantidad").and_then(Value::as_i64), Some(100)); - assert_eq!( - venta.get("currency").and_then(Value::as_str), - Some("USD") - ); + assert_eq!(venta.get("currency").and_then(Value::as_str), Some("USD")); } #[test] diff --git a/crates/modules/nakui/core/tests/schema_versioning.rs b/crates/modules/nakui/core/tests/schema_versioning.rs index abf15a1..7b485c4 100644 --- a/crates/modules/nakui/core/tests/schema_versioning.rs +++ b/crates/modules/nakui/core/tests/schema_versioning.rs @@ -10,11 +10,11 @@ use std::path::{Path, PathBuf}; use nakui_core::event_log::{ - EventLog, LogEntry, VerifyError, execute_and_log, replay, seed_and_log, verify_log, + execute_and_log, replay, seed_and_log, verify_log, EventLog, LogEntry, VerifyError, }; use nakui_core::executor::Executor; use nakui_core::store::MemoryStore; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use uuid::Uuid; fn workspace_root() -> PathBuf { @@ -66,12 +66,7 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { Ok(()) } -fn deposit_5k( - exec: &Executor, - store: &mut MemoryStore, - log: &mut EventLog, - caja: Uuid, -) { +fn deposit_5k(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, caja: Uuid) { execute_and_log( exec, store, @@ -122,7 +117,10 @@ fn executor_exposes_per_morphism_schema_hash() { // Re-loading the same module yields the same hashes — the contract // depends only on the bytes on disk, not load-time state. let exec2 = Executor::load_module(treasury_module()).expect("reload"); - assert_eq!(exec.schema_hash("register_cash_move"), exec2.schema_hash("register_cash_move")); + assert_eq!( + exec.schema_hash("register_cash_move"), + exec2.schema_hash("register_cash_move") + ); } #[test] @@ -202,7 +200,10 @@ fn verify_log_rejects_log_after_morphism_script_changes() { // Reload — the hash for register_cash_move must change. let exec2 = Executor::load_module(&temp.path).expect("reload v2"); let new_hash = exec2.schema_hash("register_cash_move").unwrap(); - assert_ne!(original_hash, new_hash, "real source edit must move the hash"); + assert_ne!( + original_hash, new_hash, + "real source edit must move the hash" + ); // verify_log must surface SchemaMismatch, not OpsMismatch — the // schema check runs first because "rules changed" is more @@ -361,7 +362,10 @@ fn verify_log_rejects_seed_after_schema_changes() { let exec2 = Executor::load_module(&temp.path).expect("reload v2"); let new_hash = exec2.schema_bundle_hash; - assert_ne!(original_hash, new_hash, "schema.ncl byte change must move the bundle hash"); + assert_ne!( + original_hash, new_hash, + "schema.ncl byte change must move the bundle hash" + ); let log = EventLog::open(&log_path).unwrap(); match verify_log(&log, &exec2) { @@ -433,7 +437,11 @@ fn morphism_script_change_does_not_flag_unrelated_seeds() { // Modify a Rhai script. Bundle stays the same. let script_path = temp.path.join("morphisms/register_cash_move.rhai"); let original = std::fs::read_to_string(&script_path).expect("read"); - std::fs::write(&script_path, format!("{}\n// rhai-only mutation\n", original)).unwrap(); + std::fs::write( + &script_path, + format!("{}\n// rhai-only mutation\n", original), + ) + .unwrap(); let exec2 = Executor::load_module(&temp.path).expect("reload"); let log = EventLog::open(&log_path).unwrap(); diff --git a/crates/modules/nakui/core/tests/snapshot_chain.rs b/crates/modules/nakui/core/tests/snapshot_chain.rs index afecc30..a7677cf 100644 --- a/crates/modules/nakui/core/tests/snapshot_chain.rs +++ b/crates/modules/nakui/core/tests/snapshot_chain.rs @@ -9,12 +9,12 @@ use std::thread; use std::time::Duration; use nakui_core::event_log::{ - EventLog, Snapshot, SnapshotMismatchError, execute_and_log, replay, seed_and_log, + execute_and_log, replay, seed_and_log, EventLog, Snapshot, SnapshotMismatchError, }; use nakui_core::executor::Executor; use nakui_core::run::run_server; use nakui_core::store::{MemoryStore, Store}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use uuid::Uuid; fn workspace_root() -> PathBuf { @@ -166,7 +166,10 @@ fn snapshot_then_compact_then_run_server_resumes_correctly() { let socket_for_client = socket_path.clone(); let client = thread::spawn(move || -> Result<(), String> { let mut conn = connect_with_retry(&socket_for_client); - let resp = exchange(&mut conn, json!({"op": "load", "entity": "Caja", "id": caja.to_string()})); + let resp = exchange( + &mut conn, + json!({"op": "load", "entity": "Caja", "id": caja.to_string()}), + ); if resp["value"]["saldo"].as_i64() != Some(112_500) { return Err(format!( "expected saldo 112_500 (100k seed + 5k + 7.5k from snapshot), got {}", @@ -191,9 +194,15 @@ fn snapshot_then_compact_then_run_server_resumes_correctly() { }), ); if resp["ok"] != json!(true) { - return Err(format!("execute on snapshot-booted server failed: {}", resp)); + return Err(format!( + "execute on snapshot-booted server failed: {}", + resp + )); } - let resp = exchange(&mut conn, json!({"op": "load", "entity": "Caja", "id": caja.to_string()})); + let resp = exchange( + &mut conn, + json!({"op": "load", "entity": "Caja", "id": caja.to_string()}), + ); if resp["value"]["saldo"].as_i64() != Some(113_500) { return Err(format!("post-execute saldo wrong: {}", resp)); } @@ -234,10 +243,7 @@ fn run_server_refuses_snapshot_with_wrong_schema_hash() { let store = MemoryStore::new(); let result = run_server(exec, log, store, Some(bad_snap), &socket_path); assert!( - matches!( - result, - Err(nakui_core::run::RunError::SnapshotMismatch(_)) - ), + matches!(result, Err(nakui_core::run::RunError::SnapshotMismatch(_))), "expected SnapshotMismatch, got {:?}", result ); @@ -352,7 +358,8 @@ fn snapshot_write_recovers_from_stale_tempfile() { let exec = Executor::load_module(treasury_module()).expect("load"); let snap = Snapshot::capture(&MemoryStore::new(), 0, &exec); - snap.write(&snap_path).expect("write despite stale tempfile"); + snap.write(&snap_path) + .expect("write despite stale tempfile"); // Tempfile should be renamed (not orphaned), so it's gone. assert!( diff --git a/crates/modules/nakui/core/tests/state_hash.rs b/crates/modules/nakui/core/tests/state_hash.rs index 19a3566..b7964ed 100644 --- a/crates/modules/nakui/core/tests/state_hash.rs +++ b/crates/modules/nakui/core/tests/state_hash.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; -use nakui_core::event_log::{EventLog, execute_and_log, replay, seed_and_log}; +use nakui_core::event_log::{execute_and_log, replay, seed_and_log, EventLog}; use nakui_core::executor::Executor; use nakui_core::store::{MemoryStore, Store}; use serde_json::json; @@ -27,13 +27,7 @@ fn fresh_log_path() -> PathBuf { std::env::temp_dir().join(format!("nakui_hash_{}.jsonl", Uuid::new_v4())) } -fn seed_two_cajas( - exec: &Executor, - store: &mut MemoryStore, - log: &mut EventLog, - a: Uuid, - b: Uuid, -) { +fn seed_two_cajas(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, a: Uuid, b: Uuid) { seed_and_log( exec, store, diff --git a/crates/modules/nakui/core/tests/surreal_persist.rs b/crates/modules/nakui/core/tests/surreal_persist.rs index 3963b1a..e49592c 100644 --- a/crates/modules/nakui/core/tests/surreal_persist.rs +++ b/crates/modules/nakui/core/tests/surreal_persist.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use nakui_core::store::Store; use nakui_core::surreal_store::SurrealStore; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use uuid::Uuid; fn fresh_db_path() -> PathBuf { @@ -39,9 +39,7 @@ fn data_survives_close_and_reopen() { { let store = SurrealStore::new_persistent(&path).expect("reopen persistent"); - let loaded = store - .load("Caja", id) - .expect("record must survive reopen"); + let loaded = store.load("Caja", id).expect("record must survive reopen"); assert_eq!( loaded.get("saldo").and_then(Value::as_i64), Some(12_345), diff --git a/crates/modules/nakui/core/tests/surreal_store.rs b/crates/modules/nakui/core/tests/surreal_store.rs index da12b88..2d49b6a 100644 --- a/crates/modules/nakui/core/tests/surreal_store.rs +++ b/crates/modules/nakui/core/tests/surreal_store.rs @@ -8,12 +8,12 @@ use std::path::{Path, PathBuf}; use nakui_core::delta::{FieldOp, FieldPath}; use nakui_core::event_log::{ - EventLog, execute_and_log, reconcile, replay_into, seed_and_log, verify_log, + execute_and_log, reconcile, replay_into, seed_and_log, verify_log, EventLog, }; use nakui_core::executor::Executor; use nakui_core::store::{MemoryStore, Store, StoreError}; use nakui_core::surreal_store::SurrealStore; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use uuid::Uuid; fn workspace_root() -> PathBuf { @@ -240,8 +240,24 @@ fn verify_log_against_surreal_passes() { let a = Uuid::new_v4(); let b = Uuid::new_v4(); - seed_and_log(&exec, &mut live, &mut log, "Caja", a, caja_data(a, 200_000, "USD")).unwrap(); - seed_and_log(&exec, &mut live, &mut log, "Caja", b, caja_data(b, 50_000, "USD")).unwrap(); + seed_and_log( + &exec, + &mut live, + &mut log, + "Caja", + a, + caja_data(a, 200_000, "USD"), + ) + .unwrap(); + seed_and_log( + &exec, + &mut live, + &mut log, + "Caja", + b, + caja_data(b, 50_000, "USD"), + ) + .unwrap(); execute_and_log( &exec, &mut live, @@ -432,8 +448,24 @@ fn iter_and_hash_state_round_trip_against_surreal() { let mut live = SurrealStore::new_in_memory().expect("live"); let a = Uuid::new_v4(); let b = Uuid::new_v4(); - seed_and_log(&exec, &mut live, &mut log, "Caja", a, caja_data(a, 200_000, "USD")).unwrap(); - seed_and_log(&exec, &mut live, &mut log, "Caja", b, caja_data(b, 50_000, "USD")).unwrap(); + seed_and_log( + &exec, + &mut live, + &mut log, + "Caja", + a, + caja_data(a, 200_000, "USD"), + ) + .unwrap(); + seed_and_log( + &exec, + &mut live, + &mut log, + "Caja", + b, + caja_data(b, 50_000, "USD"), + ) + .unwrap(); execute_and_log( &exec, &mut live, @@ -453,12 +485,17 @@ fn iter_and_hash_state_round_trip_against_surreal() { // iter must enumerate every record. let recs: Vec<_> = live.iter().expect("iter").collect(); let by_entity: std::collections::HashMap<&str, usize> = - recs.iter().fold(std::collections::HashMap::new(), |mut m, (e, _, _)| { - *m.entry(e.as_str()).or_insert(0) += 1; - m - }); + recs.iter() + .fold(std::collections::HashMap::new(), |mut m, (e, _, _)| { + *m.entry(e.as_str()).or_insert(0) += 1; + m + }); assert_eq!(by_entity.get("Caja").copied(), Some(2), "two Cajas"); - assert_eq!(by_entity.get("Movimiento").copied(), Some(1), "one Movimiento"); + assert_eq!( + by_entity.get("Movimiento").copied(), + Some(1), + "one Movimiento" + ); // canonical order: entities sorted, ids byte-sorted within entity. let entities: Vec<&str> = recs.iter().map(|(e, _, _)| e.as_str()).collect(); @@ -496,7 +533,15 @@ fn reconcile_rebuilds_drifted_surreal_store_from_log() { let mut store = SurrealStore::new_in_memory().expect("surreal"); let a = Uuid::new_v4(); - seed_and_log(&exec, &mut store, &mut log, "Caja", a, caja_data(a, 100_000, "USD")).unwrap(); + seed_and_log( + &exec, + &mut store, + &mut log, + "Caja", + a, + caja_data(a, 100_000, "USD"), + ) + .unwrap(); execute_and_log( &exec, &mut store, @@ -518,7 +563,9 @@ fn reconcile_rebuilds_drifted_surreal_store_from_log() { store.seed("Caja", ghost, caja_data(ghost, 0, "USD")); store.seed("Caja", a, caja_data(a, 999_999, "USD")); assert_eq!( - store.load("Caja", a).and_then(|v| v.get("saldo").and_then(Value::as_i64)), + store + .load("Caja", a) + .and_then(|v| v.get("saldo").and_then(Value::as_i64)), Some(999_999), "drift was applied" ); @@ -528,7 +575,9 @@ fn reconcile_rebuilds_drifted_surreal_store_from_log() { // After reconcile: ghost gone, saldo = 100_000 (seed) + 5_000 (deposit). assert!(store.load("Caja", ghost).is_none(), "poison record wiped"); assert_eq!( - store.load("Caja", a).and_then(|v| v.get("saldo").and_then(Value::as_i64)), + store + .load("Caja", a) + .and_then(|v| v.get("saldo").and_then(Value::as_i64)), Some(105_000), "reconcile must restore log-canonical saldo" ); diff --git a/crates/modules/nakui/modules/crm/morphisms/abrir_oportunidad.rhai b/crates/modules/nakui/modules/crm/morphisms/abrir_oportunidad.rhai new file mode 100644 index 0000000..c6f5ce2 --- /dev/null +++ b/crates/modules/nakui/modules/crm/morphisms/abrir_oportunidad.rhai @@ -0,0 +1,39 @@ +// abrir_oportunidad +// Abre una oportunidad de venta para un cliente existente. La etapa +// inicial la fija el morfismo ("prospecto"), no el caller — una +// oportunidad siempre nace al principio del pipeline. +// +// ids.cliente: UUID del Cliente (la oportunidad guarda la FK). +// params: { oportunidad_id, titulo, monto:i64, currency:str, timestamp:str } + +let opp_id = input.params.oportunidad_id; +if type_of(opp_id) == "()" { + throw "params.oportunidad_id es obligatorio (idempotencia)" +} + +let titulo = input.params.titulo; +if type_of(titulo) == "()" { + throw "params.titulo es obligatorio" +} + +let monto = input.params.monto; +if monto <= 0 { + throw "monto debe ser positivo" +} + +[ + #{ + op: "create", + entity: "Oportunidad", + id: opp_id, + data: #{ + id: opp_id, + cliente_id: input.ids.cliente, + titulo: titulo, + monto: monto, + currency: input.params.currency, + etapa: "prospecto", + timestamp: input.params.timestamp, + }, + }, +] diff --git a/crates/modules/nakui/modules/crm/morphisms/mover_oportunidad.rhai b/crates/modules/nakui/modules/crm/morphisms/mover_oportunidad.rhai new file mode 100644 index 0000000..9d3d7a4 --- /dev/null +++ b/crates/modules/nakui/modules/crm/morphisms/mover_oportunidad.rhai @@ -0,0 +1,55 @@ +// mover_oportunidad +// Mueve una oportunidad por el pipeline de ventas. La etapa destino la +// fija el caller; el morfismo valida que la transición sea legal: +// - la etapa destino debe ser conocida, +// - una oportunidad cerrada (ganada/perdida) ya no se mueve, +// - dentro del pipeline abierto sólo se avanza, no se retrocede; +// cerrar (ganada/perdida) es legal desde cualquier etapa abierta. +// +// states.oportunidad: el registro Oportunidad. +// params: { etapa:str, timestamp:str } + +let etapa_actual = input.states.oportunidad.etapa; +let etapa_destino = input.params.etapa; + +// Rango de cada etapa en el pipeline (-1 = etapa desconocida). +let r_actual = -1; +if etapa_actual == "prospecto" { r_actual = 0; } +if etapa_actual == "calificado" { r_actual = 1; } +if etapa_actual == "propuesta" { r_actual = 2; } +if etapa_actual == "negociacion" { r_actual = 3; } +if etapa_actual == "ganada" { r_actual = 4; } +if etapa_actual == "perdida" { r_actual = 5; } + +let r_destino = -1; +if etapa_destino == "prospecto" { r_destino = 0; } +if etapa_destino == "calificado" { r_destino = 1; } +if etapa_destino == "propuesta" { r_destino = 2; } +if etapa_destino == "negociacion" { r_destino = 3; } +if etapa_destino == "ganada" { r_destino = 4; } +if etapa_destino == "perdida" { r_destino = 5; } + +if r_destino < 0 { + throw "etapa destino desconocida: " + etapa_destino +} +if etapa_actual == "ganada" || etapa_actual == "perdida" { + throw "la oportunidad ya está cerrada (etapa " + etapa_actual + ")" +} + +// Cerrar es legal desde cualquier etapa abierta; dentro del pipeline +// abierto sólo se avanza. +let cerrando = etapa_destino == "ganada" || etapa_destino == "perdida"; +if !cerrando { + if r_destino <= r_actual { + throw "transición inválida " + etapa_actual + " -> " + etapa_destino + + " (no se retrocede)" + } +} + +[ + #{ + op: "set", + path: #{ entity: "Oportunidad", id: input.ids.oportunidad, field: "etapa" }, + value: etapa_destino, + }, +] diff --git a/crates/modules/nakui/modules/crm/morphisms/registrar_interaccion.rhai b/crates/modules/nakui/modules/crm/morphisms/registrar_interaccion.rhai new file mode 100644 index 0000000..4a7e9aa --- /dev/null +++ b/crates/modules/nakui/modules/crm/morphisms/registrar_interaccion.rhai @@ -0,0 +1,31 @@ +// registrar_interaccion +// Registra una interacción (llamada, email o reunión) con un cliente. +// Crea un registro Interaccion — el historial de contacto del CRM. +// +// ids.cliente: UUID del Cliente (la interacción guarda la FK). +// params: { interaccion_id, canal:str, nota:str, timestamp:str } + +let int_id = input.params.interaccion_id; +if type_of(int_id) == "()" { + throw "params.interaccion_id es obligatorio (idempotencia)" +} + +let canal = input.params.canal; +if canal != "llamada" && canal != "email" && canal != "reunion" { + throw "canal desconocido: " + canal + " (esperado: llamada | email | reunion)" +} + +[ + #{ + op: "create", + entity: "Interaccion", + id: int_id, + data: #{ + id: int_id, + cliente_id: input.ids.cliente, + canal: canal, + nota: input.params.nota, + timestamp: input.params.timestamp, + }, + }, +] diff --git a/crates/modules/nakui/modules/crm/nsmc.json b/crates/modules/nakui/modules/crm/nsmc.json new file mode 100644 index 0000000..e9698b4 --- /dev/null +++ b/crates/modules/nakui/modules/crm/nsmc.json @@ -0,0 +1,35 @@ +{ + "module": "crm", + "morphisms": [ + { + "name": "abrir_oportunidad", + "inputs": [ + { "role": "cliente", "entity": "Cliente" } + ], + "reads": [], + "writes": ["Oportunidad"], + "depends_on": [], + "script": "morphisms/abrir_oportunidad.rhai" + }, + { + "name": "mover_oportunidad", + "inputs": [ + { "role": "oportunidad", "entity": "Oportunidad" } + ], + "reads": ["oportunidad.etapa"], + "writes": ["oportunidad.etapa"], + "depends_on": [], + "script": "morphisms/mover_oportunidad.rhai" + }, + { + "name": "registrar_interaccion", + "inputs": [ + { "role": "cliente", "entity": "Cliente" } + ], + "reads": [], + "writes": ["Interaccion"], + "depends_on": [], + "script": "morphisms/registrar_interaccion.rhai" + } + ] +} diff --git a/crates/modules/nakui/modules/crm/schema.ncl b/crates/modules/nakui/modules/crm/schema.ncl new file mode 100644 index 0000000..5ad741b --- /dev/null +++ b/crates/modules/nakui/modules/crm/schema.ncl @@ -0,0 +1,55 @@ +# Schema Nickel para el módulo `crm` (Cliente + Oportunidad + Interaccion). +# +# Un CRM: clientes, oportunidades de venta que recorren un pipeline e +# interacciones registradas. El kernel de Nakui valida cada entity +# contra el contract de su record — antes y después de cada morfismo. + +let non_empty_string = std.contract.from_predicate (fun s => + std.is_string s && std.string.length s > 0 +) in + +let positive_int = std.contract.from_predicate (fun n => + std.is_number n && n > 0 +) in + +let currency_iso = std.contract.from_predicate (fun s => + std.is_string s && std.string.length s == 3 +) in + +# Etapas del pipeline de ventas. `ganada` y `perdida` son terminales. +let etapa_pipeline = std.contract.from_predicate (fun s => + std.is_string s + && std.array.elem s + ["prospecto", "calificado", "propuesta", "negociacion", "ganada", "perdida"] +) in + +let canal_interaccion = std.contract.from_predicate (fun s => + std.is_string s && std.array.elem s ["llamada", "email", "reunion"] +) in + +{ + Cliente = { + id | String, + nombre | non_empty_string, + email | non_empty_string, + empresa | String, + }, + + Oportunidad = { + id | String, + cliente_id | String, + titulo | non_empty_string, + monto | positive_int, + currency | currency_iso, + etapa | etapa_pipeline, + timestamp | String, + }, + + Interaccion = { + id | String, + cliente_id | String, + canal | canal_interaccion, + nota | String, + timestamp | String, + }, +} diff --git a/docs/changelog/nakui.md b/docs/changelog/nakui.md index ff842e9..695fa1b 100644 --- a/docs/changelog/nakui.md +++ b/docs/changelog/nakui.md @@ -2,7 +2,31 @@ ERP categórico. -### fix(nakui-core): schema_bundle_hash debe reflejar el contenido real del schema +### feat(nakui): módulo `crm` — clientes, pipeline de ventas, interacciones + +Módulo CRM funcional, declarativo como inventory/sales/treasury +(`crates/modules/nakui/modules/crm/`): `schema.ncl` + `nsmc.json` + +morfismos Rhai. Tres entities — `Cliente`, `Oportunidad`, `Interaccion` +— y tres morfismos: + +- `abrir_oportunidad(cliente)` — crea una `Oportunidad` en etapa + `prospecto`. +- `mover_oportunidad(oportunidad)` — avanza el pipeline + (prospecto→calificado→propuesta→negociacion→ganada/perdida). Valida: + etapa destino conocida, no mover una oportunidad cerrada, no retroceder. +- `registrar_interaccion(cliente)` — crea una `Interaccion` + (llamada/email/reunión). + +- `core/src/bin/crm_demo.rs` — demo realista (3 clientes, 18 eventos). + A diferencia de los otros demos **no borra el event log**: lo deja en + disco e imprime el comando para abrirlo con `nakui-explorer` — así el + explorador, que sólo mostraba un log vacío, muestra un CRM con cuerpo. +- `core/tests/crm.rs` — 8 tests de integración (pipeline completo + + rechazos: retroceso, oportunidad cerrada, etapa/canal/monto inválidos). + +Nota: el engine Rhai de `nakui-core` es `Engine::new_raw()` con paquetes +selectos — `Array::index_of` no resuelve; los morfismos usan sólo +`if`/`==`/`throw`. Iter 17. Regresión surfaceada por el workspace test `verify_log_rejects_seed_after_schema_kcl_changes` (rebautizado a `verify_log_rejects_seed_after_schema_changes`).