⎇ d3649bfd1a80153652c2a028315369bb4b184024
crates/apps/nakui-ui/src/main.rs
Historia
Quién tocó qué
crudo
refactor(yahweh): Fase 2c — widget render extraído a yahweh-widget-meta-form
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}