gioser

tawasuyu/brahman

Rust 334TOML 129Markdown 39JSON 174745 Sutichaykuna

⎇ f5987d9cfc47146e2a8b32a670cd38ccf9e89ed8 crates/apps/nakui-ui/src/main.rs Rust · 2970 siq'ikuna · 111.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

refactor(yahweh): Fase 1 — nakui-ui-schema → yahweh-meta-schema f5987d9cf · Sergio ·

1//! `nakui-ui` — runtime GPUI de la metainterfaz Nakui.
2//!
3//! Carga módulos desde un directorio (cada módulo = un
4//! `module.json`), monta sidebar con sus menús, y renderea la vista
5//! activa en el panel principal:
6//!
7//! - **List**: tabla de instancias del entity. Botones de acción en
8//!   el header (típicamente "Nuevo" → form).
9//! - **Form**: campos editables (con `yahweh-widget-text-input` para
10//!   teclado real); al submit, escribe al `MemoryStore` in-process
11//!   via `seed_entity` (alta directa) o por morphism (TODO en este
12//!   iter).
13//!
14//! Todo el storage es in-memory por ahora — el escenario "save to
15//! disk" se materializa cuando el daemon Nakui exista. La
16//! arquitectura permite swap sin tocar la UI.
17//!
18//! ## Uso
19//!
20//! ```sh
21//! NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
22//! # default sin env: ./nakui-modules en pwd.
23//! ```
24
25use std::collections::BTreeMap;
26use std::path::PathBuf;
27use std::sync::{Arc, Mutex};
28
29use gpui::{
30    div, prelude::*, px, App, Application, Bounds, ClickEvent, Context, Entity, IntoElement,
31    KeyDownEvent, Render, SharedString, Window, WindowBounds, WindowOptions,
32};
33use nakui_core::delta::{FieldOp, FieldPath};
34use nakui_core::event_log::{
35    execute_and_log_with_recovery, replay_with_snapshot_into, EventLog, LogEntry, Snapshot,
36};
37use brahman_cards::CardBody;
38use nakui_core::executor::Executor;
39use nakui_core::store::{MemoryStore, Store};
40use yahweh_meta_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, &params, 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(&current, &obj);
789            let clear_fields = compute_clear_fields(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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(&current_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(&current, &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(&current, &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(&current, &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(&current, &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}