tawasuyu

tawasuyu/brahman

Rust 326TOML 123JSON 17Markdown 144606 Definitions

⎇ d3649bfd1a80153652c2a028315369bb4b184024 crates/apps/nakui-ui/src/main.rs Rust · 424 lines · 14.0 KB History Who touched what raw

refactor(yahweh): Fase 2c — widget render extraído a yahweh-widget-meta-form b3a99f38d · Sergio ·

1//! `nakui-ui` — binario shell de la metainterfaz Nakui.
2//!
3//! Compone:
4//! - **Yahweh widget** [`yahweh_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 nakui_core::executor::Executor;
34use yahweh_meta_schema::Module;
35use yahweh_theme::Theme;
36use yahweh_widget_meta_form::MetaApp;
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) =
114            NakuiBackend::open(log_path, snapshot_threshold, executors);
115        let initial_toast = status.init_toast;
116        if let Some(msg) = status.load_error {
117            load_error = Some(match load_error {
118                Some(prev) => format!("{prev}; {msg}"),
119                None => msg,
120            });
121        }
122
123        // 4. Abrir window con MetaApp<NakuiBackend> como root view.
124        let bounds = Bounds::centered(None, gpui::size(px(1100.), px(720.)), cx);
125        cx.open_window(
126            WindowOptions {
127                window_bounds: Some(WindowBounds::Windowed(bounds)),
128                titlebar: Some(TitlebarOptions {
129                    title: Some(SharedString::from("Nakui")),
130                    ..Default::default()
131                }),
132                ..Default::default()
133            },
134            |_w, cx| {
135                cx.new(|cx| {
136                    MetaApp::new(modules, backend, initial_toast, load_error, cx)
137                })
138            },
139        )
140        .expect("open window");
141        cx.activate(true);
142    });
143}
144
145/// Carga UiModules desde un directorio via el brazo unificado
146/// `brahman_cards::load_cards_from_dir`. Aplica las reglas
147/// específicas de la UI:
148///  - Sólo `CardBody::UiModule` cuenta; otros body kinds
149///    (Ente, Monad, ...) se reportan en el `skipped` para que el
150///    runtime los muestre como banner informativo.
151///  - Cada `Module` se valida via `Module::validate()`.
152///  - Detecta `id` duplicados entre módulos UiModule (el runtime
153///    los direcciona por id; duplicados serían ambiguos).
154///
155/// Devuelve `(modules, skipped_ids)` ordenados por id.
156fn load_ui_modules(
157    dir: &std::path::Path,
158) -> Result<(Vec<Module>, Vec<String>), String> {
159    let cards = brahman_cards::load_cards_from_dir(dir)
160        .map_err(|e| e.to_string())?;
161    let mut modules: Vec<Module> = Vec::new();
162    let mut skipped: Vec<String> = Vec::new();
163    for c in cards {
164        match c.body {
165            CardBody::UiModule(m) => modules.push(m),
166            other => skipped.push(format!("{}({})", c.id, other.kind_name())),
167        }
168    }
169    for m in &modules {
170        m.validate()
171            .map_err(|e| format!("módulo '{}' inválido: {e}", m.id))?;
172    }
173    modules.sort_by(|a, b| a.id.cmp(&b.id));
174    let mut prev: Option<&Module> = None;
175    for cur in &modules {
176        if let Some(p) = prev {
177            if p.id == cur.id {
178                return Err(format!(
179                    "id de módulo duplicado: '{}' aparece más de una vez",
180                    cur.id
181                ));
182            }
183        }
184        prev = Some(cur);
185    }
186    Ok((modules, skipped))
187}
188
189#[cfg(test)]
190mod tests {
191    //! Tests del shell. Los tests del backend impl viven en
192    //! `backend.rs`. Los tests del widget viven en
193    //! `yahweh-widget-meta-form`. Los helpers puros en
194    //! `yahweh-meta-runtime`.
195
196    use super::*;
197    use serde_json::json;
198
199    /// E2E mínimo del WAL: armamos un log a mano con dos seeds,
200    /// abrimos con `EventLog::open` + `replay_into`, y verificamos
201    /// que el `MemoryStore` queda con esos records aplicados.
202    /// Reproduce el flujo del startup de NakuiBackend sin GPUI.
203    #[test]
204    fn event_log_replay_restores_memory_store() {
205        use nakui_core::event_log::{replay_into, EventLog, LogEntry};
206        use nakui_core::store::{MemoryStore, Store};
207        use uuid::Uuid;
208
209        let tmp = tempfile::NamedTempFile::new().unwrap();
210        let path = tmp.path().to_path_buf();
211        drop(tmp);
212
213        let id_a = Uuid::new_v4();
214        let id_b = Uuid::new_v4();
215        {
216            let mut log = EventLog::open(&path).unwrap();
217            log.append(LogEntry::Seed {
218                seq: 0,
219                entity: "customer".into(),
220                id: id_a,
221                data: json!({"name": "Acme"}),
222                schema_hash: None,
223            })
224            .unwrap();
225            log.append(LogEntry::Seed {
226                seq: 1,
227                entity: "customer".into(),
228                id: id_b,
229                data: json!({"name": "Globex"}),
230                schema_hash: None,
231            })
232            .unwrap();
233        }
234
235        let log = EventLog::open(&path).unwrap();
236        assert_eq!(log.next_seq(), 2);
237        let mut store = MemoryStore::new();
238        replay_into(&log, &mut store).unwrap();
239
240        assert_eq!(
241            store.load("customer", id_a),
242            Some(json!({"name": "Acme"}))
243        );
244        assert_eq!(
245            store.load("customer", id_b),
246            Some(json!({"name": "Globex"}))
247        );
248
249        let _ = std::fs::remove_file(&path);
250    }
251
252    /// E2E del Action::Morphism: carga el módulo nakui-core real
253    /// `sales`, arma store + log, y ejecuta el morphism `vender` vía
254    /// `execute_and_log_with_recovery` (la función que usa
255    /// `NakuiBackend::morphism` internamente). Verifica las
256    /// post-condiciones esperadas del manifest sales.
257    #[test]
258    fn morphism_pipeline_executes_real_sales_vender() {
259        use nakui_core::event_log::{execute_and_log_with_recovery, EventLog};
260        use nakui_core::executor::Executor;
261        use nakui_core::store::{MemoryStore, Store};
262        use uuid::Uuid;
263
264        let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
265        let sales_dir = here
266            .join("../../..")
267            .join("crates/modules/nakui/modules/sales");
268        if !sales_dir.join("nsmc.json").exists() {
269            eprintln!(
270                "skip: sales module no encontrado en {}",
271                sales_dir.display()
272            );
273            return;
274        }
275
276        let executor = Executor::load_module(&sales_dir).expect("cargar sales executor");
277
278        let mut store = MemoryStore::new();
279        let stock_id = Uuid::new_v4();
280        let caja_id = Uuid::new_v4();
281        store.seed(
282            "Stock",
283            stock_id,
284            json!({
285                "id": stock_id.to_string(),
286                "sku_id": "test-sku",
287                "ubicacion": "loc-1",
288                "cantidad": 100_i64,
289            }),
290        );
291        store.seed(
292            "Caja",
293            caja_id,
294            json!({
295                "id": caja_id.to_string(),
296                "name": "Caja Test",
297                "currency": "USD",
298                "saldo": 1_000_000_i64,
299            }),
300        );
301
302        let tmp = tempfile::NamedTempFile::new().unwrap();
303        let log_path = tmp.path().to_path_buf();
304        drop(tmp);
305        let mut log = EventLog::open(&log_path).unwrap();
306
307        let venta_id = Uuid::new_v4();
308        let inputs = vec![("stock", stock_id), ("caja", caja_id)];
309        let params = json!({
310            "venta_id": venta_id.to_string(),
311            "cantidad": 5_i64,
312            "precio_unitario": 200_i64,
313            "timestamp": "2026-05-04T10:00:00Z",
314        });
315
316        let ops = execute_and_log_with_recovery(
317            &executor,
318            &mut store,
319            &mut log,
320            "vender",
321            &inputs,
322            params,
323        )
324        .expect("morphism vender debe ejecutar limpio");
325
326        assert!(!ops.is_empty());
327        let stock_after = store
328            .load("Stock", stock_id)
329            .and_then(|v| v.get("cantidad").and_then(|c| c.as_i64()))
330            .expect("stock con cantidad");
331        assert_eq!(stock_after, 95);
332        let caja_after = store
333            .load("Caja", caja_id)
334            .and_then(|v| v.get("saldo").and_then(|s| s.as_i64()))
335            .expect("caja con saldo");
336        assert_eq!(caja_after, 1_001_000);
337
338        let _ = std::fs::remove_file(&log_path);
339    }
340
341    #[test]
342    fn load_ui_modules_via_brahman_cards_returns_ui_modules_and_skips_others() {
343        let root = tempfile::tempdir().unwrap();
344
345        let a = root.path().join("alpha");
346        std::fs::create_dir(&a).unwrap();
347        std::fs::write(
348            a.join("module.json"),
349            serde_json::to_vec(&json!({
350                "id": "alpha",
351                "label": "Alpha",
352                "entities": [],
353                "menu": [],
354                "views": {}
355            }))
356            .unwrap(),
357        )
358        .unwrap();
359
360        let b = root.path().join("bravo");
361        std::fs::create_dir(&b).unwrap();
362        std::fs::write(
363            b.join("card.json"),
364            serde_json::to_vec(&json!({
365                "schema_version": 1,
366                "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
367                "label": "ente-bravo",
368                "payload": "Virtual",
369                "supervision": "OneShot"
370            }))
371            .unwrap(),
372        )
373        .unwrap();
374
375        let (modules, skipped) = load_ui_modules(root.path()).expect("load ok");
376        assert_eq!(modules.len(), 1);
377        assert_eq!(modules[0].id, "alpha");
378        assert_eq!(skipped.len(), 1);
379        assert!(skipped[0].contains("ente"));
380    }
381
382    #[test]
383    fn load_ui_modules_via_brahman_cards_rejects_invalid_module() {
384        let root = tempfile::tempdir().unwrap();
385        let sub = root.path().join("broken");
386        std::fs::create_dir(&sub).unwrap();
387        std::fs::write(
388            sub.join("module.json"),
389            serde_json::to_vec(&json!({
390                "id": "broken",
391                "label": "Broken",
392                "entities": [],
393                "menu": [{ "label": "Phantom", "view": "ghost" }],
394                "views": {}
395            }))
396            .unwrap(),
397        )
398        .unwrap();
399        let err = load_ui_modules(root.path()).unwrap_err();
400        assert!(err.contains("broken"), "msg debe nombrar el módulo: {err}");
401    }
402
403    #[test]
404    fn load_ui_modules_detects_duplicate_id() {
405        let root = tempfile::tempdir().unwrap();
406        for name in ["dir_a", "dir_b"] {
407            let sub = root.path().join(name);
408            std::fs::create_dir(&sub).unwrap();
409            std::fs::write(
410                sub.join("module.json"),
411                serde_json::to_vec(&json!({
412                    "id": "dup",
413                    "label": "Dup",
414                    "entities": [], "menu": [], "views": {}
415                }))
416                .unwrap(),
417            )
418            .unwrap();
419        }
420        let err = load_ui_modules(root.path()).unwrap_err();
421        assert!(err.contains("duplicado"));
422        assert!(err.contains("dup"));
423    }
424}