//! `nakui-ui` — binario shell de la metainterfaz Nakui. //! //! Compone: //! - **Yahweh widget** [`yahweh_widget_meta_form::MetaApp`] genérico //! sobre cualquier `MetaBackend` — toda la lógica de //! render/edit/delete/morphism vive ahí. //! - **Backend** [`backend::NakuiBackend`] — implementa el trait //! wireado al stack nakui-core (event log + MemoryStore + Rhai //! executors). //! - **Loader** [`load_ui_modules`] — usa `brahman_cards` para leer //! `card.{ncl,json}` / `module.{ncl,json}` desde //! `NAKUI_MODULES_DIR`, filtra a UiModule body, valida. //! //! ## Uso //! //! ```sh //! NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui //! # default sin env: ./nakui-modules en pwd. //! ``` mod backend; use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::Arc; use gpui::{ prelude::*, px, App, Application, Bounds, SharedString, TitlebarOptions, WindowBounds, WindowOptions, }; use brahman_cards::CardBody; use nakui_core::executor::Executor; use yahweh_meta_schema::Module; use yahweh_theme::Theme; use yahweh_widget_meta_form::MetaApp; use crate::backend::NakuiBackend; fn main() { Application::new().run(|cx: &mut App| { // El text input pide Theme::global; instalarlo antes de // crear el window evita que panicee. Theme::install_default(cx); // 1. Cargar módulos (Cards UiModule via brahman_cards). let modules_dir = std::env::var("NAKUI_MODULES_DIR") .ok() .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("nakui-modules")); let (modules, mut load_error) = match load_ui_modules(&modules_dir) { Ok((mods, skipped)) => { let toast = if skipped.is_empty() { None } else { Some(format!( "skipeé {} card(s) no-UiModule en {}: {:?}", skipped.len(), modules_dir.display(), skipped )) }; (mods, toast) } Err(e) => ( Vec::new(), Some(format!( "no pude cargar módulos de {}: {e}", modules_dir.display() )), ), }; // 2. Cargar Executors para módulos con `nakui_module_dir`. // Path resuelve relativo al subdir del módulo. let mut executors: BTreeMap> = BTreeMap::new(); for m in &modules { let Some(rel) = &m.nakui_module_dir else { continue; }; let module_root = modules_dir.join(&m.id); let nakui_dir = if std::path::Path::new(rel).is_absolute() { PathBuf::from(rel) } else { module_root.join(rel) }; match Executor::load_module(&nakui_dir) { Ok(exec) => { executors.insert(m.id.clone(), Arc::new(exec)); } Err(e) => { let msg = format!( "módulo {}: no pude cargar executor nakui en {}: {e}", m.id, nakui_dir.display() ); load_error = Some(match load_error { Some(prev) => format!("{prev}; {msg}"), None => msg, }); } } } // 3. Construir el backend Nakui (abre log, replay, compact). let log_path = std::env::var("NAKUI_EVENT_LOG") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("nakui-ui-state.jsonl")); let snapshot_threshold: usize = std::env::var("NAKUI_SNAPSHOT_THRESHOLD") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(50); let (backend, status) = NakuiBackend::open(log_path, snapshot_threshold, executors); let initial_toast = status.init_toast; if let Some(msg) = status.load_error { load_error = Some(match load_error { Some(prev) => format!("{prev}; {msg}"), None => msg, }); } // 4. Abrir window con MetaApp como root view. let bounds = Bounds::centered(None, gpui::size(px(1100.), px(720.)), cx); cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), titlebar: Some(TitlebarOptions { title: Some(SharedString::from("Nakui")), ..Default::default() }), ..Default::default() }, |_w, cx| { cx.new(|cx| { MetaApp::new(modules, backend, initial_toast, load_error, cx) }) }, ) .expect("open window"); cx.activate(true); }); } /// Carga UiModules desde un directorio via el brazo unificado /// `brahman_cards::load_cards_from_dir`. Aplica las reglas /// específicas de la UI: /// - Sólo `CardBody::UiModule` cuenta; otros body kinds /// (Ente, Monad, ...) se reportan en el `skipped` para que el /// runtime los muestre como banner informativo. /// - Cada `Module` se valida via `Module::validate()`. /// - Detecta `id` duplicados entre módulos UiModule (el runtime /// los direcciona por id; duplicados serían ambiguos). /// /// Devuelve `(modules, skipped_ids)` ordenados por id. fn load_ui_modules( dir: &std::path::Path, ) -> Result<(Vec, Vec), String> { let cards = brahman_cards::load_cards_from_dir(dir) .map_err(|e| e.to_string())?; let mut modules: Vec = Vec::new(); let mut skipped: Vec = Vec::new(); for c in cards { match c.body { CardBody::UiModule(m) => modules.push(m), other => skipped.push(format!("{}({})", c.id, other.kind_name())), } } for m in &modules { m.validate() .map_err(|e| format!("módulo '{}' inválido: {e}", m.id))?; } modules.sort_by(|a, b| a.id.cmp(&b.id)); let mut prev: Option<&Module> = None; for cur in &modules { if let Some(p) = prev { if p.id == cur.id { return Err(format!( "id de módulo duplicado: '{}' aparece más de una vez", cur.id )); } } prev = Some(cur); } Ok((modules, skipped)) } #[cfg(test)] mod tests { //! Tests del shell. Los tests del backend impl viven en //! `backend.rs`. Los tests del widget viven en //! `yahweh-widget-meta-form`. Los helpers puros en //! `yahweh-meta-runtime`. use super::*; use serde_json::json; /// E2E mínimo del WAL: armamos un log a mano con dos seeds, /// abrimos con `EventLog::open` + `replay_into`, y verificamos /// que el `MemoryStore` queda con esos records aplicados. /// Reproduce el flujo del startup de NakuiBackend sin GPUI. #[test] fn event_log_replay_restores_memory_store() { use nakui_core::event_log::{replay_into, EventLog, LogEntry}; use nakui_core::store::{MemoryStore, Store}; use uuid::Uuid; let tmp = tempfile::NamedTempFile::new().unwrap(); let path = tmp.path().to_path_buf(); drop(tmp); let id_a = Uuid::new_v4(); let id_b = Uuid::new_v4(); { let mut log = EventLog::open(&path).unwrap(); log.append(LogEntry::Seed { seq: 0, entity: "customer".into(), id: id_a, data: json!({"name": "Acme"}), schema_hash: None, }) .unwrap(); log.append(LogEntry::Seed { seq: 1, entity: "customer".into(), id: id_b, data: json!({"name": "Globex"}), schema_hash: None, }) .unwrap(); } let log = EventLog::open(&path).unwrap(); assert_eq!(log.next_seq(), 2); let mut store = MemoryStore::new(); replay_into(&log, &mut store).unwrap(); assert_eq!( store.load("customer", id_a), Some(json!({"name": "Acme"})) ); assert_eq!( store.load("customer", id_b), Some(json!({"name": "Globex"})) ); let _ = std::fs::remove_file(&path); } /// E2E del Action::Morphism: carga el módulo nakui-core real /// `sales`, arma store + log, y ejecuta el morphism `vender` vía /// `execute_and_log_with_recovery` (la función que usa /// `NakuiBackend::morphism` internamente). Verifica las /// post-condiciones esperadas del manifest sales. #[test] fn morphism_pipeline_executes_real_sales_vender() { use nakui_core::event_log::{execute_and_log_with_recovery, EventLog}; use nakui_core::executor::Executor; use nakui_core::store::{MemoryStore, Store}; use uuid::Uuid; let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); let sales_dir = here .join("../../..") .join("crates/modules/nakui/modules/sales"); if !sales_dir.join("nsmc.json").exists() { eprintln!( "skip: sales module no encontrado en {}", sales_dir.display() ); return; } let executor = Executor::load_module(&sales_dir).expect("cargar sales executor"); let mut store = MemoryStore::new(); let stock_id = Uuid::new_v4(); let caja_id = Uuid::new_v4(); store.seed( "Stock", stock_id, json!({ "id": stock_id.to_string(), "sku_id": "test-sku", "ubicacion": "loc-1", "cantidad": 100_i64, }), ); store.seed( "Caja", caja_id, json!({ "id": caja_id.to_string(), "name": "Caja Test", "currency": "USD", "saldo": 1_000_000_i64, }), ); let tmp = tempfile::NamedTempFile::new().unwrap(); let log_path = tmp.path().to_path_buf(); drop(tmp); let mut log = EventLog::open(&log_path).unwrap(); let venta_id = Uuid::new_v4(); let inputs = vec![("stock", stock_id), ("caja", caja_id)]; let params = json!({ "venta_id": venta_id.to_string(), "cantidad": 5_i64, "precio_unitario": 200_i64, "timestamp": "2026-05-04T10:00:00Z", }); let ops = execute_and_log_with_recovery( &executor, &mut store, &mut log, "vender", &inputs, params, ) .expect("morphism vender debe ejecutar limpio"); assert!(!ops.is_empty()); let stock_after = store .load("Stock", stock_id) .and_then(|v| v.get("cantidad").and_then(|c| c.as_i64())) .expect("stock con cantidad"); assert_eq!(stock_after, 95); let caja_after = store .load("Caja", caja_id) .and_then(|v| v.get("saldo").and_then(|s| s.as_i64())) .expect("caja con saldo"); assert_eq!(caja_after, 1_001_000); let _ = std::fs::remove_file(&log_path); } #[test] fn load_ui_modules_via_brahman_cards_returns_ui_modules_and_skips_others() { let root = tempfile::tempdir().unwrap(); let a = root.path().join("alpha"); std::fs::create_dir(&a).unwrap(); std::fs::write( a.join("module.json"), serde_json::to_vec(&json!({ "id": "alpha", "label": "Alpha", "entities": [], "menu": [], "views": {} })) .unwrap(), ) .unwrap(); let b = root.path().join("bravo"); std::fs::create_dir(&b).unwrap(); std::fs::write( b.join("card.json"), serde_json::to_vec(&json!({ "schema_version": 1, "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "label": "ente-bravo", "payload": "Virtual", "supervision": "OneShot" })) .unwrap(), ) .unwrap(); let (modules, skipped) = load_ui_modules(root.path()).expect("load ok"); assert_eq!(modules.len(), 1); assert_eq!(modules[0].id, "alpha"); assert_eq!(skipped.len(), 1); assert!(skipped[0].contains("ente")); } #[test] fn load_ui_modules_via_brahman_cards_rejects_invalid_module() { let root = tempfile::tempdir().unwrap(); let sub = root.path().join("broken"); std::fs::create_dir(&sub).unwrap(); std::fs::write( sub.join("module.json"), serde_json::to_vec(&json!({ "id": "broken", "label": "Broken", "entities": [], "menu": [{ "label": "Phantom", "view": "ghost" }], "views": {} })) .unwrap(), ) .unwrap(); let err = load_ui_modules(root.path()).unwrap_err(); assert!(err.contains("broken"), "msg debe nombrar el módulo: {err}"); } #[test] fn load_ui_modules_detects_duplicate_id() { let root = tempfile::tempdir().unwrap(); for name in ["dir_a", "dir_b"] { let sub = root.path().join(name); std::fs::create_dir(&sub).unwrap(); std::fs::write( sub.join("module.json"), serde_json::to_vec(&json!({ "id": "dup", "label": "Dup", "entities": [], "menu": [], "views": {} })) .unwrap(), ) .unwrap(); } let err = load_ui_modules(root.path()).unwrap_err(); assert!(err.contains("duplicado")); assert!(err.contains("dup")); } }