tawasuyu

tawasuyu/brahman

Rust 570TOML 215Markdown 68JSON 198261 Definiciones

⎇ f0c0a718605924227076bef7cb8b403c388261b6 crates/apps/nakui-ui/src/main.rs Rust · 2694 líneas · 100.9 KB Historia Quién tocó qué crudo

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