⎇ e187ab4cd359ee0789a2b9c658962c19c362ee3d
crates/apps/nakui-ui/src/main.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(nakui-ui): CRM como ERP — UiModule con listas y formularios
1//! `nakui-ui` — binario shell de la metainterfaz Nakui.
2//!
3//! Compone:
4//! - **Yahweh widget** [`nahual_widget_meta_form::MetaApp`] genérico
5//! sobre cualquier `MetaBackend` — toda la lógica de
6//! render/edit/delete/morphism vive ahí.
7//! - **Backend** [`backend::NakuiBackend`] — implementa el trait
8//! wireado al stack nakui-core (event log + MemoryStore + Rhai
9//! executors).
10//! - **Loader** [`load_ui_modules`] — usa `brahman_cards` para leer
11//! `card.{ncl,json}` / `module.{ncl,json}` desde
12//! `NAKUI_MODULES_DIR`, filtra a UiModule body, valida.
13//!
14//! ## Uso
15//!
16//! ```sh
17//! NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
18//! # default sin env: ./nakui-modules en pwd.
19//! ```
20
21mod backend;
22
23use std::collections::BTreeMap;
24use std::path::PathBuf;
25use std::sync::Arc;
26
27use gpui::{
28 prelude::*, px, App, Application, Bounds, SharedString, TitlebarOptions, WindowBounds,
29 WindowOptions,
30};
31
32use brahman_cards::CardBody;
33use nahual_meta_schema::Module;
34use nahual_theme::Theme;
35use nahual_widget_meta_form::MetaApp;
36use nakui_core::executor::Executor;
37
38use crate::backend::NakuiBackend;
39
40fn main() {
41 Application::new().run(|cx: &mut App| {
42 // El text input pide Theme::global; instalarlo antes de
43 // crear el window evita que panicee.
44 Theme::install_default(cx);
45
46 // 1. Cargar módulos (Cards UiModule via brahman_cards).
47 let modules_dir = std::env::var("NAKUI_MODULES_DIR")
48 .ok()
49 .map(PathBuf::from)
50 .unwrap_or_else(|| PathBuf::from("nakui-modules"));
51 let (modules, mut load_error) = match load_ui_modules(&modules_dir) {
52 Ok((mods, skipped)) => {
53 let toast = if skipped.is_empty() {
54 None
55 } else {
56 Some(format!(
57 "skipeé {} card(s) no-UiModule en {}: {:?}",
58 skipped.len(),
59 modules_dir.display(),
60 skipped
61 ))
62 };
63 (mods, toast)
64 }
65 Err(e) => (
66 Vec::new(),
67 Some(format!(
68 "no pude cargar módulos de {}: {e}",
69 modules_dir.display()
70 )),
71 ),
72 };
73
74 // 2. Cargar Executors para módulos con `nakui_module_dir`.
75 // Path resuelve relativo al subdir del módulo.
76 let mut executors: BTreeMap<String, Arc<Executor>> = BTreeMap::new();
77 for m in &modules {
78 let Some(rel) = &m.nakui_module_dir else {
79 continue;
80 };
81 let module_root = modules_dir.join(&m.id);
82 let nakui_dir = if std::path::Path::new(rel).is_absolute() {
83 PathBuf::from(rel)
84 } else {
85 module_root.join(rel)
86 };
87 match Executor::load_module(&nakui_dir) {
88 Ok(exec) => {
89 executors.insert(m.id.clone(), Arc::new(exec));
90 }
91 Err(e) => {
92 let msg = format!(
93 "módulo {}: no pude cargar executor nakui en {}: {e}",
94 m.id,
95 nakui_dir.display()
96 );
97 load_error = Some(match load_error {
98 Some(prev) => format!("{prev}; {msg}"),
99 None => msg,
100 });
101 }
102 }
103 }
104
105 // 3. Construir el backend Nakui (abre log, replay, compact).
106 let log_path = std::env::var("NAKUI_EVENT_LOG")
107 .map(PathBuf::from)
108 .unwrap_or_else(|_| PathBuf::from("nakui-ui-state.jsonl"));
109 let snapshot_threshold: usize = std::env::var("NAKUI_SNAPSHOT_THRESHOLD")
110 .ok()
111 .and_then(|s| s.parse().ok())
112 .unwrap_or(50);
113 let (backend, status) = NakuiBackend::open(log_path, snapshot_threshold, executors);
114 let initial_toast = status.init_toast;
115 if let Some(msg) = status.load_error {
116 load_error = Some(match load_error {
117 Some(prev) => format!("{prev}; {msg}"),
118 None => msg,
119 });
120 }
121
122 // 4. Abrir window con MetaApp<NakuiBackend> como root view.
123 let bounds = Bounds::centered(None, gpui::size(px(1100.), px(720.)), cx);
124 cx.open_window(
125 WindowOptions {
126 window_bounds: Some(WindowBounds::Windowed(bounds)),
127 titlebar: Some(TitlebarOptions {
128 title: Some(SharedString::from("Nakui")),
129 ..Default::default()
130 }),
131 ..Default::default()
132 },
133 |_w, cx| cx.new(|cx| MetaApp::new(modules, backend, initial_toast, load_error, cx)),
134 )
135 .expect("open window");
136 cx.activate(true);
137 });
138}
139
140/// Carga UiModules desde un directorio via el brazo unificado
141/// `brahman_cards::load_cards_from_dir`. Aplica las reglas
142/// específicas de la UI:
143/// - Sólo `CardBody::UiModule` cuenta; otros body kinds
144/// (Ente, Monad, ...) se reportan en el `skipped` para que el
145/// runtime los muestre como banner informativo.
146/// - Cada `Module` se valida via `Module::validate()`.
147/// - Detecta `id` duplicados entre módulos UiModule (el runtime
148/// los direcciona por id; duplicados serían ambiguos).
149///
150/// Devuelve `(modules, skipped_ids)` ordenados por id.
151fn load_ui_modules(dir: &std::path::Path) -> Result<(Vec<Module>, Vec<String>), String> {
152 let cards = brahman_cards::load_cards_from_dir(dir).map_err(|e| e.to_string())?;
153 let mut modules: Vec<Module> = Vec::new();
154 let mut skipped: Vec<String> = Vec::new();
155 for c in cards {
156 match c.body {
157 CardBody::UiModule(m) => modules.push(m),
158 other => skipped.push(format!("{}({})", c.id, other.kind_name())),
159 }
160 }
161 for m in &modules {
162 m.validate()
163 .map_err(|e| format!("módulo '{}' inválido: {e}", m.id))?;
164 }
165 modules.sort_by(|a, b| a.id.cmp(&b.id));
166 let mut prev: Option<&Module> = None;
167 for cur in &modules {
168 if let Some(p) = prev {
169 if p.id == cur.id {
170 return Err(format!(
171 "id de módulo duplicado: '{}' aparece más de una vez",
172 cur.id
173 ));
174 }
175 }
176 prev = Some(cur);
177 }
178 Ok((modules, skipped))
179}
180
181#[cfg(test)]
182mod tests {
183 //! Tests del shell. Los tests del backend impl viven en
184 //! `backend.rs`. Los tests del widget viven en
185 //! `nahual-widget-meta-form`. Los helpers puros en
186 //! `nahual-meta-runtime`.
187
188 use super::*;
189 use serde_json::json;
190
191 /// E2E mínimo del WAL: armamos un log a mano con dos seeds,
192 /// abrimos con `EventLog::open` + `replay_into`, y verificamos
193 /// que el `MemoryStore` queda con esos records aplicados.
194 /// Reproduce el flujo del startup de NakuiBackend sin GPUI.
195 #[test]
196 fn event_log_replay_restores_memory_store() {
197 use nakui_core::event_log::{replay_into, EventLog, LogEntry};
198 use nakui_core::store::{MemoryStore, Store};
199 use uuid::Uuid;
200
201 let tmp = tempfile::NamedTempFile::new().unwrap();
202 let path = tmp.path().to_path_buf();
203 drop(tmp);
204
205 let id_a = Uuid::new_v4();
206 let id_b = Uuid::new_v4();
207 {
208 let mut log = EventLog::open(&path).unwrap();
209 log.append(LogEntry::Seed {
210 seq: 0,
211 entity: "customer".into(),
212 id: id_a,
213 data: json!({"name": "Acme"}),
214 schema_hash: None,
215 })
216 .unwrap();
217 log.append(LogEntry::Seed {
218 seq: 1,
219 entity: "customer".into(),
220 id: id_b,
221 data: json!({"name": "Globex"}),
222 schema_hash: None,
223 })
224 .unwrap();
225 }
226
227 let log = EventLog::open(&path).unwrap();
228 assert_eq!(log.next_seq(), 2);
229 let mut store = MemoryStore::new();
230 replay_into(&log, &mut store).unwrap();
231
232 assert_eq!(store.load("customer", id_a), Some(json!({"name": "Acme"})));
233 assert_eq!(
234 store.load("customer", id_b),
235 Some(json!({"name": "Globex"}))
236 );
237
238 let _ = std::fs::remove_file(&path);
239 }
240
241 /// E2E del Action::Morphism: carga el módulo nakui-core real
242 /// `sales`, arma store + log, y ejecuta el morphism `vender` vía
243 /// `execute_and_log_with_recovery` (la función que usa
244 /// `NakuiBackend::morphism` internamente). Verifica las
245 /// post-condiciones esperadas del manifest sales.
246 #[test]
247 fn morphism_pipeline_executes_real_sales_vender() {
248 use nakui_core::event_log::{execute_and_log_with_recovery, EventLog};
249 use nakui_core::executor::Executor;
250 use nakui_core::store::{MemoryStore, Store};
251 use uuid::Uuid;
252
253 let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
254 let sales_dir = here
255 .join("../../..")
256 .join("crates/modules/nakui/modules/sales");
257 if !sales_dir.join("nsmc.json").exists() {
258 eprintln!(
259 "skip: sales module no encontrado en {}",
260 sales_dir.display()
261 );
262 return;
263 }
264
265 let executor = Executor::load_module(&sales_dir).expect("cargar sales executor");
266
267 let mut store = MemoryStore::new();
268 let stock_id = Uuid::new_v4();
269 let caja_id = Uuid::new_v4();
270 store.seed(
271 "Stock",
272 stock_id,
273 json!({
274 "id": stock_id.to_string(),
275 "sku_id": "test-sku",
276 "ubicacion": "loc-1",
277 "cantidad": 100_i64,
278 }),
279 );
280 store.seed(
281 "Caja",
282 caja_id,
283 json!({
284 "id": caja_id.to_string(),
285 "name": "Caja Test",
286 "currency": "USD",
287 "saldo": 1_000_000_i64,
288 }),
289 );
290
291 let tmp = tempfile::NamedTempFile::new().unwrap();
292 let log_path = tmp.path().to_path_buf();
293 drop(tmp);
294 let mut log = EventLog::open(&log_path).unwrap();
295
296 let venta_id = Uuid::new_v4();
297 let inputs = vec![("stock", stock_id), ("caja", caja_id)];
298 let params = json!({
299 "venta_id": venta_id.to_string(),
300 "cantidad": 5_i64,
301 "precio_unitario": 200_i64,
302 "timestamp": "2026-05-04T10:00:00Z",
303 });
304
305 let ops = execute_and_log_with_recovery(
306 &executor, &mut store, &mut log, "vender", &inputs, params,
307 )
308 .expect("morphism vender debe ejecutar limpio");
309
310 assert!(!ops.is_empty());
311 let stock_after = store
312 .load("Stock", stock_id)
313 .and_then(|v| v.get("cantidad").and_then(|c| c.as_i64()))
314 .expect("stock con cantidad");
315 assert_eq!(stock_after, 95);
316 let caja_after = store
317 .load("Caja", caja_id)
318 .and_then(|v| v.get("saldo").and_then(|s| s.as_i64()))
319 .expect("caja con saldo");
320 assert_eq!(caja_after, 1_001_000);
321
322 let _ = std::fs::remove_file(&log_path);
323 }
324
325 #[test]
326 fn load_ui_modules_via_brahman_cards_returns_ui_modules_and_skips_others() {
327 let root = tempfile::tempdir().unwrap();
328
329 let a = root.path().join("alpha");
330 std::fs::create_dir(&a).unwrap();
331 std::fs::write(
332 a.join("module.json"),
333 serde_json::to_vec(&json!({
334 "id": "alpha",
335 "label": "Alpha",
336 "entities": [],
337 "menu": [],
338 "views": {}
339 }))
340 .unwrap(),
341 )
342 .unwrap();
343
344 let b = root.path().join("bravo");
345 std::fs::create_dir(&b).unwrap();
346 std::fs::write(
347 b.join("card.json"),
348 serde_json::to_vec(&json!({
349 "schema_version": 1,
350 "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
351 "label": "ente-bravo",
352 "payload": "Virtual",
353 "supervision": "OneShot"
354 }))
355 .unwrap(),
356 )
357 .unwrap();
358
359 let (modules, skipped) = load_ui_modules(root.path()).expect("load ok");
360 assert_eq!(modules.len(), 1);
361 assert_eq!(modules[0].id, "alpha");
362 assert_eq!(skipped.len(), 1);
363 assert!(skipped[0].contains("ente"));
364 }
365
366 #[test]
367 fn load_ui_modules_via_brahman_cards_rejects_invalid_module() {
368 let root = tempfile::tempdir().unwrap();
369 let sub = root.path().join("broken");
370 std::fs::create_dir(&sub).unwrap();
371 std::fs::write(
372 sub.join("module.json"),
373 serde_json::to_vec(&json!({
374 "id": "broken",
375 "label": "Broken",
376 "entities": [],
377 "menu": [{ "label": "Phantom", "view": "ghost" }],
378 "views": {}
379 }))
380 .unwrap(),
381 )
382 .unwrap();
383 let err = load_ui_modules(root.path()).unwrap_err();
384 assert!(err.contains("broken"), "msg debe nombrar el módulo: {err}");
385 }
386
387 #[test]
388 fn load_ui_modules_detects_duplicate_id() {
389 let root = tempfile::tempdir().unwrap();
390 for name in ["dir_a", "dir_b"] {
391 let sub = root.path().join(name);
392 std::fs::create_dir(&sub).unwrap();
393 std::fs::write(
394 sub.join("module.json"),
395 serde_json::to_vec(&json!({
396 "id": "dup",
397 "label": "Dup",
398 "entities": [], "menu": [], "views": {}
399 }))
400 .unwrap(),
401 )
402 .unwrap();
403 }
404 let err = load_ui_modules(root.path()).unwrap_err();
405 assert!(err.contains("duplicado"));
406 assert!(err.contains("dup"));
407 }
408
409 /// El UiModule del CRM (`examples/nakui-modules/crm`) debe parsear
410 /// como `Module` y pasar `validate()` — sino `nakui-ui` lo rechaza
411 /// al arrancar. Cubre que las 7 vistas del ERP existan y que
412 /// enganche el módulo-kernel.
413 #[test]
414 fn crm_example_module_parses_and_validates() {
415 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
416 .join("../../../examples/nakui-modules/crm/module.json");
417 let m = Module::from_path(&path).expect("crm/module.json debe parsear");
418 m.validate().expect("el módulo crm debe validar");
419
420 assert_eq!(m.id, "crm");
421 assert!(
422 m.nakui_module_dir.is_some(),
423 "el CRM debe enganchar el módulo-kernel"
424 );
425 for view in [
426 "cliente_list",
427 "cliente_form",
428 "oportunidad_list",
429 "abrir_form",
430 "mover_form",
431 "interaccion_list",
432 "interaccion_form",
433 ] {
434 assert!(m.views.contains_key(view), "falta la vista «{view}»");
435 }
436 }
437
438 /// Carga el módulo crm por el mismo camino que usa `nakui-ui`
439 /// (`load_ui_modules` → `brahman_cards::load_cards_from_dir`). Se
440 /// aísla en un tempdir para no acoplar el test a los otros módulos
441 /// de ejemplo.
442 #[test]
443 fn crm_module_loads_via_card_pipeline() {
444 let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
445 .join("../../../examples/nakui-modules/crm/module.json");
446 let root = tempfile::tempdir().unwrap();
447 let crm_dir = root.path().join("crm");
448 std::fs::create_dir(&crm_dir).unwrap();
449 std::fs::copy(&src, crm_dir.join("module.json")).unwrap();
450
451 let (modules, skipped) = load_ui_modules(root.path()).expect("el módulo crm debe cargar");
452 assert!(skipped.is_empty(), "ninguna card debe saltarse");
453 assert_eq!(modules.len(), 1);
454 assert_eq!(modules[0].id, "crm");
455 }
456}