⎇ 61044844980dd4f8505d9ba94600d9b92a85423e
crates/apps/nakui-ui/src/main.rs
Historia
Quién tocó qué
crudo
refactor(yahweh): Fase 2 — extraer helpers puros a yahweh-meta-runtime
1//! `nakui-ui` — runtime GPUI de la metainterfaz Nakui.
2//!
3//! Carga módulos desde un directorio (cada módulo = un
4//! `module.json`), monta sidebar con sus menús, y renderea la vista
5//! activa en el panel principal:
6//!
7//! - **List**: tabla de instancias del entity. Botones de acción en
8//! el header (típicamente "Nuevo" → form).
9//! - **Form**: campos editables (con `yahweh-widget-text-input` para
10//! teclado real); al submit, escribe al `MemoryStore` in-process
11//! via `seed_entity` (alta directa) o por morphism (TODO en este
12//! iter).
13//!
14//! Todo el storage es in-memory por ahora — el escenario "save to
15//! disk" se materializa cuando el daemon Nakui exista. La
16//! arquitectura permite swap sin tocar la UI.
17//!
18//! ## Uso
19//!
20//! ```sh
21//! NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
22//! # default sin env: ./nakui-modules en pwd.
23//! ```
24
25use std::collections::BTreeMap;
26use std::path::PathBuf;
27use std::sync::{Arc, Mutex};
28
29use gpui::{
30 div, prelude::*, px, App, Application, Bounds, ClickEvent, Context, Entity, IntoElement,
31 KeyDownEvent, Render, SharedString, Window, WindowBounds, WindowOptions,
32};
33use nakui_core::delta::{FieldOp, FieldPath};
34use nakui_core::event_log::{
35 execute_and_log_with_recovery, replay_with_snapshot_into, EventLog, LogEntry, Snapshot,
36};
37use brahman_cards::CardBody;
38use nakui_core::executor::Executor;
39use nakui_core::store::{MemoryStore, Store};
40use yahweh_meta_runtime::{
41 compute_clear_fields, compute_field_delta, human_label_for_record, parse_field_value,
42 render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text,
43};
44use yahweh_meta_schema::{
45 Action, FieldKind, FieldSpec, FormView, ListView, Module, View,
46};
47use serde_json::{json, Value};
48use uuid::Uuid;
49use yahweh_theme::Theme;
50use yahweh_widget_text_input::TextInput;
51
52fn main() {
53 Application::new().run(|cx: &mut App| {
54 // El text input pide Theme::global; instalarlo antes de
55 // crear el window evita que panicee.
56 Theme::install_default(cx);
57
58 let bounds = Bounds::centered(None, gpui::size(px(1100.), px(720.)), cx);
59 cx.open_window(
60 WindowOptions {
61 window_bounds: Some(WindowBounds::Windowed(bounds)),
62 titlebar: Some(gpui::TitlebarOptions {
63 title: Some(SharedString::from("Nakui")),
64 ..Default::default()
65 }),
66 ..Default::default()
67 },
68 |_w, cx| cx.new(MetaUi::new),
69 )
70 .expect("open window");
71 cx.activate(true);
72 });
73}
74
75/// Estado del runtime.
76struct MetaUi {
77 /// Módulos cargados, ordenados por id.
78 modules: Vec<Module>,
79 /// Store compartido. Mutado por el submit de los forms.
80 store: Arc<Mutex<MemoryStore>>,
81 /// Event log persistente compartido. Cada `seed_entity` se appende
82 /// acá antes de mutar el store (WAL). `None` si la apertura del
83 /// log falló — en ese caso el runtime degrada a in-memory only y
84 /// loggea un toast informativo.
85 event_log: Option<Arc<Mutex<EventLog>>>,
86 /// Executors nakui cargados, indexados por `module.id`. Sólo
87 /// existen los módulos que declaran `nakui_module_dir`. Las
88 /// acciones `Morphism` requieren que el módulo activo tenga
89 /// uno; sin él, despachan un toast informativo.
90 executors: BTreeMap<String, Arc<Executor>>,
91 /// (módulo idx, vista key) actualmente activos.
92 active: Option<(usize, String)>,
93 /// Inputs vivos para el form actual: nombre del campo → TextInput.
94 /// Se reemplaza al cambiar de vista (drop de los anteriores).
95 form_inputs: BTreeMap<String, Entity<TextInput>>,
96 /// Si está set, el próximo render del Form pre-llena los inputs
97 /// con los valores del record indicado, y `commit_seed` emite
98 /// un `LogEntry::Morphism { name: "ui.edit_record", ops: [Set...] }`
99 /// en lugar de un Seed nuevo. Limpia al cambiar de view o tras
100 /// submit exitoso.
101 editing: Option<(String, Uuid)>,
102 /// Si está set, el banner modal de confirmación de delete está
103 /// activo: `(entity, id)` del record que el usuario marcó para
104 /// borrar. Permanece hasta que el usuario click [Confirmar]
105 /// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia).
106 /// Navegación a otra view también cancela.
107 pending_delete: Option<(String, Uuid)>,
108 /// Path del snapshot sibling del log; cacheado en `new()` para
109 /// que `tick_runtime_compact` no tenga que recomputarlo.
110 snap_path: PathBuf,
111 /// Threshold de auto-compaction (número de writes antes de
112 /// snapshot+compact). Cacheado del env `NAKUI_SNAPSHOT_THRESHOLD`
113 /// al startup; 0 = runtime compact desactivado.
114 snapshot_threshold: usize,
115 /// Contador de writes desde el último compact (o desde el boot
116 /// si nunca compactamos). Incrementa por cada commit_seed
117 /// efectivo / commit_morphism / commit_delete; cuando alcanza
118 /// `snapshot_threshold` dispara el auto-compact y se resetea.
119 writes_since_compact: u64,
120 /// Mensaje toast al pie (success de submit, error de carga, etc.).
121 toast: Option<SharedString>,
122 /// Si la carga de módulos falló al inicio.
123 load_error: Option<SharedString>,
124}
125
126impl MetaUi {
127 fn new(_cx: &mut Context<Self>) -> Self {
128 let modules_dir = std::env::var("NAKUI_MODULES_DIR")
129 .ok()
130 .map(PathBuf::from)
131 .unwrap_or_else(|| PathBuf::from("nakui-modules"));
132
133 // Carga via el brazo unificado: brahman_cards::load_cards_from_dir
134 // walkea subdirs buscando card.ncl/card.json/module.ncl/module.json,
135 // dispatcha al reader apropiado, devuelve Vec<Card>. Acá filtramos
136 // a los UiModule body variants y aplicamos las validaciones
137 // específicas de la UI (validate de cada Module + dedup de id).
138 // Cards de otros kinds (Ente, Monad) que aparezcan en el dir se
139 // skipean con un msg al banner — no son fatales pero el usuario
140 // sabe que estaban ahí.
141 let (modules, mut load_error) = match load_ui_modules(&modules_dir) {
142 Ok((mods, skipped)) => {
143 let toast = if skipped.is_empty() {
144 None
145 } else {
146 Some(SharedString::from(format!(
147 "skipeé {} card(s) no-UiModule en {}: {:?}",
148 skipped.len(),
149 modules_dir.display(),
150 skipped
151 )))
152 };
153 (mods, toast)
154 }
155 Err(e) => (
156 Vec::new(),
157 Some(SharedString::from(format!(
158 "no pude cargar módulos de {}: {e}",
159 modules_dir.display()
160 ))),
161 ),
162 };
163
164 // Persistencia: abrir/crear el event log + opcionalmente un
165 // snapshot sibling para acortar el replay. Path del log por
166 // env `NAKUI_EVENT_LOG`, default `./nakui-ui-state.jsonl`. El
167 // snapshot vive como sibling con extensión `.snap.json`.
168 //
169 // Si abrir o replay falla, el runtime sigue en modo in-memory
170 // (sin persistencia) y el load_error se acumula al banner.
171 //
172 // Threshold de auto-compaction via env
173 // `NAKUI_SNAPSHOT_THRESHOLD` (default 50): después del replay,
174 // si el log file tiene >= N entries, capturamos un snapshot
175 // del store actual y compactamos el log. La próxima boot ya
176 // arranca de snapshot + log corto.
177 let log_path = std::env::var("NAKUI_EVENT_LOG")
178 .map(PathBuf::from)
179 .unwrap_or_else(|_| PathBuf::from("nakui-ui-state.jsonl"));
180 let snap_path = snapshot_path_for(&log_path);
181 let snapshot_threshold: usize = std::env::var("NAKUI_SNAPSHOT_THRESHOLD")
182 .ok()
183 .and_then(|s| s.parse().ok())
184 .unwrap_or(50);
185 let mut store = MemoryStore::new();
186 let mut initial_toast: Option<SharedString> = None;
187
188 // Cargar snapshot (si existe y no falla). Un snapshot
189 // corrupto no es fatal: caemos a full replay del log.
190 let snapshot: Option<Snapshot> = match Snapshot::load(&snap_path) {
191 Ok(s) => s,
192 Err(e) => {
193 let msg = format!(
194 "snapshot {}: {e} — full replay",
195 snap_path.display()
196 );
197 match &load_error {
198 Some(prev) => {
199 load_error = Some(SharedString::from(format!("{prev}; {msg}")));
200 }
201 None => load_error = Some(SharedString::from(msg)),
202 }
203 None
204 }
205 };
206
207 let event_log = match EventLog::open(&log_path) {
208 Ok(mut log) => {
209 match replay_with_snapshot_into(&log, snapshot.as_ref(), &mut store) {
210 Ok(()) => {
211 let n = log.next_seq();
212 let from_snap = snapshot
213 .as_ref()
214 .map(|s| format!(" (snapshot @ seq {})", s.seq))
215 .unwrap_or_default();
216 if n > 0 {
217 initial_toast = Some(SharedString::from(format!(
218 "log {} cargado: next_seq={n}{from_snap}",
219 log_path.display()
220 )));
221 } else {
222 initial_toast = Some(SharedString::from(format!(
223 "log nuevo en {}",
224 log_path.display()
225 )));
226 }
227
228 // Auto-compact si pasamos el threshold. No
229 // fatal — un fallo deja log+snap como están.
230 match maybe_compact_log(
231 &mut log,
232 &snap_path,
233 &store,
234 snapshot_threshold,
235 ) {
236 Ok(Some(msg)) => {
237 let prev = initial_toast
238 .map(|t| t.to_string())
239 .unwrap_or_default();
240 initial_toast = Some(SharedString::from(format!(
241 "{prev}; {msg}"
242 )));
243 }
244 Ok(None) => {}
245 Err(e) => {
246 let msg = format!("auto-compact: {e}");
247 match &load_error {
248 Some(prev) => {
249 load_error = Some(SharedString::from(format!(
250 "{prev}; {msg}"
251 )));
252 }
253 None => load_error = Some(SharedString::from(msg)),
254 }
255 }
256 }
257
258 Some(Arc::new(Mutex::new(log)))
259 }
260 Err(e) => {
261 let msg = format!(
262 "replay del log {} falló: {e} — running in-memory",
263 log_path.display()
264 );
265 match &load_error {
266 Some(prev) => {
267 load_error = Some(SharedString::from(format!("{prev}; {msg}")));
268 }
269 None => load_error = Some(SharedString::from(msg)),
270 }
271 None
272 }
273 }
274 }
275 Err(e) => {
276 let msg = format!(
277 "abrir log {}: {e} — running in-memory only",
278 log_path.display()
279 );
280 match &load_error {
281 Some(prev) => {
282 load_error = Some(SharedString::from(format!("{prev}; {msg}")));
283 }
284 None => load_error = Some(SharedString::from(msg)),
285 }
286 None
287 }
288 };
289
290 // Cargar Executors para los módulos que declararon
291 // `nakui_module_dir`. Resolvemos paths relativos al
292 // directorio del modules (NAKUI_MODULES_DIR/<id>/), no al
293 // pwd. Cualquier error de carga deja la entry afuera y
294 // anota al banner — Action::Morphism queda no-op para ese
295 // módulo pero el resto sigue funcionando.
296 let mut executors: BTreeMap<String, Arc<Executor>> = BTreeMap::new();
297 for m in &modules {
298 let Some(rel) = &m.nakui_module_dir else {
299 continue;
300 };
301 let module_root = modules_dir.join(&m.id);
302 let nakui_dir = if std::path::Path::new(rel).is_absolute() {
303 PathBuf::from(rel)
304 } else {
305 module_root.join(rel)
306 };
307 match Executor::load_module(&nakui_dir) {
308 Ok(exec) => {
309 executors.insert(m.id.clone(), Arc::new(exec));
310 }
311 Err(e) => {
312 let msg = format!(
313 "módulo {}: no pude cargar executor nakui en {}: {e}",
314 m.id,
315 nakui_dir.display()
316 );
317 match &load_error {
318 Some(prev) => {
319 load_error = Some(SharedString::from(format!("{prev}; {msg}")));
320 }
321 None => load_error = Some(SharedString::from(msg)),
322 }
323 }
324 }
325 }
326
327 let active = modules
328 .first()
329 .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
330
331 Self {
332 modules,
333 store: Arc::new(Mutex::new(store)),
334 event_log,
335 executors,
336 active,
337 form_inputs: BTreeMap::new(),
338 editing: None,
339 pending_delete: None,
340 snap_path,
341 snapshot_threshold,
342 writes_since_compact: 0,
343 toast: initial_toast,
344 load_error,
345 }
346 }
347
348 /// Cambia la vista activa. Si la nueva vista es un Form, crea
349 /// `TextInput` entities para cada field. Pre-llena con valores
350 /// del record si hay `editing` para esa entity; si no, usa el
351 /// `default` del schema.
352 /// Drop de los inputs anteriores ocurre al sobreescribir el map.
353 fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
354 self.active = Some((mod_idx, view_key.clone()));
355 self.toast = None;
356 // Navegar a otra view cancela cualquier delete pendiente:
357 // el record marcado puede no estar visible en la nueva view.
358 self.pending_delete = None;
359 self.form_inputs = BTreeMap::new();
360 if let Some(module) = self.modules.get(mod_idx) {
361 if let Some(View::Form(form)) = module.views.get(&view_key) {
362 // Snapshot del record si estamos editando esta entity.
363 let editing_record: Option<Value> = self.editing.as_ref().and_then(|(e, id)| {
364 if e == &form.entity {
365 let store = self.store.lock().ok()?;
366 store.load(e, *id)
367 } else {
368 None
369 }
370 });
371 for f in &form.fields {
372 let initial = if let Some(rec) = &editing_record {
373 rec.get(&f.name)
374 .map(value_to_input_text)
375 .unwrap_or_else(|| f.default.clone().unwrap_or_default())
376 } else {
377 f.default.clone().unwrap_or_default()
378 };
379 let input = cx.new(|cx| TextInput::new(initial, cx));
380 self.form_inputs.insert(f.name.clone(), input);
381 }
382 } else {
383 // Cambiar a una view que no es Form invalida el editing
384 // pendiente.
385 self.editing = None;
386 }
387 }
388 cx.notify();
389 }
390
391 /// Inicia un edit del record: setea `editing` y abre la primera
392 /// view de tipo Form del módulo (convención: la del schema).
393 fn open_edit(
394 &mut self,
395 mod_idx: usize,
396 entity: String,
397 id: Uuid,
398 cx: &mut Context<Self>,
399 ) {
400 self.editing = Some((entity.clone(), id));
401 let form_view_key = self.modules.get(mod_idx).and_then(|m| {
402 m.views
403 .iter()
404 .find_map(|(key, v)| match v {
405 View::Form(form) if form.entity == entity => Some(key.clone()),
406 _ => None,
407 })
408 });
409 match form_view_key {
410 Some(key) => self.select_view(mod_idx, key, cx),
411 None => {
412 self.toast = Some(SharedString::from(format!(
413 "no hay form view para entity '{entity}' en este módulo"
414 )));
415 self.editing = None;
416 cx.notify();
417 }
418 }
419 }
420
421 /// Borra un record. Emite Morphism con un FieldOp::Delete + lo
422 /// aplica al store via `apply` (no via remove directo, mantiene
423 /// el modelo de "todo cambio post-seed pasa por ops").
424 fn commit_delete(
425 &mut self,
426 entity: &str,
427 id: Uuid,
428 ) -> Result<(), String> {
429 let ops = vec![FieldOp::Delete {
430 entity: entity.to_string(),
431 id,
432 }];
433 if let Some(log_arc) = self.event_log.as_ref() {
434 let mut log = log_arc
435 .lock()
436 .map_err(|_| "log mutex envenenado".to_string())?;
437 let seq = log.next_seq();
438 log.append(LogEntry::Morphism {
439 seq,
440 morphism: "ui.delete_record".into(),
441 inputs: Default::default(),
442 params: json!({ "entity": entity, "id": id.to_string() }),
443 ops: ops.clone(),
444 schema_hash: None,
445 })
446 .map_err(|e| format!("append al log: {e}"))?;
447 }
448 let mut store = self
449 .store
450 .lock()
451 .map_err(|_| "store mutex envenenado".to_string())?;
452 store
453 .apply(&ops)
454 .map_err(|e| format!("apply Delete: {e}"))?;
455 Ok(())
456 }
457
458 /// Incrementa el contador de writes y, si cruzó el threshold,
459 /// ejecuta `maybe_compact_log` (snapshot + compact). Devuelve un
460 /// mensaje de status si compactó (para concatenar al toast del
461 /// op original) o si la compactación falló; `None` si todavía no
462 /// alcanzó el threshold.
463 ///
464 /// Llamar SIEMPRE después de cada write efectivo (commit_seed
465 /// Created/Updated, commit_morphism Ok, commit_delete Ok).
466 /// `NoChange` NO debería llamar — no hay write nuevo que contar.
467 ///
468 /// Threshold == 0 desactiva el runtime compact (early return).
469 fn tick_runtime_compact(&mut self) -> Option<String> {
470 if self.snapshot_threshold == 0 {
471 return None;
472 }
473 self.writes_since_compact += 1;
474 if self.writes_since_compact < self.snapshot_threshold as u64 {
475 return None;
476 }
477 let log_arc = self.event_log.as_ref()?.clone();
478 let mut log = match log_arc.lock() {
479 Ok(l) => l,
480 Err(_) => return Some("auto-compact skip: log mutex envenenado".into()),
481 };
482 let store = match self.store.lock() {
483 Ok(s) => s,
484 Err(_) => return Some("auto-compact skip: store mutex envenenado".into()),
485 };
486 match maybe_compact_log(&mut log, &self.snap_path, &store, self.snapshot_threshold) {
487 Ok(Some(msg)) => {
488 self.writes_since_compact = 0;
489 Some(msg)
490 }
491 Ok(None) => {
492 // Counter cruzó pero entry_count no — log tiene < 2
493 // entries en disco (ej: snapshot ya cubrió todo y sólo
494 // quedan los nuevos). Reseteamos para no re-entrar
495 // en cada write subsiguiente.
496 self.writes_since_compact = 0;
497 None
498 }
499 Err(e) => {
500 // Compactación falló — NO reseteamos el counter, así
501 // el próximo write reintenta. El usuario ve el error
502 // en el toast.
503 Some(format!("auto-compact: {e}"))
504 }
505 }
506 }
507
508 /// Aplica una acción (click en menú, botón de form, action de
509 /// list). Mutaciones contra el store ocurren acá.
510 fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
511 let mod_idx = match self.active.as_ref() {
512 Some((i, _)) => *i,
513 None => return,
514 };
515 match action {
516 Action::OpenView { view, .. } => {
517 // Salir a otra view cancela el edit pendiente.
518 self.editing = None;
519 self.select_view(mod_idx, view, cx);
520 }
521 Action::SeedEntity { entity, next_view } => {
522 match self.commit_seed(mod_idx, &entity, cx) {
523 Ok(outcome) => {
524 let id = outcome.id();
525 let toast_msg = match &outcome {
526 CommitOutcome::Created(_) => {
527 format!("creado {entity} {}", short_uuid(&id))
528 }
529 CommitOutcome::Updated { changed, .. } => {
530 format!(
531 "actualizado {entity} {} ({changed} campo(s))",
532 short_uuid(&id)
533 )
534 }
535 CommitOutcome::NoChange(_) => {
536 format!(
537 "{entity} {} sin cambios — no log entry",
538 short_uuid(&id)
539 )
540 }
541 };
542 // NoChange no escribió al log → no avanza el
543 // counter de runtime compact. Created/Updated
544 // sí lo hacen.
545 let was_write = !matches!(outcome, CommitOutcome::NoChange(_));
546 self.toast = Some(append_compact_msg(
547 toast_msg,
548 was_write.then(|| self.tick_runtime_compact()).flatten(),
549 ));
550 // Limpia editing tras un commit exitoso —
551 // el record ya está sincronizado (incluso
552 // un NoChange cierra el modo edit).
553 self.editing = None;
554 if let Some(v) = next_view {
555 self.select_view(mod_idx, v, cx);
556 } else {
557 for input in self.form_inputs.values() {
558 input.update(cx, |inp, cx| inp.set_text("", cx));
559 }
560 }
561 }
562 Err(e) => {
563 self.toast = Some(SharedString::from(format!("error: {e}")));
564 }
565 }
566 cx.notify();
567 }
568 Action::Morphism {
569 name,
570 inputs,
571 params,
572 next_view,
573 } => {
574 match self.commit_morphism(mod_idx, &name, &inputs, ¶ms, cx) {
575 Ok(op_count) => {
576 let base = format!("morphism '{name}' OK ({op_count} op(s) aplicadas)");
577 self.toast =
578 Some(append_compact_msg(base, self.tick_runtime_compact()));
579 if let Some(v) = next_view {
580 self.select_view(mod_idx, v, cx);
581 }
582 }
583 Err(e) => {
584 self.toast = Some(SharedString::from(format!(
585 "morphism '{name}' falló: {e}"
586 )));
587 }
588 }
589 cx.notify();
590 }
591 }
592 }
593
594 /// Despacha un morphism al pipeline real de nakui-core: lee
595 /// inputs (UUIDs) + params (Value object) del form, llama
596 /// `execute_and_log_with_recovery`. Devuelve la cantidad de ops
597 /// que el morphism produjo (para feedback).
598 fn commit_morphism(
599 &mut self,
600 mod_idx: usize,
601 morphism: &str,
602 inputs_map: &BTreeMap<String, String>,
603 params_fields: &[String],
604 cx: &mut Context<Self>,
605 ) -> Result<usize, String> {
606 let _ = cx;
607 let module = self
608 .modules
609 .get(mod_idx)
610 .ok_or_else(|| "módulo inválido".to_string())?;
611 let executor = self
612 .executors
613 .get(&module.id)
614 .ok_or_else(|| {
615 format!(
616 "módulo '{}' no tiene executor nakui (falta nakui_module_dir o falló la carga)",
617 module.id
618 )
619 })?
620 .clone();
621 let log_arc = self
622 .event_log
623 .as_ref()
624 .ok_or_else(|| "morphism requiere event log activo".to_string())?
625 .clone();
626
627 // Resolver inputs: por cada (role, field_name), parsear el
628 // value del input como Uuid.
629 let mut input_pairs: Vec<(String, Uuid)> = Vec::with_capacity(inputs_map.len());
630 for (role, field_name) in inputs_map {
631 let raw = self
632 .form_inputs
633 .get(field_name)
634 .map(|inp| inp.read(&*cx).text().to_string())
635 .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
636 let id = Uuid::parse_str(raw.trim()).map_err(|_| {
637 format!(
638 "input '{role}' (field '{field_name}'): '{raw}' no es UUID válido"
639 )
640 })?;
641 input_pairs.push((role.clone(), id));
642 }
643
644 // Resolver params: si la lista está vacía, todos los fields
645 // del form que no estén en `inputs_map` van a params. Si
646 // hay lista, sólo esos.
647 let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect();
648 let mut params_obj = serde_json::Map::new();
649 let field_iter: Vec<String> = if params_fields.is_empty() {
650 self.form_inputs
651 .keys()
652 .filter(|k| !input_field_set.contains(*k))
653 .cloned()
654 .collect()
655 } else {
656 params_fields.to_vec()
657 };
658
659 // Buscamos los FieldSpec del Form view activo para conocer
660 // el `kind` declarado de cada param. Usamos `parse_field_value`
661 // estricto en lugar de la heurística `infer_param_value` —
662 // así un "abc" en un campo Boolean rebota en la UI con un
663 // mensaje claro ANTES de llegar al morphism Rhai.
664 let active_form_fields: Option<Vec<FieldSpec>> = self.active.as_ref().and_then(|(_, vk)| {
665 module.views.get(vk).and_then(|v| match v {
666 View::Form(f) => Some(f.fields.clone()),
667 _ => None,
668 })
669 });
670
671 for field_name in field_iter {
672 let raw = self
673 .form_inputs
674 .get(&field_name)
675 .map(|inp| inp.read(&*cx).text().to_string())
676 .unwrap_or_default();
677 let spec = active_form_fields
678 .as_ref()
679 .and_then(|fs| fs.iter().find(|f| f.name == field_name));
680 let value = resolve_param_value(&field_name, &raw, spec)?;
681 params_obj.insert(field_name, value);
682 }
683
684 let inputs_ref: Vec<(&str, Uuid)> = input_pairs
685 .iter()
686 .map(|(r, id)| (r.as_str(), *id))
687 .collect();
688
689 let mut log = log_arc
690 .lock()
691 .map_err(|_| "log mutex envenenado".to_string())?;
692 let mut store = self
693 .store
694 .lock()
695 .map_err(|_| "store mutex envenenado".to_string())?;
696
697 let ops = execute_and_log_with_recovery(
698 &executor,
699 &mut *store,
700 &mut *log,
701 morphism,
702 &inputs_ref,
703 Value::Object(params_obj),
704 )
705 .map_err(|e| format!("{e}"))?;
706 Ok(ops.len())
707 }
708
709 /// Construye un Value desde los TextInput vivos y lo seedea al store.
710 /// Resultado de `commit_seed`. Distingue alta nueva vs edit
711 /// efectivo vs no-op para que el toast sea preciso.
712 fn commit_seed(
713 &mut self,
714 mod_idx: usize,
715 entity: &str,
716 cx: &mut Context<Self>,
717 ) -> Result<CommitOutcome, String> {
718 let module = &self.modules[mod_idx];
719 let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
720 Some((_, view_key)) => match module.views.get(view_key) {
721 Some(View::Form(f)) => f.fields.clone(),
722 _ => return Err("la vista activa no es un Form".into()),
723 },
724 None => return Err("ninguna vista activa".into()),
725 };
726 let mut obj = serde_json::Map::new();
727 // Fields que el form deja vacíos y son optional: candidatos a
728 // `FieldOp::Clear` en el path de EDIT (sólo se emiten si el
729 // current store value tiene algo en ese key). En el SEED path
730 // simplemente no se incluyen, igual que antes.
731 let mut to_clear: Vec<String> = Vec::new();
732 // EntityRef references a chequear existencia DESPUÉS del parse
733 // loop (necesitan lock del store; lo tomamos una sola vez).
734 // Tuple: (label legible, target entity, parsed UUID).
735 let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new();
736 for f in &spec_fields {
737 let raw = self
738 .form_inputs
739 .get(&f.name)
740 .map(|input| input.read(cx).text().to_string())
741 .unwrap_or_default();
742 if f.required && raw.trim().is_empty() {
743 return Err(format!("campo '{}' es obligatorio", f.label));
744 }
745 if raw.is_empty() && !f.required {
746 to_clear.push(f.name.clone());
747 continue;
748 }
749 let value = parse_field_value(f.kind, &raw)
750 .map_err(|e| format!("campo '{}': {e}", f.label))?;
751 // Si el field es EntityRef y declara ref_entity, encolamos
752 // el (label, target, uuid) para validar existence en lote.
753 // El UUID ya está bien-formado (parse_field_value lo
754 // validó); ahora chequeamos que el record exista.
755 if f.kind == FieldKind::EntityRef {
756 if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
757 let id = Uuid::parse_str(uuid_str)
758 .expect("parse_field_value validated UUID");
759 entity_refs.push((f.label.clone(), target.clone(), id));
760 }
761 }
762 obj.insert(f.name.clone(), value);
763 }
764 // Validar EntityRefs contra el store actual. Una sola toma del
765 // lock para todas las refs.
766 if !entity_refs.is_empty() {
767 let store = self
768 .store
769 .lock()
770 .map_err(|_| "store mutex envenenado".to_string())?;
771 // El helper de yahweh-meta-runtime es store-agnóstico —
772 // toma un cierre `Fn(&str, Uuid) -> Option<Value>` que
773 // wrappea el store concreto.
774 validate_entity_refs(|e, id| store.load(e, id), &entity_refs)?;
775 }
776 // Ramificación: si `editing` está set para esta entity, es un
777 // edit de un record existente — emitimos Morphism con un
778 // FieldOp::Set por cada campo del form (sobreescribe). Si no,
779 // es alta nueva — emitimos Seed con UUID fresco.
780 let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
781
782 if let Some((_, id)) = editing_match {
783 // EDIT path: delta-only. Cargar el record actual del store
784 // y emitir `FieldOp::Set` por cada campo cuyo valor nuevo
785 // difiere del actual + `FieldOp::Clear` por cada optional
786 // empty cuyo current tenía un valor non-null. Si nada
787 // cambió, ningún log entry y ningún apply.
788 let current: Value = {
789 let store = self
790 .store
791 .lock()
792 .map_err(|_| "store mutex envenenado".to_string())?;
793 store.load(entity, id).unwrap_or(Value::Null)
794 };
795 let set_delta = compute_field_delta(¤t, &obj);
796 let clear_fields = compute_clear_fields(¤t, &to_clear);
797
798 if set_delta.is_empty() && clear_fields.is_empty() {
799 // No-op edit: no entry al log, no apply.
800 return Ok(CommitOutcome::NoChange(id));
801 }
802
803 let mut ops: Vec<FieldOp> = set_delta
804 .iter()
805 .map(|(field, value)| FieldOp::Set {
806 path: FieldPath {
807 entity: entity.to_string(),
808 id,
809 field: field.clone(),
810 },
811 value: value.clone(),
812 })
813 .collect();
814 for field in &clear_fields {
815 ops.push(FieldOp::Clear {
816 path: FieldPath {
817 entity: entity.to_string(),
818 id,
819 field: field.clone(),
820 },
821 });
822 }
823
824 if let Some(log_arc) = self.event_log.as_ref() {
825 let mut log = log_arc
826 .lock()
827 .map_err(|_| "log mutex envenenado".to_string())?;
828 let seq = log.next_seq();
829 let mut params = serde_json::Map::new();
830 params.insert("entity".into(), json!(entity));
831 params.insert("id".into(), json!(id.to_string()));
832 if !set_delta.is_empty() {
833 params.insert("fields".into(), Value::Object(set_delta.clone()));
834 }
835 if !clear_fields.is_empty() {
836 params.insert(
837 "cleared".into(),
838 Value::Array(
839 clear_fields.iter().map(|s| json!(s)).collect(),
840 ),
841 );
842 }
843 log.append(LogEntry::Morphism {
844 seq,
845 morphism: "ui.edit_record".into(),
846 inputs: Default::default(),
847 params: Value::Object(params),
848 ops: ops.clone(),
849 schema_hash: None,
850 })
851 .map_err(|e| format!("append al log: {e}"))?;
852 }
853 let mut store = self
854 .store
855 .lock()
856 .map_err(|_| "store mutex envenenado".to_string())?;
857 store.apply(&ops).map_err(|e| format!("apply edit ops: {e}"))?;
858 Ok(CommitOutcome::Updated {
859 id,
860 changed: set_delta.len() + clear_fields.len(),
861 })
862 } else {
863 // SEED path: alta nueva.
864 let id = Uuid::new_v4();
865 let data = Value::Object(obj);
866 if let Some(log_arc) = self.event_log.as_ref() {
867 let mut log = log_arc
868 .lock()
869 .map_err(|_| "log mutex envenenado".to_string())?;
870 let seq = log.next_seq();
871 log.append(LogEntry::Seed {
872 seq,
873 entity: entity.to_string(),
874 id,
875 data: data.clone(),
876 schema_hash: None,
877 })
878 .map_err(|e| format!("append al log: {e}"))?;
879 }
880 let mut store = self
881 .store
882 .lock()
883 .map_err(|_| "store mutex envenenado".to_string())?;
884 store.seed(entity, id, data);
885 Ok(CommitOutcome::Created(id))
886 }
887 }
888
889 /// Snapshot ordenado de records de una entity.
890 fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
891 let store = match self.store.lock() {
892 Ok(g) => g,
893 Err(_) => return Vec::new(),
894 };
895 let it = match store.iter() {
896 Ok(i) => i,
897 Err(_) => return Vec::new(),
898 };
899 it.filter(|(e, _, _)| e == entity)
900 .map(|(_, id, v)| (id, v))
901 .collect()
902 }
903}
904
905/// Resultado de `commit_seed`. Distingue alta nueva, edit efectivo
906/// con N campos modificados, y no-op (delta vacío en el path de edit).
907#[derive(Debug, Clone, PartialEq, Eq)]
908enum CommitOutcome {
909 Created(Uuid),
910 Updated { id: Uuid, changed: usize },
911 NoChange(Uuid),
912}
913
914impl CommitOutcome {
915 fn id(&self) -> Uuid {
916 match self {
917 Self::Created(id) | Self::Updated { id, .. } | Self::NoChange(id) => *id,
918 }
919 }
920}
921
922/// Concatena un mensaje de compact opcional al toast del op original.
923/// Devuelve el toast resultante listo para ir a `SharedString`.
924/// Sin `compact_msg` devuelve `base` tal cual.
925/// Carga UiModules desde un directorio via el brazo unificado
926/// `brahman_cards::load_cards_from_dir`. Aplica las reglas
927/// específicas de la UI:
928/// - Sólo `CardBody::UiModule` cuenta; otros body kinds
929/// (Ente, Monad, ...) se reportan en el `skipped` para que el
930/// runtime los muestre como banner informativo.
931/// - Cada `Module` se valida via `Module::validate()`.
932/// - Detecta `id` duplicados entre módulos UiModule (el runtime
933/// los direcciona por id; duplicados serían ambiguos).
934///
935/// Devuelve `(modules, skipped_ids)` ordenados por id.
936fn load_ui_modules(
937 dir: &std::path::Path,
938) -> Result<(Vec<Module>, Vec<String>), String> {
939 let cards = brahman_cards::load_cards_from_dir(dir)
940 .map_err(|e| e.to_string())?;
941 let mut modules: Vec<Module> = Vec::new();
942 let mut skipped: Vec<String> = Vec::new();
943 for c in cards {
944 match c.body {
945 CardBody::UiModule(m) => modules.push(m),
946 other => skipped.push(format!("{}({})", c.id, other.kind_name())),
947 }
948 }
949 for m in &modules {
950 m.validate()
951 .map_err(|e| format!("módulo '{}' inválido: {e}", m.id))?;
952 }
953 modules.sort_by(|a, b| a.id.cmp(&b.id));
954 let mut prev: Option<&Module> = None;
955 for cur in &modules {
956 if let Some(p) = prev {
957 if p.id == cur.id {
958 return Err(format!(
959 "id de módulo duplicado: '{}' aparece más de una vez",
960 cur.id
961 ));
962 }
963 }
964 prev = Some(cur);
965 }
966 Ok((modules, skipped))
967}
968
969fn append_compact_msg(base: String, compact_msg: Option<String>) -> SharedString {
970 match compact_msg {
971 Some(m) => SharedString::from(format!("{base}; {m}")),
972 None => SharedString::from(base),
973 }
974}
975
976/// Devuelve el path del snapshot sibling para un log dado:
977/// `nakui-ui-state.jsonl` → `nakui-ui-state.snap.json`. Mantiene el
978/// snapshot junto al log para que un usuario pueda mover la pareja
979/// sin desincronizarlos.
980fn snapshot_path_for(log_path: &std::path::Path) -> PathBuf {
981 log_path.with_extension("snap.json")
982}
983
984/// Si el log file tiene >= `threshold` entries, captura un snapshot
985/// del store actual y compacta el log. Idempotente abajo del threshold.
986///
987/// Cursor invariant: `EventLog::open` re-deriva `next_seq` del primer
988/// entry del archivo. Si compactáramos *todo*, al reabrir el cursor
989/// volvería a 0 — el próximo append crashearía con NonMonotonic. Por
990/// eso siempre dejamos la última entry como anchor: compactamos sólo
991/// hasta `next_seq - 2`. La survivor entry del snap.seq queda en
992/// disco pero `replay_with_snapshot_into` la skipea (snap ya cubre
993/// hasta snap.seq inclusive), así el costo es 1 línea de log y el
994/// resultado del replay es idéntico.
995///
996/// Orden de operaciones (importa por crash safety):
997/// 1. Capturar snapshot en memoria.
998/// 2. `Snapshot::write` (atómico via tempfile + fsync + rename).
999/// 3. `EventLog::compact_through` (atómico igual).
1000/// Si (3) falla tras (2) éxito, el próximo boot ve snap@K + log con
1001/// entries 0..N — `replay_with_snapshot_into` skipea las cubiertas
1002/// por snap, outcome idéntico.
1003///
1004/// Devuelve `Ok(Some(msg))` si compactó, `Ok(None)` si no había
1005/// nada que hacer, `Err(s)` si snapshot/compact falló.
1006fn maybe_compact_log(
1007 log: &mut EventLog,
1008 snap_path: &std::path::Path,
1009 store: &MemoryStore,
1010 threshold: usize,
1011) -> Result<Option<String>, String> {
1012 if threshold == 0 {
1013 return Ok(None);
1014 }
1015 let entry_count = log
1016 .entries()
1017 .map_err(|e| format!("read entries: {e}"))?
1018 .len();
1019 if entry_count < threshold || entry_count < 2 {
1020 // < 2 entries: la regla "dejar 1 como anchor" no permite
1021 // dropear nada útil (sólo el anchor sobreviviría).
1022 // entry_count<threshold también incluye el caso post-compact
1023 // donde sobrevive 1 anchor: idempotente, no rebote infinito.
1024 return Ok(None);
1025 }
1026 let snap_seq = log.next_seq() - 1;
1027 let through = log.next_seq() - 2;
1028 let snap = Snapshot::from_memory_store(store, snap_seq);
1029 snap.write(snap_path)
1030 .map_err(|e| format!("write snapshot {}: {e}", snap_path.display()))?;
1031 log.compact_through(through)
1032 .map_err(|e| format!("compact_through({through}): {e}"))?;
1033 Ok(Some(format!(
1034 "auto-compact: snapshot @ seq {snap_seq}, {} entries dropped (1 anchor kept)",
1035 entry_count - 1
1036 )))
1037}
1038
1039/// Walker dentro de un `Value` por path con `.` como separador.
1040/// Local porque sólo lo usa la lista renderer y no tiene tests
1041/// dedicados afuera. Si crece su uso se puede mover a meta-runtime.
1042fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
1043 let mut cur = v;
1044 for seg in path.split('.') {
1045 cur = cur.get(seg)?;
1046 }
1047 Some(cur)
1048}
1049
1050impl Render for MetaUi {
1051 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1052 let bg = gpui::rgb(0x14171c);
1053 let panel = gpui::rgb(0x1d2128);
1054 let border = gpui::rgb(0x2a2f38);
1055 let text = gpui::rgb(0xe6e8ec);
1056 let text_dim = gpui::rgb(0x9ba1ad);
1057 let accent = gpui::rgb(0x88c0d0);
1058 let accent_active = gpui::rgb(0xa3be8c);
1059
1060 let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
1061 let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
1062 let confirm_banner = self.render_confirm_delete_banner(cx);
1063 let toast_div = self.toast.as_ref().map(|t| {
1064 div()
1065 .px(px(12.))
1066 .py(px(6.))
1067 .bg(gpui::rgb(0x2d3a2a))
1068 .text_color(gpui::rgb(0xc0e0a0))
1069 .text_size(px(11.))
1070 .child(t.clone())
1071 });
1072 let error_banner = self.load_error.as_ref().map(|e| {
1073 div()
1074 .px(px(12.))
1075 .py(px(6.))
1076 .bg(gpui::rgb(0x4a2020))
1077 .text_color(gpui::rgb(0xffd0d0))
1078 .text_size(px(11.))
1079 .child(e.clone())
1080 });
1081
1082 div()
1083 .flex()
1084 .flex_col()
1085 .size_full()
1086 .bg(bg)
1087 // Capture phase: el Esc llega al root ANTES que cualquier
1088 // TextInput descendiente. Si hay un delete pendiente, lo
1089 // cancelamos. Sin pending no hacemos nada (el evento sigue
1090 // su flujo normal y el TextInput recibe el Esc bubble).
1091 .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _w, cx| {
1092 if event.keystroke.key != "escape" {
1093 return;
1094 }
1095 if let Some((entity, _id)) = this.pending_delete.take() {
1096 this.toast = Some(SharedString::from(format!(
1097 "delete cancelado ({entity}) [esc]"
1098 )));
1099 cx.notify();
1100 }
1101 }))
1102 .when_some(error_banner, |d, b| d.child(b))
1103 .when_some(confirm_banner, |d, b| d.child(b))
1104 .child(
1105 div()
1106 .flex()
1107 .flex_row()
1108 .flex_grow()
1109 .child(sidebar)
1110 .child(main_panel),
1111 )
1112 .when_some(toast_div, |d, t| d.child(t))
1113 }
1114}
1115
1116impl MetaUi {
1117 /// Renderea el banner modal de confirmación cuando hay un delete
1118 /// pendiente. Devuelve `None` si no hay nada que confirmar.
1119 ///
1120 /// UX: banner amber prominente arriba de todo el contenido. No es
1121 /// un overlay flotante (GPUI no expone z-index fácilmente sin
1122 /// setup) — es un row del flex_col raíz, así que el contenido
1123 /// debajo se desplaza unos pixels mientras está activo. Suficiente
1124 /// para forzar al usuario a leer + click.
1125 fn render_confirm_delete_banner(&self, cx: &mut Context<Self>) -> Option<gpui::Div> {
1126 let (entity, id) = self.pending_delete.as_ref()?;
1127 let entity_owned = entity.clone();
1128 let id_owned = *id;
1129 let entity_for_cancel = entity_owned.clone();
1130 let id_short = short_uuid(&id_owned);
1131 let entity_for_confirm = entity_owned.clone();
1132
1133 let banner_bg = gpui::rgb(0x4a3a1a);
1134 let banner_text = gpui::rgb(0xf0e0a0);
1135 let confirm_bg = gpui::rgb(0x6a2222);
1136 let confirm_text = gpui::rgb(0xffd0d0);
1137 let cancel_bg = gpui::rgb(0x2a2f38);
1138 let cancel_text = gpui::rgb(0xc0c8d0);
1139
1140 Some(
1141 div()
1142 .flex()
1143 .flex_row()
1144 .items_center()
1145 .gap(px(12.))
1146 .px(px(12.))
1147 .py(px(8.))
1148 .bg(banner_bg)
1149 .text_color(banner_text)
1150 .text_size(px(12.))
1151 .child(
1152 div()
1153 .flex_grow()
1154 .flex()
1155 .flex_col()
1156 .child(format!("¿Borrar {entity_owned} {id_short}?"))
1157 .child(
1158 div()
1159 .text_size(px(10.))
1160 .text_color(gpui::rgb(0xc0a070))
1161 .child("Esc para cancelar · click [Confirmar] para borrar"),
1162 ),
1163 )
1164 .child(
1165 div()
1166 .id(SharedString::from(format!(
1167 "confirm-del-cancel-{}",
1168 id_owned
1169 )))
1170 .px(px(10.))
1171 .py(px(4.))
1172 .bg(cancel_bg)
1173 .text_color(cancel_text)
1174 .hover(|d| d.bg(gpui::rgb(0x3a3f48)))
1175 .child("Cancelar")
1176 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1177 this.pending_delete = None;
1178 this.toast = Some(SharedString::from(format!(
1179 "delete cancelado ({entity_for_cancel})"
1180 )));
1181 cx.notify();
1182 })),
1183 )
1184 .child(
1185 div()
1186 .id(SharedString::from(format!(
1187 "confirm-del-ok-{}",
1188 id_owned
1189 )))
1190 .px(px(10.))
1191 .py(px(4.))
1192 .bg(confirm_bg)
1193 .text_color(confirm_text)
1194 .hover(|d| d.bg(gpui::rgb(0x8a2828)))
1195 .child("Confirmar")
1196 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1197 // Limpiar primero para que un fallo del
1198 // commit no deje el banner colgado y el
1199 // toast tape el banner.
1200 this.pending_delete = None;
1201 match this.commit_delete(&entity_for_confirm, id_owned) {
1202 Ok(()) => {
1203 let base = format!(
1204 "borrado {entity_for_confirm} {}",
1205 short_uuid(&id_owned)
1206 );
1207 this.toast = Some(append_compact_msg(
1208 base,
1209 this.tick_runtime_compact(),
1210 ));
1211 }
1212 Err(e) => {
1213 this.toast = Some(SharedString::from(format!(
1214 "error borrando: {e}"
1215 )));
1216 }
1217 }
1218 cx.notify();
1219 })),
1220 ),
1221 )
1222 }
1223
1224 fn render_sidebar(
1225 &self,
1226 cx: &mut Context<Self>,
1227 panel: gpui::Rgba,
1228 border: gpui::Rgba,
1229 text: gpui::Rgba,
1230 text_dim: gpui::Rgba,
1231 accent_active: gpui::Rgba,
1232 ) -> gpui::Div {
1233 let mut sidebar = div()
1234 .w(px(240.))
1235 .h_full()
1236 .bg(panel)
1237 .border_r_1()
1238 .border_color(border)
1239 .flex()
1240 .flex_col();
1241
1242 sidebar = sidebar.child(
1243 div()
1244 .px(px(12.))
1245 .py(px(10.))
1246 .text_color(text)
1247 .text_size(px(13.))
1248 .child("Nakui"),
1249 );
1250
1251 if self.modules.is_empty() {
1252 return sidebar.child(
1253 div()
1254 .px(px(12.))
1255 .py(px(8.))
1256 .text_color(text_dim)
1257 .text_size(px(11.))
1258 .child("(no hay módulos cargados)"),
1259 );
1260 }
1261
1262 // Snapshot del active para evitar borrow del self adentro de la closure.
1263 let active_snapshot = self.active.clone();
1264
1265 for (mod_idx, m) in self.modules.iter().enumerate() {
1266 sidebar = sidebar.child(
1267 div()
1268 .px(px(12.))
1269 .py(px(8.))
1270 .border_t_1()
1271 .border_color(border)
1272 .text_color(text)
1273 .text_size(px(12.))
1274 .child(m.label.clone()),
1275 );
1276
1277 for item in &m.menu {
1278 let is_active = active_snapshot
1279 .as_ref()
1280 .map(|(i, v)| *i == mod_idx && v == &item.view)
1281 .unwrap_or(false);
1282 let label = item
1283 .icon
1284 .as_deref()
1285 .map(|ic| format!("{ic} {}", item.label))
1286 .unwrap_or_else(|| item.label.clone());
1287
1288 let view_key = item.view.clone();
1289 sidebar = sidebar.child(
1290 div()
1291 .id(SharedString::from(format!(
1292 "menu-{}-{}",
1293 mod_idx, item.view
1294 )))
1295 .px(px(20.))
1296 .py(px(6.))
1297 .text_size(px(12.))
1298 .text_color(if is_active { accent_active } else { text_dim })
1299 .when(is_active, |d| {
1300 d.bg(gpui::rgb(0x232a36)).text_color(text)
1301 })
1302 .hover(|d| d.bg(gpui::rgb(0x1f2630)))
1303 .child(label)
1304 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1305 this.select_view(mod_idx, view_key.clone(), cx);
1306 })),
1307 );
1308 }
1309 }
1310 sidebar
1311 }
1312
1313 fn render_main(
1314 &mut self,
1315 cx: &mut Context<Self>,
1316 panel: gpui::Rgba,
1317 border: gpui::Rgba,
1318 text: gpui::Rgba,
1319 text_dim: gpui::Rgba,
1320 accent: gpui::Rgba,
1321 ) -> gpui::Div {
1322 let main = div()
1323 .flex_grow()
1324 .h_full()
1325 .bg(panel)
1326 .flex()
1327 .flex_col()
1328 .p(px(16.));
1329
1330 let (mod_idx, view_key) = match &self.active {
1331 Some(a) => (a.0, a.1.clone()),
1332 None => {
1333 return main.child(
1334 div()
1335 .text_color(text_dim)
1336 .child("Seleccioná un menú a la izquierda."),
1337 );
1338 }
1339 };
1340
1341 let view = match self
1342 .modules
1343 .get(mod_idx)
1344 .and_then(|m| m.views.get(&view_key))
1345 {
1346 Some(v) => v.clone(),
1347 None => {
1348 return main.child(
1349 div()
1350 .text_color(text_dim)
1351 .child(format!("Vista no encontrada: {view_key}")),
1352 );
1353 }
1354 };
1355
1356 match view {
1357 View::List(lv) => self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent),
1358 View::Form(fv) => self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent),
1359 }
1360 }
1361
1362 #[allow(clippy::too_many_arguments)]
1363 fn render_list(
1364 &self,
1365 cx: &mut Context<Self>,
1366 mut main: gpui::Div,
1367 lv: &ListView,
1368 mod_idx: usize,
1369 border: gpui::Rgba,
1370 text: gpui::Rgba,
1371 text_dim: gpui::Rgba,
1372 accent: gpui::Rgba,
1373 ) -> gpui::Div {
1374 let mut header = div()
1375 .flex()
1376 .flex_row()
1377 .items_center()
1378 .gap(px(12.))
1379 .mb(px(12.))
1380 .child(
1381 div()
1382 .text_color(text)
1383 .text_size(px(18.))
1384 .child(lv.title.clone()),
1385 );
1386 for (idx, action) in lv.actions.iter().enumerate() {
1387 let label = match action {
1388 Action::OpenView { label, view } => {
1389 label.clone().unwrap_or_else(|| format!("→ {view}"))
1390 }
1391 Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
1392 Action::Morphism { name, .. } => format!("⚡ {name}"),
1393 };
1394 let action_clone = action.clone();
1395 header = header.child(
1396 div()
1397 .id(SharedString::from(format!(
1398 "list-action-{mod_idx}-{idx}"
1399 )))
1400 .px(px(10.))
1401 .py(px(4.))
1402 .bg(gpui::rgb(0x232a36))
1403 .text_color(accent)
1404 .text_size(px(11.))
1405 .rounded(px(3.))
1406 .hover(|d| d.bg(gpui::rgb(0x2c3540)))
1407 .child(label)
1408 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1409 this.apply_action(action_clone.clone(), cx);
1410 })),
1411 );
1412 }
1413 main = main.child(header);
1414
1415 let rows = self.list_rows(&lv.entity);
1416 let total = rows.len();
1417
1418 let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
1419 let mut col_header = div()
1420 .flex()
1421 .flex_row()
1422 .py(px(6.))
1423 .border_b_1()
1424 .border_color(border)
1425 .text_color(text_dim)
1426 .text_size(px(11.));
1427 for c in &lv.columns {
1428 let frac = c.weight / total_weight;
1429 col_header = col_header.child(
1430 div()
1431 .flex_grow()
1432 .flex_basis(px(100. * frac))
1433 .child(c.label.clone()),
1434 );
1435 }
1436 col_header = col_header
1437 .child(div().w(px(80.)).text_color(text_dim).child("id"))
1438 .child(div().w(px(70.)).text_color(text_dim).child("acciones"));
1439 main = main.child(col_header);
1440
1441 let entity_name = lv.entity.clone();
1442 for (id, value) in &rows {
1443 let id_copy = *id;
1444 let entity_for_edit = entity_name.clone();
1445 let entity_for_delete = entity_name.clone();
1446 let mut row = div()
1447 .flex()
1448 .flex_row()
1449 .py(px(6.))
1450 .border_b_1()
1451 .border_color(gpui::rgb(0x232a36))
1452 .text_color(text)
1453 .text_size(px(12.));
1454 for c in &lv.columns {
1455 let frac = c.weight / total_weight;
1456 let v = lookup_field(value, &c.field);
1457 row = row.child(
1458 div()
1459 .flex_grow()
1460 .flex_basis(px(100. * frac))
1461 .child(render_value(v)),
1462 );
1463 }
1464 row = row.child(
1465 div()
1466 .w(px(80.))
1467 .text_color(text_dim)
1468 .text_size(px(11.))
1469 .child(short_uuid(id)),
1470 );
1471 // Acciones: ✎ edit + ✕ delete por fila.
1472 row = row.child(
1473 div()
1474 .w(px(70.))
1475 .flex()
1476 .flex_row()
1477 .gap(px(4.))
1478 .child(
1479 div()
1480 .id(SharedString::from(format!(
1481 "row-edit-{mod_idx}-{id_copy}"
1482 )))
1483 .px(px(6.))
1484 .text_color(accent)
1485 .text_size(px(13.))
1486 .hover(|d| d.bg(gpui::rgb(0x2c3540)))
1487 .child("✎")
1488 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1489 this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
1490 })),
1491 )
1492 .child(
1493 div()
1494 .id(SharedString::from(format!(
1495 "row-del-{mod_idx}-{id_copy}"
1496 )))
1497 .px(px(6.))
1498 .text_color(gpui::rgb(0xd07070))
1499 .text_size(px(13.))
1500 .hover(|d| d.bg(gpui::rgb(0x4a2020)))
1501 .child("✕")
1502 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1503 // Marca para borrar en lugar de borrar
1504 // directo: el modal de confirmación se
1505 // renderea arriba en `render` y maneja
1506 // confirm/cancel.
1507 this.pending_delete =
1508 Some((entity_for_delete.clone(), id_copy));
1509 this.toast = None;
1510 cx.notify();
1511 })),
1512 ),
1513 );
1514 main = main.child(row);
1515 }
1516
1517 if rows.is_empty() {
1518 main = main.child(
1519 div()
1520 .py(px(12.))
1521 .text_color(text_dim)
1522 .text_size(px(12.))
1523 .child(format!("(sin {})", lv.entity)),
1524 );
1525 } else {
1526 main = main.child(
1527 div()
1528 .mt(px(8.))
1529 .text_color(text_dim)
1530 .text_size(px(11.))
1531 .child(format!("{total} fila(s)")),
1532 );
1533 }
1534
1535 main
1536 }
1537
1538 /// Renderea el selector clickable de records existentes para un
1539 /// FieldSpec con kind=EntityRef. Lista compacta debajo del input;
1540 /// click en una opción setea el TextInput del field con el UUID
1541 /// seleccionado. El item del UUID actualmente seleccionado (si
1542 /// hay) se resalta con accent color.
1543 #[allow(clippy::too_many_arguments)]
1544 fn render_entity_ref_selector(
1545 &self,
1546 cx: &mut Context<Self>,
1547 field_name: String,
1548 target_entity: String,
1549 text: gpui::Rgba,
1550 text_dim: gpui::Rgba,
1551 accent: gpui::Rgba,
1552 ) -> gpui::Div {
1553 let _ = text;
1554 let rows = self.list_rows(&target_entity);
1555 let current = self
1556 .form_inputs
1557 .get(&field_name)
1558 .map(|inp| inp.read(&*cx).text().to_string())
1559 .unwrap_or_default();
1560
1561 let mut container = div()
1562 .mt(px(4.))
1563 .pl(px(8.))
1564 .border_l_2()
1565 .border_color(gpui::rgb(0x2a2f38))
1566 .flex()
1567 .flex_col()
1568 .gap(px(2.));
1569
1570 if rows.is_empty() {
1571 return container.child(
1572 div()
1573 .px(px(6.))
1574 .py(px(4.))
1575 .text_color(text_dim)
1576 .text_size(px(10.))
1577 .child(format!(
1578 "(sin {target_entity}: creá uno antes para referenciar)"
1579 )),
1580 );
1581 }
1582
1583 container = container.child(
1584 div()
1585 .text_color(text_dim)
1586 .text_size(px(10.))
1587 .child(format!("Seleccioná un {target_entity}:")),
1588 );
1589
1590 for (id, value) in &rows {
1591 let label = human_label_for_record(value, id);
1592 let id_str = id.to_string();
1593 let is_selected = current == id_str;
1594 let field_for_click = field_name.clone();
1595 let id_for_click = id_str.clone();
1596 container = container.child(
1597 div()
1598 .id(SharedString::from(format!(
1599 "entity-ref-{field_name}-{id_str}"
1600 )))
1601 .px(px(6.))
1602 .py(px(2.))
1603 .text_size(px(11.))
1604 .text_color(if is_selected { accent } else { text_dim })
1605 .when(is_selected, |d| d.bg(gpui::rgb(0x232a36)))
1606 .hover(|d| d.bg(gpui::rgb(0x1f2630)))
1607 .child(label)
1608 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1609 if let Some(input) = this.form_inputs.get(&field_for_click) {
1610 input.update(cx, |inp, cx| inp.set_text(id_for_click.clone(), cx));
1611 }
1612 cx.notify();
1613 })),
1614 );
1615 }
1616 container
1617 }
1618
1619 #[allow(clippy::too_many_arguments)]
1620 fn render_form(
1621 &self,
1622 cx: &mut Context<Self>,
1623 mut main: gpui::Div,
1624 fv: &FormView,
1625 mod_idx: usize,
1626 _border: gpui::Rgba,
1627 text: gpui::Rgba,
1628 text_dim: gpui::Rgba,
1629 accent: gpui::Rgba,
1630 ) -> gpui::Div {
1631 // En modo edit, el título refleja eso para que el user no
1632 // se confunda creyendo que hace alta nueva.
1633 let title = match self.editing.as_ref() {
1634 Some((e, id)) if e == &fv.entity => {
1635 format!("Editar {} {}", fv.entity, short_uuid(id))
1636 }
1637 _ => fv.title.clone(),
1638 };
1639 main = main.child(
1640 div()
1641 .text_color(text)
1642 .text_size(px(18.))
1643 .mb(px(12.))
1644 .child(title),
1645 );
1646 for f in &fv.fields {
1647 let label = if f.required {
1648 format!("{} *", f.label)
1649 } else {
1650 f.label.clone()
1651 };
1652
1653 let mut field_box = div().flex().flex_col().mb(px(10.)).child(
1654 div()
1655 .text_color(text_dim)
1656 .text_size(px(11.))
1657 .mb(px(2.))
1658 .child(label),
1659 );
1660
1661 // Mount del TextInput vivo (creado en select_view).
1662 if let Some(input) = self.form_inputs.get(&f.name) {
1663 field_box = field_box.child(input.clone());
1664 } else {
1665 // No debería pasar — select_view crea inputs por cada
1666 // field. Fallback display estático por seguridad.
1667 field_box = field_box.child(
1668 div()
1669 .px(px(8.))
1670 .py(px(6.))
1671 .bg(gpui::rgb(0x171a20))
1672 .text_color(text_dim)
1673 .child("(input no inicializado)"),
1674 );
1675 }
1676
1677 // Para EntityRef, agregamos un selector clickable de
1678 // records existentes debajo del TextInput. Click en una
1679 // opción setea el TextInput interno con el UUID; el
1680 // submit lee de ahí como cualquier otro field.
1681 if f.kind == FieldKind::EntityRef {
1682 if let Some(target_entity) = &f.ref_entity {
1683 field_box = field_box.child(self.render_entity_ref_selector(
1684 cx,
1685 f.name.clone(),
1686 target_entity.clone(),
1687 text,
1688 text_dim,
1689 accent,
1690 ));
1691 }
1692 }
1693
1694 if let Some(help) = &f.help {
1695 field_box = field_box.child(
1696 div()
1697 .mt(px(2.))
1698 .text_color(text_dim)
1699 .text_size(px(10.))
1700 .child(help.clone()),
1701 );
1702 }
1703 main = main.child(field_box);
1704 }
1705
1706 let editing_this = matches!(
1707 self.editing.as_ref(),
1708 Some((e, _)) if e == &fv.entity
1709 );
1710 let submit_label = match &fv.on_submit {
1711 Action::SeedEntity { entity, .. } => {
1712 if editing_this {
1713 format!("Guardar cambios en {entity}")
1714 } else {
1715 format!("Crear {entity}")
1716 }
1717 }
1718 Action::Morphism { name, .. } => format!("Ejecutar {name}"),
1719 Action::OpenView { label, view } => {
1720 label.clone().unwrap_or_else(|| format!("Ir a {view}"))
1721 }
1722 };
1723 let submit_action = fv.on_submit.clone();
1724 main = main.child(
1725 div().mt(px(12.)).child(
1726 div()
1727 .id(SharedString::from(format!("form-submit-{mod_idx}")))
1728 .px(px(12.))
1729 .py(px(6.))
1730 .bg(gpui::rgb(0x2c3540))
1731 .text_color(accent)
1732 .text_size(px(12.))
1733 .rounded(px(3.))
1734 .hover(|d| d.bg(gpui::rgb(0x3a4555)))
1735 .child(submit_label)
1736 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1737 this.apply_action(submit_action.clone(), cx);
1738 })),
1739 ),
1740 );
1741
1742 main = main.child(
1743 div()
1744 .mt(px(20.))
1745 .text_color(text_dim)
1746 .text_size(px(10.))
1747 .child(
1748 "Tip: click en el campo para enfocar; Enter no envía (todavía), \
1749 usá el botón. Backspace borra el último carácter.",
1750 ),
1751 );
1752 main
1753 }
1754}
1755
1756#[cfg(test)]
1757mod tests {
1758 use super::*;
1759
1760 // NOTA: `parse_field_value` / `parse_field_*` viven y se testean
1761 // en `yahweh-meta-runtime`. Tests duplicados aquí se borraron en
1762 // la Fase 2 del refactor yahweh.
1763
1764 #[test]
1765 fn lookup_field_simple_and_nested() {
1766 let v = json!({
1767 "name": "Acme",
1768 "address": { "city": "Bogotá", "country": "CO" }
1769 });
1770 assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
1771 assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
1772 assert!(lookup_field(&v, "missing").is_none());
1773 assert!(lookup_field(&v, "address.zipcode").is_none());
1774 }
1775
1776 // `infer_param_value`, helpers `spec`/`map`, todos los tests
1777 // delta_* / clear_fields_* / parse_field_* / resolve_param_* /
1778 // human_label_* / render_value / value_to_input_text / validate_entity_refs_*
1779 // viven en `yahweh-meta-runtime`. Borrados en Fase 2 — quedan acá
1780 // sólo tests de funcionalidad runtime-específica (compact, snapshot,
1781 // event log, morphism pipeline, load_ui_modules).
1782
1783 #[test]
1784 fn append_compact_msg_handles_both_branches() {
1785 // Sin compact: base solo, sin separador.
1786 let s = append_compact_msg("creado X".into(), None);
1787 assert_eq!(s.as_ref(), "creado X");
1788 // Con compact: concatena con "; ".
1789 let s = append_compact_msg(
1790 "creado X".into(),
1791 Some("auto-compact: snapshot @ seq 49".into()),
1792 );
1793 assert_eq!(s.as_ref(), "creado X; auto-compact: snapshot @ seq 49");
1794 }
1795
1796 /// Simula el ciclo de write+tick que ocurriría en runtime: con
1797 /// threshold=N, los primeros N-1 writes no compactan, el N-ésimo
1798 /// dispara compact y resetea el counter, los siguientes vuelven
1799 /// a acumular.
1800 ///
1801 /// Como `tick_runtime_compact` es un método de `MetaUi` (necesita
1802 /// el cx GPUI para construir el state completo), reproducimos el
1803 /// algorithm por separado: counter manual + invocación directa de
1804 /// `maybe_compact_log`. Si la lógica del tick cambia, este test
1805 /// se va a romper como signal de que hay que actualizarlo.
1806 #[test]
1807 fn runtime_compact_cycle_resets_counter_after_threshold() {
1808 let tmp = tempfile::NamedTempFile::new().unwrap();
1809 let path = tmp.path().to_path_buf();
1810 drop(tmp);
1811 let snap_path = snapshot_path_for(&path);
1812 let threshold: usize = 3;
1813
1814 let mut store = MemoryStore::new();
1815 let mut log = EventLog::open(&path).unwrap();
1816 let mut counter: u64 = 0;
1817 let mut total_compactions = 0u32;
1818
1819 // 7 writes con threshold=3 → 2 compacts (en write 3 y en
1820 // write 6), counter restante = 1 al final.
1821 for i in 0..7u64 {
1822 let id = Uuid::new_v4();
1823 log.append(LogEntry::Seed {
1824 seq: i,
1825 entity: "row".into(),
1826 id,
1827 data: json!({"i": i}),
1828 schema_hash: None,
1829 })
1830 .unwrap();
1831 store.seed("row", id, json!({"i": i}));
1832
1833 // Tick.
1834 counter += 1;
1835 if counter >= threshold as u64 {
1836 let res =
1837 maybe_compact_log(&mut log, &snap_path, &store, threshold).unwrap();
1838 if res.is_some() {
1839 total_compactions += 1;
1840 }
1841 counter = 0;
1842 }
1843 }
1844
1845 assert_eq!(
1846 total_compactions, 2,
1847 "con 7 writes y threshold 3 deberíamos disparar 2 compacts"
1848 );
1849 assert_eq!(counter, 1, "1 write residual sin compactar");
1850
1851 // El log final debería tener: el anchor del último compact +
1852 // el write residual = 2 entries.
1853 let entries_after = EventLog::open(&path).unwrap().entries().unwrap().len();
1854 assert_eq!(
1855 entries_after, 2,
1856 "1 anchor del último compact + 1 write residual"
1857 );
1858
1859 let _ = std::fs::remove_file(&path);
1860 let _ = std::fs::remove_file(&snap_path);
1861 }
1862
1863 /// E2E del nuevo `load_ui_modules` que pasa por
1864 /// `brahman_cards::load_cards_from_dir`. Verifica:
1865 /// 1. UiModules cargados ordenados por id.
1866 /// 2. Validación per-module se aplica (un module.json con
1867 /// menu apuntando a una view inexistente debería fallar).
1868 /// 3. Cards de otros body kinds (Ente fixture) se reportan
1869 /// en el `skipped` sin romper la carga.
1870 #[test]
1871 fn load_ui_modules_via_brahman_cards_returns_ui_modules_and_skips_others() {
1872 let root = tempfile::tempdir().unwrap();
1873
1874 // Subdir A: UiModule válido.
1875 let a = root.path().join("alpha");
1876 std::fs::create_dir(&a).unwrap();
1877 std::fs::write(
1878 a.join("module.json"),
1879 serde_json::to_vec(&json!({
1880 "id": "alpha",
1881 "label": "Alpha",
1882 "entities": [],
1883 "menu": [],
1884 "views": {}
1885 }))
1886 .unwrap(),
1887 )
1888 .unwrap();
1889
1890 // Subdir B: Ente card (no UiModule). Debe skipearse,
1891 // no romper la carga.
1892 let b = root.path().join("bravo");
1893 std::fs::create_dir(&b).unwrap();
1894 std::fs::write(
1895 b.join("card.json"),
1896 serde_json::to_vec(&json!({
1897 "schema_version": 1,
1898 "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
1899 "label": "ente-bravo",
1900 "payload": "Virtual",
1901 "supervision": "OneShot"
1902 }))
1903 .unwrap(),
1904 )
1905 .unwrap();
1906
1907 let (modules, skipped) =
1908 load_ui_modules(root.path()).expect("load ok");
1909 assert_eq!(modules.len(), 1);
1910 assert_eq!(modules[0].id, "alpha");
1911 assert_eq!(skipped.len(), 1);
1912 assert!(skipped[0].contains("ente"));
1913 }
1914
1915 #[test]
1916 fn load_ui_modules_via_brahman_cards_rejects_invalid_module() {
1917 let root = tempfile::tempdir().unwrap();
1918 let sub = root.path().join("broken");
1919 std::fs::create_dir(&sub).unwrap();
1920 // menu apunta a una view que no existe en `views`.
1921 std::fs::write(
1922 sub.join("module.json"),
1923 serde_json::to_vec(&json!({
1924 "id": "broken",
1925 "label": "Broken",
1926 "entities": [],
1927 "menu": [{ "label": "Phantom", "view": "ghost" }],
1928 "views": {}
1929 }))
1930 .unwrap(),
1931 )
1932 .unwrap();
1933 let err = load_ui_modules(root.path()).unwrap_err();
1934 assert!(err.contains("broken"), "msg debe nombrar el módulo: {err}");
1935 }
1936
1937 #[test]
1938 fn load_ui_modules_detects_duplicate_id() {
1939 let root = tempfile::tempdir().unwrap();
1940 for name in ["dir_a", "dir_b"] {
1941 let sub = root.path().join(name);
1942 std::fs::create_dir(&sub).unwrap();
1943 std::fs::write(
1944 sub.join("module.json"),
1945 serde_json::to_vec(&json!({
1946 "id": "dup",
1947 "label": "Dup",
1948 "entities": [], "menu": [], "views": {}
1949 }))
1950 .unwrap(),
1951 )
1952 .unwrap();
1953 }
1954 let err = load_ui_modules(root.path()).unwrap_err();
1955 assert!(err.contains("duplicado"), "msg debe decir duplicado: {err}");
1956 assert!(err.contains("dup"), "msg debe nombrar el id: {err}");
1957 }
1958
1959 #[test]
1960 fn snapshot_path_for_replaces_extension() {
1961 use std::path::Path;
1962 assert_eq!(
1963 snapshot_path_for(Path::new("nakui-ui-state.jsonl")),
1964 std::path::PathBuf::from("nakui-ui-state.snap.json"),
1965 );
1966 // Sin extensión: agrega .snap.json.
1967 assert_eq!(
1968 snapshot_path_for(Path::new("/tmp/foo")),
1969 std::path::PathBuf::from("/tmp/foo.snap.json"),
1970 );
1971 }
1972
1973 #[test]
1974 fn maybe_compact_log_below_threshold_noops() {
1975 let tmp = tempfile::NamedTempFile::new().unwrap();
1976 let path = tmp.path().to_path_buf();
1977 drop(tmp);
1978 let snap_path = snapshot_path_for(&path);
1979 let mut log = EventLog::open(&path).unwrap();
1980 for i in 0..5 {
1981 log.append(LogEntry::Seed {
1982 seq: i,
1983 entity: "x".into(),
1984 id: Uuid::new_v4(),
1985 data: json!({"i": i}),
1986 schema_hash: None,
1987 })
1988 .unwrap();
1989 }
1990 let store = MemoryStore::new();
1991 let res = maybe_compact_log(&mut log, &snap_path, &store, 50).unwrap();
1992 assert!(res.is_none(), "5 < 50 → no debería compactar");
1993 assert_eq!(log.entries().unwrap().len(), 5, "log intacto");
1994 assert!(!snap_path.exists(), "no debería haber escrito snapshot");
1995 let _ = std::fs::remove_file(&path);
1996 }
1997
1998 #[test]
1999 fn maybe_compact_log_threshold_zero_noops() {
2000 let tmp = tempfile::NamedTempFile::new().unwrap();
2001 let path = tmp.path().to_path_buf();
2002 drop(tmp);
2003 let snap_path = snapshot_path_for(&path);
2004 let mut log = EventLog::open(&path).unwrap();
2005 for i in 0..3 {
2006 log.append(LogEntry::Seed {
2007 seq: i,
2008 entity: "x".into(),
2009 id: Uuid::new_v4(),
2010 data: json!({"i": i}),
2011 schema_hash: None,
2012 })
2013 .unwrap();
2014 }
2015 let store = MemoryStore::new();
2016 let res = maybe_compact_log(&mut log, &snap_path, &store, 0).unwrap();
2017 assert!(res.is_none(), "threshold 0 = disabled");
2018 let _ = std::fs::remove_file(&path);
2019 }
2020
2021 /// E2E del ciclo completo: write log con N entries por encima del
2022 /// threshold → maybe_compact_log captura snapshot y trunca el log
2023 /// → re-open + replay con snapshot → store final == store que
2024 /// resultaría de full replay sin snapshot.
2025 #[test]
2026 fn maybe_compact_log_then_reopen_preserves_records() {
2027 let tmp = tempfile::NamedTempFile::new().unwrap();
2028 let path = tmp.path().to_path_buf();
2029 drop(tmp);
2030 let snap_path = snapshot_path_for(&path);
2031
2032 // 1. Escribir 60 seeds + popular store en sync.
2033 let mut store = MemoryStore::new();
2034 let mut ids = Vec::new();
2035 let mut log = EventLog::open(&path).unwrap();
2036 for i in 0..60u64 {
2037 let id = Uuid::new_v4();
2038 log.append(LogEntry::Seed {
2039 seq: i,
2040 entity: "row".into(),
2041 id,
2042 data: json!({"i": i}),
2043 schema_hash: None,
2044 })
2045 .unwrap();
2046 store.seed("row", id, json!({"i": i}));
2047 ids.push(id);
2048 }
2049 assert_eq!(log.next_seq(), 60);
2050 assert_eq!(log.entries().unwrap().len(), 60);
2051
2052 // 2. Compactar (60 >= 50).
2053 let res = maybe_compact_log(&mut log, &snap_path, &store, 50).unwrap();
2054 assert!(res.is_some(), "60 >= 50 debe compactar");
2055 let msg = res.unwrap();
2056 assert!(msg.contains("seq 59"), "msg debe incluir el seq: {msg}");
2057 assert!(
2058 msg.contains("59 entries dropped"),
2059 "msg debe reportar 59 dropped (60 - 1 anchor): {msg}"
2060 );
2061
2062 // 3. Verificar: snapshot existe, log queda con 1 entry
2063 // (anchor del cursor), next_seq se preserva (60).
2064 assert!(snap_path.exists(), "snapshot debería existir");
2065 let log_after = EventLog::open(&path).unwrap();
2066 assert_eq!(
2067 log_after.entries().unwrap().len(),
2068 1,
2069 "log debería tener 1 anchor entry tras compact"
2070 );
2071 assert_eq!(
2072 log_after.next_seq(),
2073 60,
2074 "next_seq se preserva via anchor entry"
2075 );
2076
2077 // 4. Re-open + replay desde snapshot → todos los records.
2078 // El anchor entry cae bajo snap.seq así que se skipea.
2079 let snap = Snapshot::load(&snap_path).unwrap().expect("snap loadeable");
2080 assert_eq!(snap.seq, 59);
2081 let mut fresh_store = MemoryStore::new();
2082 replay_with_snapshot_into(&log_after, Some(&snap), &mut fresh_store).unwrap();
2083 for (i, id) in ids.iter().enumerate() {
2084 assert_eq!(
2085 fresh_store.load("row", *id),
2086 Some(json!({"i": i as u64})),
2087 "record {i} debería estar tras snapshot+replay"
2088 );
2089 }
2090
2091 // 5. Idempotencia: segunda corrida del compact con threshold=1
2092 // no hace nada — queda 1 anchor entry, no hay nada útil
2093 // que dropear.
2094 let mut log_reopened = EventLog::open(&path).unwrap();
2095 let res2 =
2096 maybe_compact_log(&mut log_reopened, &snap_path, &fresh_store, 1).unwrap();
2097 assert!(
2098 res2.is_none(),
2099 "post-compact: 1 anchor entry, segundo compact debe ser no-op"
2100 );
2101
2102 let _ = std::fs::remove_file(&path);
2103 let _ = std::fs::remove_file(&snap_path);
2104 }
2105
2106 #[test]
2107 fn value_to_input_then_parse_round_trip() {
2108 // El round-trip es la propiedad fundamental: edit → text →
2109 // parse → mismo Value (modulo casts numéricos).
2110 let cases = vec![
2111 (FieldKind::Text, json!("hola")),
2112 (FieldKind::Boolean, json!(true)),
2113 (FieldKind::Boolean, json!(false)),
2114 (FieldKind::Number, json!(42)),
2115 ];
2116 for (kind, original) in cases {
2117 let text = value_to_input_text(&original);
2118 let parsed = parse_field_value(kind, &text).unwrap();
2119 assert_eq!(
2120 parsed, original,
2121 "round-trip text→parse falló para {original:?}"
2122 );
2123 }
2124 }
2125
2126 /// E2E mínimo del WAL: armamos un log a mano con dos seeds,
2127 /// abrimos con `EventLog::open` + `replay_into`, y verificamos
2128 /// que el `MemoryStore` queda con esos records aplicados.
2129 /// Esto reproduce el flujo del startup de `MetaUi::new` sin
2130 /// necesitar GPUI.
2131 #[test]
2132 fn event_log_replay_restores_memory_store() {
2133 use nakui_core::event_log::{replay_into, EventLog, LogEntry};
2134 use nakui_core::store::{MemoryStore, Store};
2135
2136 let tmp = tempfile::NamedTempFile::new().unwrap();
2137 let path = tmp.path().to_path_buf();
2138 // Cerramos el handle de tempfile pero conservamos el path
2139 // para que EventLog pueda re-abrir.
2140 drop(tmp);
2141
2142 // Escribimos dos seeds via EventLog::append.
2143 let id_a = Uuid::new_v4();
2144 let id_b = Uuid::new_v4();
2145 {
2146 let mut log = EventLog::open(&path).unwrap();
2147 log.append(LogEntry::Seed {
2148 seq: 0,
2149 entity: "customer".into(),
2150 id: id_a,
2151 data: json!({"name": "Acme"}),
2152 schema_hash: None,
2153 })
2154 .unwrap();
2155 log.append(LogEntry::Seed {
2156 seq: 1,
2157 entity: "customer".into(),
2158 id: id_b,
2159 data: json!({"name": "Globex"}),
2160 schema_hash: None,
2161 })
2162 .unwrap();
2163 }
2164
2165 // Re-abrir + replay (simula startup de MetaUi).
2166 let log = EventLog::open(&path).unwrap();
2167 assert_eq!(log.next_seq(), 2, "next_seq debe ser 2 tras 2 entries");
2168 let mut store = MemoryStore::new();
2169 replay_into(&log, &mut store).unwrap();
2170
2171 // Verificar que ambos records están en el store.
2172 assert_eq!(
2173 store.load("customer", id_a),
2174 Some(json!({"name": "Acme"})),
2175 "Acme debería estar tras replay"
2176 );
2177 assert_eq!(
2178 store.load("customer", id_b),
2179 Some(json!({"name": "Globex"})),
2180 "Globex debería estar tras replay"
2181 );
2182
2183 let _ = std::fs::remove_file(&path);
2184 }
2185
2186 /// E2E del Action::Morphism: carga el módulo nakui-core real
2187 /// `sales` (que vive en `crates/modules/nakui/modules/sales`),
2188 /// arma store + log, y ejecuta el morphism `vender` vía
2189 /// `execute_and_log_with_recovery` (la misma función que
2190 /// `commit_morphism` invoca). Verifica que las ops esperadas
2191 /// se loguean y aplican (stock decrementa, caja incrementa).
2192 ///
2193 /// Reproduce el flujo del runtime sin necesitar GPUI.
2194 #[test]
2195 fn morphism_pipeline_executes_real_sales_vender() {
2196 use nakui_core::event_log::{execute_and_log_with_recovery, EventLog};
2197 use nakui_core::executor::Executor;
2198 use nakui_core::store::{MemoryStore, Store};
2199
2200 // Path al módulo real (3 dirs arriba: crates/apps/nakui-ui/
2201 // → crates/modules/nakui/modules/sales).
2202 let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
2203 let sales_dir = here
2204 .join("../../..")
2205 .join("crates/modules/nakui/modules/sales");
2206 if !sales_dir.join("nsmc.json").exists() {
2207 // Si el módulo demo no está donde esperamos, skipeamos
2208 // — no es regresión del feature, es ambiente.
2209 eprintln!(
2210 "skip: sales module no encontrado en {}",
2211 sales_dir.display()
2212 );
2213 return;
2214 }
2215
2216 let executor = Executor::load_module(&sales_dir).expect("cargar sales executor");
2217
2218 let mut store = MemoryStore::new();
2219 let stock_id = Uuid::new_v4();
2220 let caja_id = Uuid::new_v4();
2221 store.seed(
2222 "Stock",
2223 stock_id,
2224 json!({
2225 "id": stock_id.to_string(),
2226 "sku_id": "test-sku",
2227 "ubicacion": "loc-1",
2228 "cantidad": 100_i64,
2229 }),
2230 );
2231 store.seed(
2232 "Caja",
2233 caja_id,
2234 json!({
2235 "id": caja_id.to_string(),
2236 "name": "Caja Test",
2237 "currency": "USD",
2238 "saldo": 1_000_000_i64,
2239 }),
2240 );
2241
2242 let tmp = tempfile::NamedTempFile::new().unwrap();
2243 let log_path = tmp.path().to_path_buf();
2244 drop(tmp);
2245 let mut log = EventLog::open(&log_path).unwrap();
2246
2247 let venta_id = Uuid::new_v4();
2248 let inputs = vec![("stock", stock_id), ("caja", caja_id)];
2249 let params = json!({
2250 "venta_id": venta_id.to_string(),
2251 "cantidad": 5_i64,
2252 "precio_unitario": 200_i64,
2253 "timestamp": "2026-05-04T10:00:00Z",
2254 });
2255
2256 let ops = execute_and_log_with_recovery(
2257 &executor,
2258 &mut store,
2259 &mut log,
2260 "vender",
2261 &inputs,
2262 params,
2263 )
2264 .expect("morphism vender debe ejecutar limpio");
2265
2266 assert!(!ops.is_empty(), "vender debería producir ops");
2267
2268 // Sanity post-condiciones esperadas del manifest sales:
2269 // - stock.cantidad bajó (vendimos 5).
2270 let stock_after = store
2271 .load("Stock", stock_id)
2272 .and_then(|v| v.get("cantidad").and_then(Value::as_i64))
2273 .expect("stock con cantidad");
2274 assert_eq!(stock_after, 95, "stock debería bajar de 100 a 95");
2275 // - caja.saldo subió (cobramos 5*200 = 1000 sobre saldo
2276 // inicial 1_000_000).
2277 let caja_after = store
2278 .load("Caja", caja_id)
2279 .and_then(|v| v.get("saldo").and_then(Value::as_i64))
2280 .expect("caja con saldo");
2281 assert_eq!(caja_after, 1_001_000, "caja debería subir 5*200=1000");
2282
2283 let _ = std::fs::remove_file(&log_path);
2284 }
2285
2286 /// E2E del ciclo CRUD vía log:
2287 /// 1. Seed un record.
2288 /// 2. Morphism con Set ops (edit) — sobreescribe campos.
2289 /// 3. Morphism con Delete op — borra el record.
2290 /// 4. Replay desde cero: el store queda como tras el delete (vacío).
2291 #[test]
2292 fn event_log_replay_handles_full_crud_cycle() {
2293 use nakui_core::delta::{FieldOp, FieldPath};
2294 use nakui_core::event_log::{replay_into, EventLog, LogEntry};
2295 use nakui_core::store::{MemoryStore, Store};
2296
2297 let tmp = tempfile::NamedTempFile::new().unwrap();
2298 let path = tmp.path().to_path_buf();
2299 drop(tmp);
2300
2301 let id = Uuid::new_v4();
2302
2303 // 1. Escribir 3 entries: seed, edit, delete.
2304 {
2305 let mut log = EventLog::open(&path).unwrap();
2306 log.append(LogEntry::Seed {
2307 seq: 0,
2308 entity: "customer".into(),
2309 id,
2310 data: json!({"name": "Acme", "active": true}),
2311 schema_hash: None,
2312 })
2313 .unwrap();
2314 log.append(LogEntry::Morphism {
2315 seq: 1,
2316 morphism: "ui.edit_record".into(),
2317 inputs: Default::default(),
2318 params: json!({}),
2319 ops: vec![
2320 FieldOp::Set {
2321 path: FieldPath {
2322 entity: "customer".into(),
2323 id,
2324 field: "name".into(),
2325 },
2326 value: json!("Acme S.A."),
2327 },
2328 FieldOp::Set {
2329 path: FieldPath {
2330 entity: "customer".into(),
2331 id,
2332 field: "active".into(),
2333 },
2334 value: json!(false),
2335 },
2336 ],
2337 schema_hash: None,
2338 })
2339 .unwrap();
2340 log.append(LogEntry::Morphism {
2341 seq: 2,
2342 morphism: "ui.delete_record".into(),
2343 inputs: Default::default(),
2344 params: json!({}),
2345 ops: vec![FieldOp::Delete {
2346 entity: "customer".into(),
2347 id,
2348 }],
2349 schema_hash: None,
2350 })
2351 .unwrap();
2352 }
2353
2354 // 2. Replay desde cero — debe terminar con store vacío
2355 // (el delete fue el último op).
2356 let log = EventLog::open(&path).unwrap();
2357 let mut store = MemoryStore::new();
2358 replay_into(&log, &mut store).unwrap();
2359 assert_eq!(
2360 store.load("customer", id),
2361 None,
2362 "tras seed + edit + delete, el record no debería existir"
2363 );
2364
2365 // 3. Sanity: si paramos en seq=1 (snapshot post-edit), el
2366 // record debería tener los valores editados.
2367 // (Construimos un store fresh y aplicamos sólo seq 0 y 1
2368 // a mano para verificar.)
2369 let mut store_partial = MemoryStore::new();
2370 store_partial.seed("customer", id, json!({"name": "Acme", "active": true}));
2371 store_partial
2372 .apply(&[
2373 FieldOp::Set {
2374 path: FieldPath {
2375 entity: "customer".into(),
2376 id,
2377 field: "name".into(),
2378 },
2379 value: json!("Acme S.A."),
2380 },
2381 FieldOp::Set {
2382 path: FieldPath {
2383 entity: "customer".into(),
2384 id,
2385 field: "active".into(),
2386 },
2387 value: json!(false),
2388 },
2389 ])
2390 .unwrap();
2391 assert_eq!(
2392 store_partial.load("customer", id),
2393 Some(json!({"name": "Acme S.A.", "active": false})),
2394 "tras seed + edit, el record debería tener los nuevos valores"
2395 );
2396
2397 let _ = std::fs::remove_file(&path);
2398 }
2399}