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