gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definitions

⎇ eba629a806c7f861dd00a37c20b0f9e9d1ef3a52 crates/apps/nakui-ui/src/main.rs Rust · 480 lines · 16.6 KB History Who touched what raw

feat(nakui): Fase 2 del ERP — relaciones legibles + formato eba629a80 · sergio ·

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        // Fase 2: la lista de oportunidades resuelve `cliente_id` al
438        // label del cliente y formatea `monto` como moneda.
439        let nahual_meta_schema::View::List(lv) = &m.views["oportunidad_list"] else {
440            panic!("oportunidad_list debe ser una lista");
441        };
442        let cliente_col = lv
443            .columns
444            .iter()
445            .find(|c| c.field == "cliente_id")
446            .expect("columna cliente_id");
447        assert_eq!(cliente_col.ref_entity.as_deref(), Some("Cliente"));
448        let monto_col = lv
449            .columns
450            .iter()
451            .find(|c| c.field == "monto")
452            .expect("columna monto");
453        assert!(
454            matches!(
455                monto_col.format,
456                nahual_meta_schema::ValueFormat::Currency { .. }
457            ),
458            "monto debe formatearse como moneda",
459        );
460    }
461
462    /// Carga el módulo crm por el mismo camino que usa `nakui-ui`
463    /// (`load_ui_modules` → `brahman_cards::load_cards_from_dir`). Se
464    /// aísla en un tempdir para no acoplar el test a los otros módulos
465    /// de ejemplo.
466    #[test]
467    fn crm_module_loads_via_card_pipeline() {
468        let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
469            .join("../../../examples/nakui-modules/crm/module.json");
470        let root = tempfile::tempdir().unwrap();
471        let crm_dir = root.path().join("crm");
472        std::fs::create_dir(&crm_dir).unwrap();
473        std::fs::copy(&src, crm_dir.join("module.json")).unwrap();
474
475        let (modules, skipped) = load_ui_modules(root.path()).expect("el módulo crm debe cargar");
476        assert!(skipped.is_empty(), "ninguna card debe saltarse");
477        assert_eq!(modules.len(), 1);
478        assert_eq!(modules[0].id, "crm");
479    }
480}