gioser

tawasuyu/brahman

Rust 326TOML 123JSON 14Markdown 114554 Definiciones

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

feat(nakui-ui): validación cross-field del EntityRef (existence en store) ffdfa6f8d · 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        // EntityRef references a chequear existencia DESPUÉS del parse
709        // loop (necesitan lock del store; lo tomamos una sola vez).
710        // Tuple: (label legible, target entity, parsed UUID).
711        let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new();
712        for f in &spec_fields {
713            let raw = self
714                .form_inputs
715                .get(&f.name)
716                .map(|input| input.read(cx).text().to_string())
717                .unwrap_or_default();
718            if f.required && raw.trim().is_empty() {
719                return Err(format!("campo '{}' es obligatorio", f.label));
720            }
721            if raw.is_empty() && !f.required {
722                to_clear.push(f.name.clone());
723                continue;
724            }
725            let value = parse_field_value(f.kind, &raw)
726                .map_err(|e| format!("campo '{}': {e}", f.label))?;
727            // Si el field es EntityRef y declara ref_entity, encolamos
728            // el (label, target, uuid) para validar existence en lote.
729            // El UUID ya está bien-formado (parse_field_value lo
730            // validó); ahora chequeamos que el record exista.
731            if f.kind == FieldKind::EntityRef {
732                if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
733                    let id = Uuid::parse_str(uuid_str)
734                        .expect("parse_field_value validated UUID");
735                    entity_refs.push((f.label.clone(), target.clone(), id));
736                }
737            }
738            obj.insert(f.name.clone(), value);
739        }
740        // Validar EntityRefs contra el store actual. Una sola toma del
741        // lock para todas las refs.
742        if !entity_refs.is_empty() {
743            let store = self
744                .store
745                .lock()
746                .map_err(|_| "store mutex envenenado".to_string())?;
747            validate_entity_refs(&*store, &entity_refs)?;
748        }
749        // Ramificación: si `editing` está set para esta entity, es un
750        // edit de un record existente — emitimos Morphism con un
751        // FieldOp::Set por cada campo del form (sobreescribe). Si no,
752        // es alta nueva — emitimos Seed con UUID fresco.
753        let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
754
755        if let Some((_, id)) = editing_match {
756            // EDIT path: delta-only. Cargar el record actual del store
757            // y emitir `FieldOp::Set` por cada campo cuyo valor nuevo
758            // difiere del actual + `FieldOp::Clear` por cada optional
759            // empty cuyo current tenía un valor non-null. Si nada
760            // cambió, ningún log entry y ningún apply.
761            let current: Value = {
762                let store = self
763                    .store
764                    .lock()
765                    .map_err(|_| "store mutex envenenado".to_string())?;
766                store.load(entity, id).unwrap_or(Value::Null)
767            };
768            let set_delta = compute_field_delta(&current, &obj);
769            let clear_fields = compute_clear_fields(&current, &to_clear);
770
771            if set_delta.is_empty() && clear_fields.is_empty() {
772                // No-op edit: no entry al log, no apply.
773                return Ok(CommitOutcome::NoChange(id));
774            }
775
776            let mut ops: Vec<FieldOp> = set_delta
777                .iter()
778                .map(|(field, value)| FieldOp::Set {
779                    path: FieldPath {
780                        entity: entity.to_string(),
781                        id,
782                        field: field.clone(),
783                    },
784                    value: value.clone(),
785                })
786                .collect();
787            for field in &clear_fields {
788                ops.push(FieldOp::Clear {
789                    path: FieldPath {
790                        entity: entity.to_string(),
791                        id,
792                        field: field.clone(),
793                    },
794                });
795            }
796
797            if let Some(log_arc) = self.event_log.as_ref() {
798                let mut log = log_arc
799                    .lock()
800                    .map_err(|_| "log mutex envenenado".to_string())?;
801                let seq = log.next_seq();
802                let mut params = serde_json::Map::new();
803                params.insert("entity".into(), json!(entity));
804                params.insert("id".into(), json!(id.to_string()));
805                if !set_delta.is_empty() {
806                    params.insert("fields".into(), Value::Object(set_delta.clone()));
807                }
808                if !clear_fields.is_empty() {
809                    params.insert(
810                        "cleared".into(),
811                        Value::Array(
812                            clear_fields.iter().map(|s| json!(s)).collect(),
813                        ),
814                    );
815                }
816                log.append(LogEntry::Morphism {
817                    seq,
818                    morphism: "ui.edit_record".into(),
819                    inputs: Default::default(),
820                    params: Value::Object(params),
821                    ops: ops.clone(),
822                    schema_hash: None,
823                })
824                .map_err(|e| format!("append al log: {e}"))?;
825            }
826            let mut store = self
827                .store
828                .lock()
829                .map_err(|_| "store mutex envenenado".to_string())?;
830            store.apply(&ops).map_err(|e| format!("apply edit ops: {e}"))?;
831            Ok(CommitOutcome::Updated {
832                id,
833                changed: set_delta.len() + clear_fields.len(),
834            })
835        } else {
836            // SEED path: alta nueva.
837            let id = Uuid::new_v4();
838            let data = Value::Object(obj);
839            if let Some(log_arc) = self.event_log.as_ref() {
840                let mut log = log_arc
841                    .lock()
842                    .map_err(|_| "log mutex envenenado".to_string())?;
843                let seq = log.next_seq();
844                log.append(LogEntry::Seed {
845                    seq,
846                    entity: entity.to_string(),
847                    id,
848                    data: data.clone(),
849                    schema_hash: None,
850                })
851                .map_err(|e| format!("append al log: {e}"))?;
852            }
853            let mut store = self
854                .store
855                .lock()
856                .map_err(|_| "store mutex envenenado".to_string())?;
857            store.seed(entity, id, data);
858            Ok(CommitOutcome::Created(id))
859        }
860    }
861
862    /// Snapshot ordenado de records de una entity.
863    fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
864        let store = match self.store.lock() {
865            Ok(g) => g,
866            Err(_) => return Vec::new(),
867        };
868        let it = match store.iter() {
869            Ok(i) => i,
870            Err(_) => return Vec::new(),
871        };
872        it.filter(|(e, _, _)| e == entity)
873            .map(|(_, id, v)| (id, v))
874            .collect()
875    }
876}
877
878/// Resultado de `commit_seed`. Distingue alta nueva, edit efectivo
879/// con N campos modificados, y no-op (delta vacío en el path de edit).
880#[derive(Debug, Clone, PartialEq, Eq)]
881enum CommitOutcome {
882    Created(Uuid),
883    Updated { id: Uuid, changed: usize },
884    NoChange(Uuid),
885}
886
887impl CommitOutcome {
888    fn id(&self) -> Uuid {
889        match self {
890            Self::Created(id) | Self::Updated { id, .. } | Self::NoChange(id) => *id,
891        }
892    }
893}
894
895/// Concatena un mensaje de compact opcional al toast del op original.
896/// Devuelve el toast resultante listo para ir a `SharedString`.
897/// Sin `compact_msg` devuelve `base` tal cual.
898fn append_compact_msg(base: String, compact_msg: Option<String>) -> SharedString {
899    match compact_msg {
900        Some(m) => SharedString::from(format!("{base}; {m}")),
901        None => SharedString::from(base),
902    }
903}
904
905/// Devuelve el path del snapshot sibling para un log dado:
906/// `nakui-ui-state.jsonl` → `nakui-ui-state.snap.json`. Mantiene el
907/// snapshot junto al log para que un usuario pueda mover la pareja
908/// sin desincronizarlos.
909fn snapshot_path_for(log_path: &std::path::Path) -> PathBuf {
910    log_path.with_extension("snap.json")
911}
912
913/// Si el log file tiene >= `threshold` entries, captura un snapshot
914/// del store actual y compacta el log. Idempotente abajo del threshold.
915///
916/// Cursor invariant: `EventLog::open` re-deriva `next_seq` del primer
917/// entry del archivo. Si compactáramos *todo*, al reabrir el cursor
918/// volvería a 0 — el próximo append crashearía con NonMonotonic. Por
919/// eso siempre dejamos la última entry como anchor: compactamos sólo
920/// hasta `next_seq - 2`. La survivor entry del snap.seq queda en
921/// disco pero `replay_with_snapshot_into` la skipea (snap ya cubre
922/// hasta snap.seq inclusive), así el costo es 1 línea de log y el
923/// resultado del replay es idéntico.
924///
925/// Orden de operaciones (importa por crash safety):
926/// 1. Capturar snapshot en memoria.
927/// 2. `Snapshot::write` (atómico via tempfile + fsync + rename).
928/// 3. `EventLog::compact_through` (atómico igual).
929/// Si (3) falla tras (2) éxito, el próximo boot ve snap@K + log con
930/// entries 0..N — `replay_with_snapshot_into` skipea las cubiertas
931/// por snap, outcome idéntico.
932///
933/// Devuelve `Ok(Some(msg))` si compactó, `Ok(None)` si no había
934/// nada que hacer, `Err(s)` si snapshot/compact falló.
935fn maybe_compact_log(
936    log: &mut EventLog,
937    snap_path: &std::path::Path,
938    store: &MemoryStore,
939    threshold: usize,
940) -> Result<Option<String>, String> {
941    if threshold == 0 {
942        return Ok(None);
943    }
944    let entry_count = log
945        .entries()
946        .map_err(|e| format!("read entries: {e}"))?
947        .len();
948    if entry_count < threshold || entry_count < 2 {
949        // < 2 entries: la regla "dejar 1 como anchor" no permite
950        // dropear nada útil (sólo el anchor sobreviviría).
951        // entry_count<threshold también incluye el caso post-compact
952        // donde sobrevive 1 anchor: idempotente, no rebote infinito.
953        return Ok(None);
954    }
955    let snap_seq = log.next_seq() - 1;
956    let through = log.next_seq() - 2;
957    let snap = Snapshot::from_memory_store(store, snap_seq);
958    snap.write(snap_path)
959        .map_err(|e| format!("write snapshot {}: {e}", snap_path.display()))?;
960    log.compact_through(through)
961        .map_err(|e| format!("compact_through({through}): {e}"))?;
962    Ok(Some(format!(
963        "auto-compact: snapshot @ seq {snap_seq}, {} entries dropped (1 anchor kept)",
964        entry_count - 1
965    )))
966}
967
968/// Valida que cada UUID en `refs` apunte a un record que realmente
969/// existe en el store bajo la entity esperada. Devuelve el primer
970/// error encontrado (fail-fast).
971///
972/// `refs` es una lista de `(label, target_entity, uuid)`. El label
973/// va al error message, así que conviene que sea legible (ej:
974/// `FieldSpec.label` en lugar de `FieldSpec.name`).
975///
976/// Sólo se llama desde el SEED path de la UI. Los inputs de morphism
977/// no necesitan este check porque `Executor::compute` ya valida cada
978/// input via `store.load(...).ok_or(EntityMissing)` antes de correr
979/// el script Rhai.
980fn validate_entity_refs<S: Store>(
981    store: &S,
982    refs: &[(String, String, Uuid)],
983) -> Result<(), String> {
984    for (label, target, id) in refs {
985        if store.load(target, *id).is_none() {
986            return Err(format!(
987                "campo '{label}': record {} de '{target}' no existe en el store",
988                short_uuid(id)
989            ));
990        }
991    }
992    Ok(())
993}
994
995/// Decide cuáles fields del `to_clear` candidate list ameritan
996/// realmente un `FieldOp::Clear`: sólo los que existen en el current
997/// con un valor non-null. Para fields ausentes o ya null, Clear es
998/// no-op semántico (el post-state es el mismo) y dropearlos
999/// preserva la propiedad "1 op = 1 cambio efectivo" del log.
1000///
1001/// Preserva el orden del input para que el log entry sea estable.
1002fn compute_clear_fields(current: &Value, to_clear: &[String]) -> Vec<String> {
1003    to_clear
1004        .iter()
1005        .filter(|f| match current.get(f.as_str()) {
1006            None | Some(Value::Null) => false,
1007            Some(_) => true,
1008        })
1009        .cloned()
1010        .collect()
1011}
1012
1013/// Calcula el delta entre el record actual y los valores propuestos
1014/// del form. Devuelve un Map con sólo los campos cuyo valor difiere.
1015///
1016/// Comparación: igualdad estructural sobre `serde_json::Value`. Un
1017/// `current=Value::Null` (record no encontrado) hace que todos los
1018/// campos del `proposed` sean considerados nuevos. Un campo del
1019/// proposed que coincide con el del current se omite. Campos que
1020/// están en current pero NO en proposed se preservan tal cual (el
1021/// edit no los toca; ver el comentario en commit_seed sobre por qué
1022/// no clearemos campos vacíos).
1023fn compute_field_delta(
1024    current: &Value,
1025    proposed: &serde_json::Map<String, Value>,
1026) -> serde_json::Map<String, Value> {
1027    proposed
1028        .iter()
1029        .filter(|(field, value)| current.get(field.as_str()) != Some(*value))
1030        .map(|(k, v)| (k.clone(), v.clone()))
1031        .collect()
1032}
1033
1034fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
1035    match kind {
1036        FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
1037        // EntityRef se almacena como string del UUID seleccionado.
1038        // Validamos que parsee como UUID al submit — antes esto se
1039        // chequeaba sólo para morphism inputs (línea ~540), pero un
1040        // EntityRef como SEED field o como param de morphism caía
1041        // de la heurística silenciosa. Ahora rebota con mensaje
1042        // claro acá, antes de tocar el log o el morphism Rhai.
1043        // El selector clickable garantiza UUIDs válidos en happy
1044        // path; este check protege paste manual o garbage.
1045        FieldKind::EntityRef => {
1046            let trimmed = raw.trim();
1047            Uuid::parse_str(trimmed).map_err(|_| {
1048                format!("'{raw}' no es UUID válido (usá el selector de records)")
1049            })?;
1050            Ok(json!(trimmed))
1051        }
1052        FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
1053            "true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
1054            "" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
1055            other => Err(format!("'{other}' no es booleano")),
1056        },
1057        FieldKind::Number => {
1058            if let Ok(i) = raw.parse::<i64>() {
1059                Ok(json!(i))
1060            } else if let Ok(f) = raw.parse::<f64>() {
1061                Ok(json!(f))
1062            } else {
1063                Err(format!("'{raw}' no es número"))
1064            }
1065        }
1066    }
1067}
1068
1069/// Etiqueta humana para representar un record en el selector de
1070/// EntityRef. Heurística: prefiere campos comunes en este orden:
1071/// `name`, `label`, `title`, `sku`, `sku_id`. Fallback al UUID corto.
1072fn human_label_for_record(value: &Value, id: &Uuid) -> String {
1073    for key in ["name", "label", "title", "sku", "sku_id"] {
1074        if let Some(v) = value.get(key).and_then(Value::as_str) {
1075            if !v.is_empty() {
1076                return format!("{} ({})", v, short_uuid(id));
1077            }
1078        }
1079    }
1080    short_uuid(id)
1081}
1082
1083fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
1084    let mut cur = v;
1085    for seg in path.split('.') {
1086        cur = cur.get(seg)?;
1087    }
1088    Some(cur)
1089}
1090
1091fn render_value(v: Option<&Value>) -> String {
1092    match v {
1093        None | Some(Value::Null) => String::new(),
1094        Some(Value::String(s)) => s.clone(),
1095        Some(Value::Bool(b)) => if *b { "✓" } else { "✗" }.to_string(),
1096        Some(Value::Number(n)) => n.to_string(),
1097        Some(other) => other.to_string(),
1098    }
1099}
1100
1101/// Resuelve un param de morphism a su `Value` según el `FieldSpec`
1102/// del form. **Strict path**: si hay spec, valida `required` y parsea
1103/// con el `kind` declarado (ej. Boolean rebota con "abc" antes de
1104/// llegar al morphism). **Fallback path**: si no hay spec (param
1105/// declarado en `Action::Morphism.params` que no aparece en
1106/// `form.fields`), usa la heurística `infer_param_value` para no
1107/// quedar atado a un schema mal-formado.
1108///
1109/// Errores tienen el label legible del spec, así el toast de la UI
1110/// es interpretable.
1111fn resolve_param_value(
1112    field_name: &str,
1113    raw: &str,
1114    spec: Option<&FieldSpec>,
1115) -> Result<Value, String> {
1116    let Some(s) = spec else {
1117        return Ok(infer_param_value(raw));
1118    };
1119
1120    let label = if s.label.is_empty() { field_name } else { &s.label };
1121
1122    if s.required && raw.trim().is_empty() {
1123        return Err(format!("param '{label}' es obligatorio y está vacío"));
1124    }
1125    if raw.is_empty() && !s.required {
1126        return Ok(Value::Null);
1127    }
1128    parse_field_value(s.kind, raw).map_err(|e| format!("param '{label}': {e}"))
1129}
1130
1131/// Inferencia de tipo para values pasados como `params` a un
1132/// morphism. Usada como fallback en `resolve_param_value` cuando el
1133/// param declarado en `Action::Morphism.params` no aparece en los
1134/// `form.fields` (módulo mal-formado).
1135///
1136/// Heurística simple: int → i64, float → f64, "true"/"false" → bool,
1137/// resto → string.
1138fn infer_param_value(raw: &str) -> Value {
1139    if raw.is_empty() {
1140        return Value::Null;
1141    }
1142    if let Ok(i) = raw.parse::<i64>() {
1143        return json!(i);
1144    }
1145    if let Ok(f) = raw.parse::<f64>() {
1146        return json!(f);
1147    }
1148    match raw {
1149        "true" => return json!(true),
1150        "false" => return json!(false),
1151        _ => {}
1152    }
1153    json!(raw)
1154}
1155
1156/// Conversión inversa a `parse_field_value`: del JSON al texto raw
1157/// que un input puede tomar y volver a parsearse igual al submit.
1158/// Usado para pre-llenar inputs en modo edit.
1159fn value_to_input_text(v: &Value) -> String {
1160    match v {
1161        Value::Null => String::new(),
1162        Value::String(s) => s.clone(),
1163        Value::Bool(b) => if *b { "true" } else { "false" }.to_string(),
1164        Value::Number(n) => n.to_string(),
1165        other => other.to_string(),
1166    }
1167}
1168
1169fn short_uuid(id: &Uuid) -> String {
1170    id.to_string().chars().take(8).collect()
1171}
1172
1173impl Render for MetaUi {
1174    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1175        let bg = gpui::rgb(0x14171c);
1176        let panel = gpui::rgb(0x1d2128);
1177        let border = gpui::rgb(0x2a2f38);
1178        let text = gpui::rgb(0xe6e8ec);
1179        let text_dim = gpui::rgb(0x9ba1ad);
1180        let accent = gpui::rgb(0x88c0d0);
1181        let accent_active = gpui::rgb(0xa3be8c);
1182
1183        let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
1184        let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
1185        let confirm_banner = self.render_confirm_delete_banner(cx);
1186        let toast_div = self.toast.as_ref().map(|t| {
1187            div()
1188                .px(px(12.))
1189                .py(px(6.))
1190                .bg(gpui::rgb(0x2d3a2a))
1191                .text_color(gpui::rgb(0xc0e0a0))
1192                .text_size(px(11.))
1193                .child(t.clone())
1194        });
1195        let error_banner = self.load_error.as_ref().map(|e| {
1196            div()
1197                .px(px(12.))
1198                .py(px(6.))
1199                .bg(gpui::rgb(0x4a2020))
1200                .text_color(gpui::rgb(0xffd0d0))
1201                .text_size(px(11.))
1202                .child(e.clone())
1203        });
1204
1205        div()
1206            .flex()
1207            .flex_col()
1208            .size_full()
1209            .bg(bg)
1210            // Capture phase: el Esc llega al root ANTES que cualquier
1211            // TextInput descendiente. Si hay un delete pendiente, lo
1212            // cancelamos. Sin pending no hacemos nada (el evento sigue
1213            // su flujo normal y el TextInput recibe el Esc bubble).
1214            .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _w, cx| {
1215                if event.keystroke.key != "escape" {
1216                    return;
1217                }
1218                if let Some((entity, _id)) = this.pending_delete.take() {
1219                    this.toast = Some(SharedString::from(format!(
1220                        "delete cancelado ({entity}) [esc]"
1221                    )));
1222                    cx.notify();
1223                }
1224            }))
1225            .when_some(error_banner, |d, b| d.child(b))
1226            .when_some(confirm_banner, |d, b| d.child(b))
1227            .child(
1228                div()
1229                    .flex()
1230                    .flex_row()
1231                    .flex_grow()
1232                    .child(sidebar)
1233                    .child(main_panel),
1234            )
1235            .when_some(toast_div, |d, t| d.child(t))
1236    }
1237}
1238
1239impl MetaUi {
1240    /// Renderea el banner modal de confirmación cuando hay un delete
1241    /// pendiente. Devuelve `None` si no hay nada que confirmar.
1242    ///
1243    /// UX: banner amber prominente arriba de todo el contenido. No es
1244    /// un overlay flotante (GPUI no expone z-index fácilmente sin
1245    /// setup) — es un row del flex_col raíz, así que el contenido
1246    /// debajo se desplaza unos pixels mientras está activo. Suficiente
1247    /// para forzar al usuario a leer + click.
1248    fn render_confirm_delete_banner(&self, cx: &mut Context<Self>) -> Option<gpui::Div> {
1249        let (entity, id) = self.pending_delete.as_ref()?;
1250        let entity_owned = entity.clone();
1251        let id_owned = *id;
1252        let entity_for_cancel = entity_owned.clone();
1253        let id_short = short_uuid(&id_owned);
1254        let entity_for_confirm = entity_owned.clone();
1255
1256        let banner_bg = gpui::rgb(0x4a3a1a);
1257        let banner_text = gpui::rgb(0xf0e0a0);
1258        let confirm_bg = gpui::rgb(0x6a2222);
1259        let confirm_text = gpui::rgb(0xffd0d0);
1260        let cancel_bg = gpui::rgb(0x2a2f38);
1261        let cancel_text = gpui::rgb(0xc0c8d0);
1262
1263        Some(
1264            div()
1265                .flex()
1266                .flex_row()
1267                .items_center()
1268                .gap(px(12.))
1269                .px(px(12.))
1270                .py(px(8.))
1271                .bg(banner_bg)
1272                .text_color(banner_text)
1273                .text_size(px(12.))
1274                .child(
1275                    div()
1276                        .flex_grow()
1277                        .flex()
1278                        .flex_col()
1279                        .child(format!("¿Borrar {entity_owned} {id_short}?"))
1280                        .child(
1281                            div()
1282                                .text_size(px(10.))
1283                                .text_color(gpui::rgb(0xc0a070))
1284                                .child("Esc para cancelar · click [Confirmar] para borrar"),
1285                        ),
1286                )
1287                .child(
1288                    div()
1289                        .id(SharedString::from(format!(
1290                            "confirm-del-cancel-{}",
1291                            id_owned
1292                        )))
1293                        .px(px(10.))
1294                        .py(px(4.))
1295                        .bg(cancel_bg)
1296                        .text_color(cancel_text)
1297                        .hover(|d| d.bg(gpui::rgb(0x3a3f48)))
1298                        .child("Cancelar")
1299                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1300                            this.pending_delete = None;
1301                            this.toast = Some(SharedString::from(format!(
1302                                "delete cancelado ({entity_for_cancel})"
1303                            )));
1304                            cx.notify();
1305                        })),
1306                )
1307                .child(
1308                    div()
1309                        .id(SharedString::from(format!(
1310                            "confirm-del-ok-{}",
1311                            id_owned
1312                        )))
1313                        .px(px(10.))
1314                        .py(px(4.))
1315                        .bg(confirm_bg)
1316                        .text_color(confirm_text)
1317                        .hover(|d| d.bg(gpui::rgb(0x8a2828)))
1318                        .child("Confirmar")
1319                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1320                            // Limpiar primero para que un fallo del
1321                            // commit no deje el banner colgado y el
1322                            // toast tape el banner.
1323                            this.pending_delete = None;
1324                            match this.commit_delete(&entity_for_confirm, id_owned) {
1325                                Ok(()) => {
1326                                    let base = format!(
1327                                        "borrado {entity_for_confirm} {}",
1328                                        short_uuid(&id_owned)
1329                                    );
1330                                    this.toast = Some(append_compact_msg(
1331                                        base,
1332                                        this.tick_runtime_compact(),
1333                                    ));
1334                                }
1335                                Err(e) => {
1336                                    this.toast = Some(SharedString::from(format!(
1337                                        "error borrando: {e}"
1338                                    )));
1339                                }
1340                            }
1341                            cx.notify();
1342                        })),
1343                ),
1344        )
1345    }
1346
1347    fn render_sidebar(
1348        &self,
1349        cx: &mut Context<Self>,
1350        panel: gpui::Rgba,
1351        border: gpui::Rgba,
1352        text: gpui::Rgba,
1353        text_dim: gpui::Rgba,
1354        accent_active: gpui::Rgba,
1355    ) -> gpui::Div {
1356        let mut sidebar = div()
1357            .w(px(240.))
1358            .h_full()
1359            .bg(panel)
1360            .border_r_1()
1361            .border_color(border)
1362            .flex()
1363            .flex_col();
1364
1365        sidebar = sidebar.child(
1366            div()
1367                .px(px(12.))
1368                .py(px(10.))
1369                .text_color(text)
1370                .text_size(px(13.))
1371                .child("Nakui"),
1372        );
1373
1374        if self.modules.is_empty() {
1375            return sidebar.child(
1376                div()
1377                    .px(px(12.))
1378                    .py(px(8.))
1379                    .text_color(text_dim)
1380                    .text_size(px(11.))
1381                    .child("(no hay módulos cargados)"),
1382            );
1383        }
1384
1385        // Snapshot del active para evitar borrow del self adentro de la closure.
1386        let active_snapshot = self.active.clone();
1387
1388        for (mod_idx, m) in self.modules.iter().enumerate() {
1389            sidebar = sidebar.child(
1390                div()
1391                    .px(px(12.))
1392                    .py(px(8.))
1393                    .border_t_1()
1394                    .border_color(border)
1395                    .text_color(text)
1396                    .text_size(px(12.))
1397                    .child(m.label.clone()),
1398            );
1399
1400            for item in &m.menu {
1401                let is_active = active_snapshot
1402                    .as_ref()
1403                    .map(|(i, v)| *i == mod_idx && v == &item.view)
1404                    .unwrap_or(false);
1405                let label = item
1406                    .icon
1407                    .as_deref()
1408                    .map(|ic| format!("{ic}  {}", item.label))
1409                    .unwrap_or_else(|| item.label.clone());
1410
1411                let view_key = item.view.clone();
1412                sidebar = sidebar.child(
1413                    div()
1414                        .id(SharedString::from(format!(
1415                            "menu-{}-{}",
1416                            mod_idx, item.view
1417                        )))
1418                        .px(px(20.))
1419                        .py(px(6.))
1420                        .text_size(px(12.))
1421                        .text_color(if is_active { accent_active } else { text_dim })
1422                        .when(is_active, |d| {
1423                            d.bg(gpui::rgb(0x232a36)).text_color(text)
1424                        })
1425                        .hover(|d| d.bg(gpui::rgb(0x1f2630)))
1426                        .child(label)
1427                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1428                            this.select_view(mod_idx, view_key.clone(), cx);
1429                        })),
1430                );
1431            }
1432        }
1433        sidebar
1434    }
1435
1436    fn render_main(
1437        &mut self,
1438        cx: &mut Context<Self>,
1439        panel: gpui::Rgba,
1440        border: gpui::Rgba,
1441        text: gpui::Rgba,
1442        text_dim: gpui::Rgba,
1443        accent: gpui::Rgba,
1444    ) -> gpui::Div {
1445        let main = div()
1446            .flex_grow()
1447            .h_full()
1448            .bg(panel)
1449            .flex()
1450            .flex_col()
1451            .p(px(16.));
1452
1453        let (mod_idx, view_key) = match &self.active {
1454            Some(a) => (a.0, a.1.clone()),
1455            None => {
1456                return main.child(
1457                    div()
1458                        .text_color(text_dim)
1459                        .child("Seleccioná un menú a la izquierda."),
1460                );
1461            }
1462        };
1463
1464        let view = match self
1465            .modules
1466            .get(mod_idx)
1467            .and_then(|m| m.views.get(&view_key))
1468        {
1469            Some(v) => v.clone(),
1470            None => {
1471                return main.child(
1472                    div()
1473                        .text_color(text_dim)
1474                        .child(format!("Vista no encontrada: {view_key}")),
1475                );
1476            }
1477        };
1478
1479        match view {
1480            View::List(lv) => self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent),
1481            View::Form(fv) => self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent),
1482        }
1483    }
1484
1485    #[allow(clippy::too_many_arguments)]
1486    fn render_list(
1487        &self,
1488        cx: &mut Context<Self>,
1489        mut main: gpui::Div,
1490        lv: &ListView,
1491        mod_idx: usize,
1492        border: gpui::Rgba,
1493        text: gpui::Rgba,
1494        text_dim: gpui::Rgba,
1495        accent: gpui::Rgba,
1496    ) -> gpui::Div {
1497        let mut header = div()
1498            .flex()
1499            .flex_row()
1500            .items_center()
1501            .gap(px(12.))
1502            .mb(px(12.))
1503            .child(
1504                div()
1505                    .text_color(text)
1506                    .text_size(px(18.))
1507                    .child(lv.title.clone()),
1508            );
1509        for (idx, action) in lv.actions.iter().enumerate() {
1510            let label = match action {
1511                Action::OpenView { label, view } => {
1512                    label.clone().unwrap_or_else(|| format!("→ {view}"))
1513                }
1514                Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
1515                Action::Morphism { name, .. } => format!("⚡ {name}"),
1516            };
1517            let action_clone = action.clone();
1518            header = header.child(
1519                div()
1520                    .id(SharedString::from(format!(
1521                        "list-action-{mod_idx}-{idx}"
1522                    )))
1523                    .px(px(10.))
1524                    .py(px(4.))
1525                    .bg(gpui::rgb(0x232a36))
1526                    .text_color(accent)
1527                    .text_size(px(11.))
1528                    .rounded(px(3.))
1529                    .hover(|d| d.bg(gpui::rgb(0x2c3540)))
1530                    .child(label)
1531                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1532                        this.apply_action(action_clone.clone(), cx);
1533                    })),
1534            );
1535        }
1536        main = main.child(header);
1537
1538        let rows = self.list_rows(&lv.entity);
1539        let total = rows.len();
1540
1541        let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
1542        let mut col_header = div()
1543            .flex()
1544            .flex_row()
1545            .py(px(6.))
1546            .border_b_1()
1547            .border_color(border)
1548            .text_color(text_dim)
1549            .text_size(px(11.));
1550        for c in &lv.columns {
1551            let frac = c.weight / total_weight;
1552            col_header = col_header.child(
1553                div()
1554                    .flex_grow()
1555                    .flex_basis(px(100. * frac))
1556                    .child(c.label.clone()),
1557            );
1558        }
1559        col_header = col_header
1560            .child(div().w(px(80.)).text_color(text_dim).child("id"))
1561            .child(div().w(px(70.)).text_color(text_dim).child("acciones"));
1562        main = main.child(col_header);
1563
1564        let entity_name = lv.entity.clone();
1565        for (id, value) in &rows {
1566            let id_copy = *id;
1567            let entity_for_edit = entity_name.clone();
1568            let entity_for_delete = entity_name.clone();
1569            let mut row = div()
1570                .flex()
1571                .flex_row()
1572                .py(px(6.))
1573                .border_b_1()
1574                .border_color(gpui::rgb(0x232a36))
1575                .text_color(text)
1576                .text_size(px(12.));
1577            for c in &lv.columns {
1578                let frac = c.weight / total_weight;
1579                let v = lookup_field(value, &c.field);
1580                row = row.child(
1581                    div()
1582                        .flex_grow()
1583                        .flex_basis(px(100. * frac))
1584                        .child(render_value(v)),
1585                );
1586            }
1587            row = row.child(
1588                div()
1589                    .w(px(80.))
1590                    .text_color(text_dim)
1591                    .text_size(px(11.))
1592                    .child(short_uuid(id)),
1593            );
1594            // Acciones: ✎ edit + ✕ delete por fila.
1595            row = row.child(
1596                div()
1597                    .w(px(70.))
1598                    .flex()
1599                    .flex_row()
1600                    .gap(px(4.))
1601                    .child(
1602                        div()
1603                            .id(SharedString::from(format!(
1604                                "row-edit-{mod_idx}-{id_copy}"
1605                            )))
1606                            .px(px(6.))
1607                            .text_color(accent)
1608                            .text_size(px(13.))
1609                            .hover(|d| d.bg(gpui::rgb(0x2c3540)))
1610                            .child("✎")
1611                            .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1612                                this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
1613                            })),
1614                    )
1615                    .child(
1616                        div()
1617                            .id(SharedString::from(format!(
1618                                "row-del-{mod_idx}-{id_copy}"
1619                            )))
1620                            .px(px(6.))
1621                            .text_color(gpui::rgb(0xd07070))
1622                            .text_size(px(13.))
1623                            .hover(|d| d.bg(gpui::rgb(0x4a2020)))
1624                            .child("✕")
1625                            .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1626                                // Marca para borrar en lugar de borrar
1627                                // directo: el modal de confirmación se
1628                                // renderea arriba en `render` y maneja
1629                                // confirm/cancel.
1630                                this.pending_delete =
1631                                    Some((entity_for_delete.clone(), id_copy));
1632                                this.toast = None;
1633                                cx.notify();
1634                            })),
1635                    ),
1636            );
1637            main = main.child(row);
1638        }
1639
1640        if rows.is_empty() {
1641            main = main.child(
1642                div()
1643                    .py(px(12.))
1644                    .text_color(text_dim)
1645                    .text_size(px(12.))
1646                    .child(format!("(sin {})", lv.entity)),
1647            );
1648        } else {
1649            main = main.child(
1650                div()
1651                    .mt(px(8.))
1652                    .text_color(text_dim)
1653                    .text_size(px(11.))
1654                    .child(format!("{total} fila(s)")),
1655            );
1656        }
1657
1658        main
1659    }
1660
1661    /// Renderea el selector clickable de records existentes para un
1662    /// FieldSpec con kind=EntityRef. Lista compacta debajo del input;
1663    /// click en una opción setea el TextInput del field con el UUID
1664    /// seleccionado. El item del UUID actualmente seleccionado (si
1665    /// hay) se resalta con accent color.
1666    #[allow(clippy::too_many_arguments)]
1667    fn render_entity_ref_selector(
1668        &self,
1669        cx: &mut Context<Self>,
1670        field_name: String,
1671        target_entity: String,
1672        text: gpui::Rgba,
1673        text_dim: gpui::Rgba,
1674        accent: gpui::Rgba,
1675    ) -> gpui::Div {
1676        let _ = text;
1677        let rows = self.list_rows(&target_entity);
1678        let current = self
1679            .form_inputs
1680            .get(&field_name)
1681            .map(|inp| inp.read(&*cx).text().to_string())
1682            .unwrap_or_default();
1683
1684        let mut container = div()
1685            .mt(px(4.))
1686            .pl(px(8.))
1687            .border_l_2()
1688            .border_color(gpui::rgb(0x2a2f38))
1689            .flex()
1690            .flex_col()
1691            .gap(px(2.));
1692
1693        if rows.is_empty() {
1694            return container.child(
1695                div()
1696                    .px(px(6.))
1697                    .py(px(4.))
1698                    .text_color(text_dim)
1699                    .text_size(px(10.))
1700                    .child(format!(
1701                        "(sin {target_entity}: creá uno antes para referenciar)"
1702                    )),
1703            );
1704        }
1705
1706        container = container.child(
1707            div()
1708                .text_color(text_dim)
1709                .text_size(px(10.))
1710                .child(format!("Seleccioná un {target_entity}:")),
1711        );
1712
1713        for (id, value) in &rows {
1714            let label = human_label_for_record(value, id);
1715            let id_str = id.to_string();
1716            let is_selected = current == id_str;
1717            let field_for_click = field_name.clone();
1718            let id_for_click = id_str.clone();
1719            container = container.child(
1720                div()
1721                    .id(SharedString::from(format!(
1722                        "entity-ref-{field_name}-{id_str}"
1723                    )))
1724                    .px(px(6.))
1725                    .py(px(2.))
1726                    .text_size(px(11.))
1727                    .text_color(if is_selected { accent } else { text_dim })
1728                    .when(is_selected, |d| d.bg(gpui::rgb(0x232a36)))
1729                    .hover(|d| d.bg(gpui::rgb(0x1f2630)))
1730                    .child(label)
1731                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1732                        if let Some(input) = this.form_inputs.get(&field_for_click) {
1733                            input.update(cx, |inp, cx| inp.set_text(id_for_click.clone(), cx));
1734                        }
1735                        cx.notify();
1736                    })),
1737            );
1738        }
1739        container
1740    }
1741
1742    #[allow(clippy::too_many_arguments)]
1743    fn render_form(
1744        &self,
1745        cx: &mut Context<Self>,
1746        mut main: gpui::Div,
1747        fv: &FormView,
1748        mod_idx: usize,
1749        _border: gpui::Rgba,
1750        text: gpui::Rgba,
1751        text_dim: gpui::Rgba,
1752        accent: gpui::Rgba,
1753    ) -> gpui::Div {
1754        // En modo edit, el título refleja eso para que el user no
1755        // se confunda creyendo que hace alta nueva.
1756        let title = match self.editing.as_ref() {
1757            Some((e, id)) if e == &fv.entity => {
1758                format!("Editar {} {}", fv.entity, short_uuid(id))
1759            }
1760            _ => fv.title.clone(),
1761        };
1762        main = main.child(
1763            div()
1764                .text_color(text)
1765                .text_size(px(18.))
1766                .mb(px(12.))
1767                .child(title),
1768        );
1769        for f in &fv.fields {
1770            let label = if f.required {
1771                format!("{} *", f.label)
1772            } else {
1773                f.label.clone()
1774            };
1775
1776            let mut field_box = div().flex().flex_col().mb(px(10.)).child(
1777                div()
1778                    .text_color(text_dim)
1779                    .text_size(px(11.))
1780                    .mb(px(2.))
1781                    .child(label),
1782            );
1783
1784            // Mount del TextInput vivo (creado en select_view).
1785            if let Some(input) = self.form_inputs.get(&f.name) {
1786                field_box = field_box.child(input.clone());
1787            } else {
1788                // No debería pasar — select_view crea inputs por cada
1789                // field. Fallback display estático por seguridad.
1790                field_box = field_box.child(
1791                    div()
1792                        .px(px(8.))
1793                        .py(px(6.))
1794                        .bg(gpui::rgb(0x171a20))
1795                        .text_color(text_dim)
1796                        .child("(input no inicializado)"),
1797                );
1798            }
1799
1800            // Para EntityRef, agregamos un selector clickable de
1801            // records existentes debajo del TextInput. Click en una
1802            // opción setea el TextInput interno con el UUID; el
1803            // submit lee de ahí como cualquier otro field.
1804            if f.kind == FieldKind::EntityRef {
1805                if let Some(target_entity) = &f.ref_entity {
1806                    field_box = field_box.child(self.render_entity_ref_selector(
1807                        cx,
1808                        f.name.clone(),
1809                        target_entity.clone(),
1810                        text,
1811                        text_dim,
1812                        accent,
1813                    ));
1814                }
1815            }
1816
1817            if let Some(help) = &f.help {
1818                field_box = field_box.child(
1819                    div()
1820                        .mt(px(2.))
1821                        .text_color(text_dim)
1822                        .text_size(px(10.))
1823                        .child(help.clone()),
1824                );
1825            }
1826            main = main.child(field_box);
1827        }
1828
1829        let editing_this = matches!(
1830            self.editing.as_ref(),
1831            Some((e, _)) if e == &fv.entity
1832        );
1833        let submit_label = match &fv.on_submit {
1834            Action::SeedEntity { entity, .. } => {
1835                if editing_this {
1836                    format!("Guardar cambios en {entity}")
1837                } else {
1838                    format!("Crear {entity}")
1839                }
1840            }
1841            Action::Morphism { name, .. } => format!("Ejecutar {name}"),
1842            Action::OpenView { label, view } => {
1843                label.clone().unwrap_or_else(|| format!("Ir a {view}"))
1844            }
1845        };
1846        let submit_action = fv.on_submit.clone();
1847        main = main.child(
1848            div().mt(px(12.)).child(
1849                div()
1850                    .id(SharedString::from(format!("form-submit-{mod_idx}")))
1851                    .px(px(12.))
1852                    .py(px(6.))
1853                    .bg(gpui::rgb(0x2c3540))
1854                    .text_color(accent)
1855                    .text_size(px(12.))
1856                    .rounded(px(3.))
1857                    .hover(|d| d.bg(gpui::rgb(0x3a4555)))
1858                    .child(submit_label)
1859                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1860                        this.apply_action(submit_action.clone(), cx);
1861                    })),
1862            ),
1863        );
1864
1865        main = main.child(
1866            div()
1867                .mt(px(20.))
1868                .text_color(text_dim)
1869                .text_size(px(10.))
1870                .child(
1871                    "Tip: click en el campo para enfocar; Enter no envía (todavía), \
1872                     usá el botón. Backspace borra el último carácter.",
1873                ),
1874        );
1875        main
1876    }
1877}
1878
1879#[cfg(test)]
1880mod tests {
1881    use super::*;
1882
1883    #[test]
1884    fn parse_field_text_returns_string() {
1885        assert_eq!(parse_field_value(FieldKind::Text, "hola").unwrap(), json!("hola"));
1886    }
1887
1888    #[test]
1889    fn parse_field_number_int_then_float() {
1890        assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42));
1891        assert_eq!(parse_field_value(FieldKind::Number, "3.14").unwrap(), json!(3.14));
1892    }
1893
1894    #[test]
1895    fn parse_field_number_invalid_errors() {
1896        assert!(parse_field_value(FieldKind::Number, "not-a-number").is_err());
1897    }
1898
1899    #[test]
1900    fn parse_field_boolean_variants() {
1901        assert_eq!(parse_field_value(FieldKind::Boolean, "true").unwrap(), json!(true));
1902        assert_eq!(parse_field_value(FieldKind::Boolean, "yes").unwrap(), json!(true));
1903        assert_eq!(parse_field_value(FieldKind::Boolean, "false").unwrap(), json!(false));
1904        assert_eq!(parse_field_value(FieldKind::Boolean, "").unwrap(), json!(false));
1905        assert!(parse_field_value(FieldKind::Boolean, "maybe").is_err());
1906    }
1907
1908    #[test]
1909    fn lookup_field_simple_and_nested() {
1910        let v = json!({
1911            "name": "Acme",
1912            "address": { "city": "Bogotá", "country": "CO" }
1913        });
1914        assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
1915        assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
1916        assert!(lookup_field(&v, "missing").is_none());
1917        assert!(lookup_field(&v, "address.zipcode").is_none());
1918    }
1919
1920    #[test]
1921    fn infer_param_value_int_then_float_then_bool_then_string() {
1922        assert_eq!(infer_param_value(""), json!(null));
1923        assert_eq!(infer_param_value("42"), json!(42));
1924        assert_eq!(infer_param_value("3.14"), json!(3.14));
1925        assert_eq!(infer_param_value("true"), json!(true));
1926        assert_eq!(infer_param_value("false"), json!(false));
1927        assert_eq!(infer_param_value("hola"), json!("hola"));
1928    }
1929
1930    fn spec(name: &str, kind: FieldKind, required: bool) -> FieldSpec {
1931        FieldSpec {
1932            name: name.into(),
1933            label: name.into(),
1934            kind,
1935            default: None,
1936            required,
1937            help: None,
1938            ref_entity: None,
1939        }
1940    }
1941
1942    fn map(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
1943        items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
1944    }
1945
1946    #[test]
1947    fn delta_empty_when_all_fields_match() {
1948        let current = json!({
1949            "name": "Acme",
1950            "saldo": 100_i64,
1951            "currency": "USD",
1952        });
1953        let proposed = map(&[
1954            ("name", json!("Acme")),
1955            ("saldo", json!(100_i64)),
1956            ("currency", json!("USD")),
1957        ]);
1958        let delta = compute_field_delta(&current, &proposed);
1959        assert!(delta.is_empty(), "no-op edit debería dar delta vacío");
1960    }
1961
1962    #[test]
1963    fn delta_includes_only_changed_field() {
1964        let current = json!({
1965            "name": "Acme",
1966            "saldo": 100_i64,
1967            "currency": "USD",
1968        });
1969        // El usuario sólo cambió saldo.
1970        let proposed = map(&[
1971            ("name", json!("Acme")),
1972            ("saldo", json!(200_i64)),
1973            ("currency", json!("USD")),
1974        ]);
1975        let delta = compute_field_delta(&current, &proposed);
1976        assert_eq!(delta.len(), 1, "sólo saldo debería estar en delta");
1977        assert_eq!(delta.get("saldo"), Some(&json!(200_i64)));
1978        assert!(!delta.contains_key("name"));
1979        assert!(!delta.contains_key("currency"));
1980    }
1981
1982    #[test]
1983    fn delta_treats_missing_record_as_all_new() {
1984        // Record no existe en el store (load → None → Value::Null).
1985        // Todos los campos del proposed deberían entrar al delta.
1986        let current = Value::Null;
1987        let proposed = map(&[
1988            ("name", json!("Acme")),
1989            ("saldo", json!(0_i64)),
1990        ]);
1991        let delta = compute_field_delta(&current, &proposed);
1992        assert_eq!(delta.len(), 2);
1993    }
1994
1995    #[test]
1996    fn delta_distinguishes_int_from_string_repr() {
1997        // Sanity: si el form devuelve "100" como Number → json!(100_i64)
1998        // y el store tiene json!(100), comparan iguales (PartialEq de
1999        // Value normaliza). Si el store tuviera "100" string, NO igualan.
2000        let current = json!({"qty": 100_i64});
2001        let proposed = map(&[("qty", json!(100_i64))]);
2002        assert!(compute_field_delta(&current, &proposed).is_empty());
2003
2004        let current_str = json!({"qty": "100"});
2005        let proposed_int = map(&[("qty", json!(100_i64))]);
2006        assert_eq!(
2007            compute_field_delta(&current_str, &proposed_int).len(),
2008            1,
2009            "string '100' vs int 100 sí debería contar como cambio"
2010        );
2011    }
2012
2013    #[test]
2014    fn delta_skips_fields_absent_from_proposed() {
2015        // Si el form omite un field (porque el FieldSpec no lo
2016        // declara), no lo deberíamos mencionar en el delta — el edit
2017        // sólo toca los fields del form.
2018        let current = json!({
2019            "name": "Acme",
2020            "saldo": 100_i64,
2021            "internal_marker": "x",
2022        });
2023        let proposed = map(&[
2024            ("name", json!("Acme")),
2025            ("saldo", json!(150_i64)),
2026        ]);
2027        let delta = compute_field_delta(&current, &proposed);
2028        assert_eq!(delta.len(), 1);
2029        assert_eq!(delta.get("saldo"), Some(&json!(150_i64)));
2030        assert!(!delta.contains_key("internal_marker"));
2031    }
2032
2033    #[test]
2034    fn append_compact_msg_handles_both_branches() {
2035        // Sin compact: base solo, sin separador.
2036        let s = append_compact_msg("creado X".into(), None);
2037        assert_eq!(s.as_ref(), "creado X");
2038        // Con compact: concatena con "; ".
2039        let s = append_compact_msg(
2040            "creado X".into(),
2041            Some("auto-compact: snapshot @ seq 49".into()),
2042        );
2043        assert_eq!(s.as_ref(), "creado X; auto-compact: snapshot @ seq 49");
2044    }
2045
2046    /// Simula el ciclo de write+tick que ocurriría en runtime: con
2047    /// threshold=N, los primeros N-1 writes no compactan, el N-ésimo
2048    /// dispara compact y resetea el counter, los siguientes vuelven
2049    /// a acumular.
2050    ///
2051    /// Como `tick_runtime_compact` es un método de `MetaUi` (necesita
2052    /// el cx GPUI para construir el state completo), reproducimos el
2053    /// algorithm por separado: counter manual + invocación directa de
2054    /// `maybe_compact_log`. Si la lógica del tick cambia, este test
2055    /// se va a romper como signal de que hay que actualizarlo.
2056    #[test]
2057    fn runtime_compact_cycle_resets_counter_after_threshold() {
2058        let tmp = tempfile::NamedTempFile::new().unwrap();
2059        let path = tmp.path().to_path_buf();
2060        drop(tmp);
2061        let snap_path = snapshot_path_for(&path);
2062        let threshold: usize = 3;
2063
2064        let mut store = MemoryStore::new();
2065        let mut log = EventLog::open(&path).unwrap();
2066        let mut counter: u64 = 0;
2067        let mut total_compactions = 0u32;
2068
2069        // 7 writes con threshold=3 → 2 compacts (en write 3 y en
2070        // write 6), counter restante = 1 al final.
2071        for i in 0..7u64 {
2072            let id = Uuid::new_v4();
2073            log.append(LogEntry::Seed {
2074                seq: i,
2075                entity: "row".into(),
2076                id,
2077                data: json!({"i": i}),
2078                schema_hash: None,
2079            })
2080            .unwrap();
2081            store.seed("row", id, json!({"i": i}));
2082
2083            // Tick.
2084            counter += 1;
2085            if counter >= threshold as u64 {
2086                let res =
2087                    maybe_compact_log(&mut log, &snap_path, &store, threshold).unwrap();
2088                if res.is_some() {
2089                    total_compactions += 1;
2090                }
2091                counter = 0;
2092            }
2093        }
2094
2095        assert_eq!(
2096            total_compactions, 2,
2097            "con 7 writes y threshold 3 deberíamos disparar 2 compacts"
2098        );
2099        assert_eq!(counter, 1, "1 write residual sin compactar");
2100
2101        // El log final debería tener: el anchor del último compact +
2102        // el write residual = 2 entries.
2103        let entries_after = EventLog::open(&path).unwrap().entries().unwrap().len();
2104        assert_eq!(
2105            entries_after, 2,
2106            "1 anchor del último compact + 1 write residual"
2107        );
2108
2109        let _ = std::fs::remove_file(&path);
2110        let _ = std::fs::remove_file(&snap_path);
2111    }
2112
2113    #[test]
2114    fn validate_entity_refs_passes_when_all_records_exist() {
2115        let mut store = MemoryStore::new();
2116        let stock_id = Uuid::new_v4();
2117        let caja_id = Uuid::new_v4();
2118        store.seed("Stock", stock_id, json!({"sku_id": "abc"}));
2119        store.seed("Caja", caja_id, json!({"name": "Principal"}));
2120        let refs = vec![
2121            ("Stock".into(), "Stock".into(), stock_id),
2122            ("Caja".into(), "Caja".into(), caja_id),
2123        ];
2124        assert!(validate_entity_refs(&store, &refs).is_ok());
2125    }
2126
2127    #[test]
2128    fn validate_entity_refs_fails_on_first_missing() {
2129        let mut store = MemoryStore::new();
2130        let stock_id = Uuid::new_v4();
2131        store.seed("Stock", stock_id, json!({"sku_id": "abc"}));
2132        let missing_caja = Uuid::new_v4();
2133        let refs = vec![
2134            ("Stock".into(), "Stock".into(), stock_id),
2135            ("Caja".into(), "Caja".into(), missing_caja),
2136        ];
2137        let err = validate_entity_refs(&store, &refs).unwrap_err();
2138        assert!(err.contains("Caja"), "msg debe nombrar la entity: {err}");
2139        assert!(
2140            err.contains(&short_uuid(&missing_caja)),
2141            "msg debe incluir el UUID corto: {err}"
2142        );
2143    }
2144
2145    #[test]
2146    fn validate_entity_refs_uses_label_not_entity_in_msg() {
2147        // Si el FieldSpec.label es distinto de la entity (ej:
2148        // "Stock origen" en lugar de "Stock"), el error debería usar
2149        // el label legible.
2150        let store = MemoryStore::new();
2151        let id = Uuid::new_v4();
2152        let refs = vec![("Stock origen".into(), "Stock".into(), id)];
2153        let err = validate_entity_refs(&store, &refs).unwrap_err();
2154        assert!(
2155            err.contains("Stock origen"),
2156            "msg debe incluir el label: {err}"
2157        );
2158    }
2159
2160    #[test]
2161    fn validate_entity_refs_empty_list_is_ok() {
2162        let store = MemoryStore::new();
2163        assert!(validate_entity_refs(&store, &[]).is_ok());
2164    }
2165
2166    #[test]
2167    fn validate_entity_refs_distinguishes_target_from_other_entities() {
2168        // Sanity: un UUID que existe bajo entity X pero NO bajo Y
2169        // debería fallar la validación contra Y.
2170        let mut store = MemoryStore::new();
2171        let id = Uuid::new_v4();
2172        store.seed("Customer", id, json!({"name": "Acme"}));
2173        // Mismo UUID, target distinto.
2174        let refs = vec![("Stock".into(), "Stock".into(), id)];
2175        assert!(validate_entity_refs(&store, &refs).is_err());
2176    }
2177
2178    #[test]
2179    fn clear_fields_skips_absent_and_null() {
2180        let current = json!({
2181            "name": "Acme",
2182            "notes": "lorem",
2183            "tag": null,
2184        });
2185        let to_clear = vec![
2186            "name".to_string(),
2187            "notes".to_string(),
2188            "tag".to_string(),
2189            "missing".to_string(),
2190        ];
2191        let actual = compute_clear_fields(&current, &to_clear);
2192        assert_eq!(
2193            actual,
2194            vec!["name".to_string(), "notes".to_string()],
2195            "tag (null) y missing (ausente) deberían filtrarse — Clear sería no-op"
2196        );
2197    }
2198
2199    #[test]
2200    fn clear_fields_preserves_input_order() {
2201        let current = json!({"a": 1, "b": 2, "c": 3});
2202        let to_clear = vec!["c".to_string(), "a".to_string(), "b".to_string()];
2203        let actual = compute_clear_fields(&current, &to_clear);
2204        assert_eq!(actual, vec!["c", "a", "b"], "orden del input se preserva");
2205    }
2206
2207    #[test]
2208    fn clear_fields_empty_when_current_is_null() {
2209        // Record no existe en el store (load → None → Value::Null
2210        // upstream). Ningún clear debería emitirse.
2211        let current = Value::Null;
2212        let to_clear = vec!["name".to_string()];
2213        assert!(compute_clear_fields(&current, &to_clear).is_empty());
2214    }
2215
2216    #[test]
2217    fn snapshot_path_for_replaces_extension() {
2218        use std::path::Path;
2219        assert_eq!(
2220            snapshot_path_for(Path::new("nakui-ui-state.jsonl")),
2221            std::path::PathBuf::from("nakui-ui-state.snap.json"),
2222        );
2223        // Sin extensión: agrega .snap.json.
2224        assert_eq!(
2225            snapshot_path_for(Path::new("/tmp/foo")),
2226            std::path::PathBuf::from("/tmp/foo.snap.json"),
2227        );
2228    }
2229
2230    #[test]
2231    fn maybe_compact_log_below_threshold_noops() {
2232        let tmp = tempfile::NamedTempFile::new().unwrap();
2233        let path = tmp.path().to_path_buf();
2234        drop(tmp);
2235        let snap_path = snapshot_path_for(&path);
2236        let mut log = EventLog::open(&path).unwrap();
2237        for i in 0..5 {
2238            log.append(LogEntry::Seed {
2239                seq: i,
2240                entity: "x".into(),
2241                id: Uuid::new_v4(),
2242                data: json!({"i": i}),
2243                schema_hash: None,
2244            })
2245            .unwrap();
2246        }
2247        let store = MemoryStore::new();
2248        let res = maybe_compact_log(&mut log, &snap_path, &store, 50).unwrap();
2249        assert!(res.is_none(), "5 < 50 → no debería compactar");
2250        assert_eq!(log.entries().unwrap().len(), 5, "log intacto");
2251        assert!(!snap_path.exists(), "no debería haber escrito snapshot");
2252        let _ = std::fs::remove_file(&path);
2253    }
2254
2255    #[test]
2256    fn maybe_compact_log_threshold_zero_noops() {
2257        let tmp = tempfile::NamedTempFile::new().unwrap();
2258        let path = tmp.path().to_path_buf();
2259        drop(tmp);
2260        let snap_path = snapshot_path_for(&path);
2261        let mut log = EventLog::open(&path).unwrap();
2262        for i in 0..3 {
2263            log.append(LogEntry::Seed {
2264                seq: i,
2265                entity: "x".into(),
2266                id: Uuid::new_v4(),
2267                data: json!({"i": i}),
2268                schema_hash: None,
2269            })
2270            .unwrap();
2271        }
2272        let store = MemoryStore::new();
2273        let res = maybe_compact_log(&mut log, &snap_path, &store, 0).unwrap();
2274        assert!(res.is_none(), "threshold 0 = disabled");
2275        let _ = std::fs::remove_file(&path);
2276    }
2277
2278    /// E2E del ciclo completo: write log con N entries por encima del
2279    /// threshold → maybe_compact_log captura snapshot y trunca el log
2280    /// → re-open + replay con snapshot → store final == store que
2281    /// resultaría de full replay sin snapshot.
2282    #[test]
2283    fn maybe_compact_log_then_reopen_preserves_records() {
2284        let tmp = tempfile::NamedTempFile::new().unwrap();
2285        let path = tmp.path().to_path_buf();
2286        drop(tmp);
2287        let snap_path = snapshot_path_for(&path);
2288
2289        // 1. Escribir 60 seeds + popular store en sync.
2290        let mut store = MemoryStore::new();
2291        let mut ids = Vec::new();
2292        let mut log = EventLog::open(&path).unwrap();
2293        for i in 0..60u64 {
2294            let id = Uuid::new_v4();
2295            log.append(LogEntry::Seed {
2296                seq: i,
2297                entity: "row".into(),
2298                id,
2299                data: json!({"i": i}),
2300                schema_hash: None,
2301            })
2302            .unwrap();
2303            store.seed("row", id, json!({"i": i}));
2304            ids.push(id);
2305        }
2306        assert_eq!(log.next_seq(), 60);
2307        assert_eq!(log.entries().unwrap().len(), 60);
2308
2309        // 2. Compactar (60 >= 50).
2310        let res = maybe_compact_log(&mut log, &snap_path, &store, 50).unwrap();
2311        assert!(res.is_some(), "60 >= 50 debe compactar");
2312        let msg = res.unwrap();
2313        assert!(msg.contains("seq 59"), "msg debe incluir el seq: {msg}");
2314        assert!(
2315            msg.contains("59 entries dropped"),
2316            "msg debe reportar 59 dropped (60 - 1 anchor): {msg}"
2317        );
2318
2319        // 3. Verificar: snapshot existe, log queda con 1 entry
2320        //    (anchor del cursor), next_seq se preserva (60).
2321        assert!(snap_path.exists(), "snapshot debería existir");
2322        let log_after = EventLog::open(&path).unwrap();
2323        assert_eq!(
2324            log_after.entries().unwrap().len(),
2325            1,
2326            "log debería tener 1 anchor entry tras compact"
2327        );
2328        assert_eq!(
2329            log_after.next_seq(),
2330            60,
2331            "next_seq se preserva via anchor entry"
2332        );
2333
2334        // 4. Re-open + replay desde snapshot → todos los records.
2335        //    El anchor entry cae bajo snap.seq así que se skipea.
2336        let snap = Snapshot::load(&snap_path).unwrap().expect("snap loadeable");
2337        assert_eq!(snap.seq, 59);
2338        let mut fresh_store = MemoryStore::new();
2339        replay_with_snapshot_into(&log_after, Some(&snap), &mut fresh_store).unwrap();
2340        for (i, id) in ids.iter().enumerate() {
2341            assert_eq!(
2342                fresh_store.load("row", *id),
2343                Some(json!({"i": i as u64})),
2344                "record {i} debería estar tras snapshot+replay"
2345            );
2346        }
2347
2348        // 5. Idempotencia: segunda corrida del compact con threshold=1
2349        //    no hace nada — queda 1 anchor entry, no hay nada útil
2350        //    que dropear.
2351        let mut log_reopened = EventLog::open(&path).unwrap();
2352        let res2 =
2353            maybe_compact_log(&mut log_reopened, &snap_path, &fresh_store, 1).unwrap();
2354        assert!(
2355            res2.is_none(),
2356            "post-compact: 1 anchor entry, segundo compact debe ser no-op"
2357        );
2358
2359        let _ = std::fs::remove_file(&path);
2360        let _ = std::fs::remove_file(&snap_path);
2361    }
2362
2363    #[test]
2364    fn resolve_param_strict_number_parses_i64() {
2365        let s = spec("qty", FieldKind::Number, true);
2366        let v = resolve_param_value("qty", "42", Some(&s)).unwrap();
2367        assert_eq!(v, json!(42));
2368    }
2369
2370    #[test]
2371    fn resolve_param_strict_boolean_rejects_non_boolean() {
2372        let s = spec("active", FieldKind::Boolean, true);
2373        let err = resolve_param_value("active", "abc", Some(&s)).unwrap_err();
2374        assert!(err.contains("active"), "msg debe mencionar el label: {err}");
2375        assert!(
2376            err.to_lowercase().contains("bool") || err.contains("'abc'"),
2377            "msg debe explicar el tipo o value: {err}"
2378        );
2379    }
2380
2381    #[test]
2382    fn resolve_param_strict_number_rejects_garbage() {
2383        let s = spec("qty", FieldKind::Number, true);
2384        let err = resolve_param_value("qty", "abc", Some(&s)).unwrap_err();
2385        assert!(err.contains("qty"), "msg debe mencionar el label: {err}");
2386    }
2387
2388    #[test]
2389    fn resolve_param_required_empty_rejected() {
2390        let s = spec("name", FieldKind::Text, true);
2391        let err = resolve_param_value("name", "   ", Some(&s)).unwrap_err();
2392        assert!(
2393            err.contains("obligatorio"),
2394            "msg debe decir obligatorio: {err}"
2395        );
2396    }
2397
2398    #[test]
2399    fn resolve_param_optional_empty_returns_null() {
2400        let s = spec("notes", FieldKind::Text, false);
2401        let v = resolve_param_value("notes", "", Some(&s)).unwrap();
2402        assert_eq!(v, json!(null));
2403    }
2404
2405    #[test]
2406    fn resolve_param_no_spec_falls_back_to_infer() {
2407        // Sin FieldSpec (módulo mal-formado): infer_param_value
2408        // se usa como red de seguridad.
2409        let v = resolve_param_value("foo", "42", None).unwrap();
2410        assert_eq!(v, json!(42));
2411        let v = resolve_param_value("foo", "true", None).unwrap();
2412        assert_eq!(v, json!(true));
2413        let v = resolve_param_value("foo", "x", None).unwrap();
2414        assert_eq!(v, json!("x"));
2415    }
2416
2417    #[test]
2418    fn parse_field_entity_ref_accepts_valid_uuid() {
2419        let id = Uuid::new_v4();
2420        let v = parse_field_value(FieldKind::EntityRef, &id.to_string()).unwrap();
2421        assert_eq!(v, json!(id.to_string()));
2422    }
2423
2424    #[test]
2425    fn parse_field_entity_ref_trims_whitespace() {
2426        // El selector clickable garantiza el value pelado; este check
2427        // protege contra paste manual con espacios accidentales.
2428        let id = Uuid::new_v4();
2429        let padded = format!("  {id}\n");
2430        let v = parse_field_value(FieldKind::EntityRef, &padded).unwrap();
2431        assert_eq!(v, json!(id.to_string()), "debería trimear y devolver el UUID limpio");
2432    }
2433
2434    #[test]
2435    fn parse_field_entity_ref_rejects_non_uuid() {
2436        let err = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap_err();
2437        assert!(err.contains("'abc-123'"), "msg debe mencionar el value: {err}");
2438        assert!(
2439            err.contains("UUID") || err.contains("uuid"),
2440            "msg debe mencionar UUID: {err}"
2441        );
2442    }
2443
2444    #[test]
2445    fn parse_field_entity_ref_rejects_empty_string() {
2446        // Un EntityRef vacío al submit: el form lo manda como ""
2447        // si el usuario no clickeó nada. Debería rebotar acá en
2448        // lugar de loguear "" como un record id basura.
2449        let err = parse_field_value(FieldKind::EntityRef, "").unwrap_err();
2450        assert!(err.contains("UUID"), "msg debe mencionar UUID: {err}");
2451    }
2452
2453    #[test]
2454    fn resolve_param_strict_entity_ref_propagates_error() {
2455        // Sanity: resolve_param_value con kind=EntityRef invoca
2456        // parse_field_value y propaga el error de UUID inválido,
2457        // con el label del FieldSpec en el mensaje.
2458        let s = spec("stock_ref", FieldKind::EntityRef, true);
2459        let err = resolve_param_value("stock_ref", "not-a-uuid", Some(&s)).unwrap_err();
2460        assert!(err.contains("stock_ref"), "msg debe incluir label: {err}");
2461        assert!(err.contains("UUID"), "msg debe mencionar UUID: {err}");
2462    }
2463
2464    #[test]
2465    fn human_label_for_record_prefers_name_over_id() {
2466        let id = Uuid::new_v4();
2467        let with_name = json!({"name": "Acme S.A.", "email": "x@y.z"});
2468        let label = human_label_for_record(&with_name, &id);
2469        assert!(label.starts_with("Acme S.A."), "got: {label}");
2470        assert!(label.contains(&short_uuid(&id)));
2471    }
2472
2473    #[test]
2474    fn human_label_falls_back_through_label_title_sku() {
2475        let id = Uuid::new_v4();
2476        let only_label = json!({"label": "X"});
2477        assert!(human_label_for_record(&only_label, &id).starts_with("X "));
2478        let only_title = json!({"title": "Y"});
2479        assert!(human_label_for_record(&only_title, &id).starts_with("Y "));
2480        let only_sku = json!({"sku": "Z-001"});
2481        assert!(human_label_for_record(&only_sku, &id).starts_with("Z-001 "));
2482        let only_sku_id = json!({"sku_id": "W-002"});
2483        assert!(human_label_for_record(&only_sku_id, &id).starts_with("W-002 "));
2484    }
2485
2486    #[test]
2487    fn human_label_falls_back_to_id_when_no_known_keys() {
2488        let id = Uuid::new_v4();
2489        let v = json!({"weird_field": "val"});
2490        assert_eq!(human_label_for_record(&v, &id), short_uuid(&id));
2491    }
2492
2493    #[test]
2494    fn render_value_handles_null_string_bool() {
2495        assert_eq!(render_value(None), "");
2496        assert_eq!(render_value(Some(&json!(null))), "");
2497        assert_eq!(render_value(Some(&json!("x"))), "x");
2498        assert_eq!(render_value(Some(&json!(true))), "✓");
2499        assert_eq!(render_value(Some(&json!(false))), "✗");
2500        assert_eq!(render_value(Some(&json!(42))), "42");
2501    }
2502
2503    #[test]
2504    fn value_to_input_text_inverse_of_parse() {
2505        // text → text
2506        assert_eq!(value_to_input_text(&json!("hola")), "hola");
2507        // bool → "true"/"false" (parse_field_value lo acepta)
2508        assert_eq!(value_to_input_text(&json!(true)), "true");
2509        assert_eq!(value_to_input_text(&json!(false)), "false");
2510        // number → string
2511        assert_eq!(value_to_input_text(&json!(42)), "42");
2512        assert_eq!(value_to_input_text(&json!(3.14)), "3.14");
2513        // null → ""
2514        assert_eq!(value_to_input_text(&json!(null)), "");
2515    }
2516
2517    #[test]
2518    fn value_to_input_then_parse_round_trip() {
2519        // El round-trip es la propiedad fundamental: edit → text →
2520        // parse → mismo Value (modulo casts numéricos).
2521        let cases = vec![
2522            (FieldKind::Text, json!("hola")),
2523            (FieldKind::Boolean, json!(true)),
2524            (FieldKind::Boolean, json!(false)),
2525            (FieldKind::Number, json!(42)),
2526        ];
2527        for (kind, original) in cases {
2528            let text = value_to_input_text(&original);
2529            let parsed = parse_field_value(kind, &text).unwrap();
2530            assert_eq!(
2531                parsed, original,
2532                "round-trip text→parse falló para {original:?}"
2533            );
2534        }
2535    }
2536
2537    /// E2E mínimo del WAL: armamos un log a mano con dos seeds,
2538    /// abrimos con `EventLog::open` + `replay_into`, y verificamos
2539    /// que el `MemoryStore` queda con esos records aplicados.
2540    /// Esto reproduce el flujo del startup de `MetaUi::new` sin
2541    /// necesitar GPUI.
2542    #[test]
2543    fn event_log_replay_restores_memory_store() {
2544        use nakui_core::event_log::{replay_into, EventLog, LogEntry};
2545        use nakui_core::store::{MemoryStore, Store};
2546
2547        let tmp = tempfile::NamedTempFile::new().unwrap();
2548        let path = tmp.path().to_path_buf();
2549        // Cerramos el handle de tempfile pero conservamos el path
2550        // para que EventLog pueda re-abrir.
2551        drop(tmp);
2552
2553        // Escribimos dos seeds via EventLog::append.
2554        let id_a = Uuid::new_v4();
2555        let id_b = Uuid::new_v4();
2556        {
2557            let mut log = EventLog::open(&path).unwrap();
2558            log.append(LogEntry::Seed {
2559                seq: 0,
2560                entity: "customer".into(),
2561                id: id_a,
2562                data: json!({"name": "Acme"}),
2563                schema_hash: None,
2564            })
2565            .unwrap();
2566            log.append(LogEntry::Seed {
2567                seq: 1,
2568                entity: "customer".into(),
2569                id: id_b,
2570                data: json!({"name": "Globex"}),
2571                schema_hash: None,
2572            })
2573            .unwrap();
2574        }
2575
2576        // Re-abrir + replay (simula startup de MetaUi).
2577        let log = EventLog::open(&path).unwrap();
2578        assert_eq!(log.next_seq(), 2, "next_seq debe ser 2 tras 2 entries");
2579        let mut store = MemoryStore::new();
2580        replay_into(&log, &mut store).unwrap();
2581
2582        // Verificar que ambos records están en el store.
2583        assert_eq!(
2584            store.load("customer", id_a),
2585            Some(json!({"name": "Acme"})),
2586            "Acme debería estar tras replay"
2587        );
2588        assert_eq!(
2589            store.load("customer", id_b),
2590            Some(json!({"name": "Globex"})),
2591            "Globex debería estar tras replay"
2592        );
2593
2594        let _ = std::fs::remove_file(&path);
2595    }
2596
2597    /// E2E del Action::Morphism: carga el módulo nakui-core real
2598    /// `sales` (que vive en `crates/modules/nakui/modules/sales`),
2599    /// arma store + log, y ejecuta el morphism `vender` vía
2600    /// `execute_and_log_with_recovery` (la misma función que
2601    /// `commit_morphism` invoca). Verifica que las ops esperadas
2602    /// se loguean y aplican (stock decrementa, caja incrementa).
2603    ///
2604    /// Reproduce el flujo del runtime sin necesitar GPUI.
2605    #[test]
2606    fn morphism_pipeline_executes_real_sales_vender() {
2607        use nakui_core::event_log::{execute_and_log_with_recovery, EventLog};
2608        use nakui_core::executor::Executor;
2609        use nakui_core::store::{MemoryStore, Store};
2610
2611        // Path al módulo real (3 dirs arriba: crates/apps/nakui-ui/
2612        // → crates/modules/nakui/modules/sales).
2613        let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
2614        let sales_dir = here
2615            .join("../../..")
2616            .join("crates/modules/nakui/modules/sales");
2617        if !sales_dir.join("nsmc.json").exists() {
2618            // Si el módulo demo no está donde esperamos, skipeamos
2619            // — no es regresión del feature, es ambiente.
2620            eprintln!(
2621                "skip: sales module no encontrado en {}",
2622                sales_dir.display()
2623            );
2624            return;
2625        }
2626
2627        let executor = Executor::load_module(&sales_dir).expect("cargar sales executor");
2628
2629        let mut store = MemoryStore::new();
2630        let stock_id = Uuid::new_v4();
2631        let caja_id = Uuid::new_v4();
2632        store.seed(
2633            "Stock",
2634            stock_id,
2635            json!({
2636                "id": stock_id.to_string(),
2637                "sku_id": "test-sku",
2638                "ubicacion": "loc-1",
2639                "cantidad": 100_i64,
2640            }),
2641        );
2642        store.seed(
2643            "Caja",
2644            caja_id,
2645            json!({
2646                "id": caja_id.to_string(),
2647                "name": "Caja Test",
2648                "currency": "USD",
2649                "saldo": 1_000_000_i64,
2650            }),
2651        );
2652
2653        let tmp = tempfile::NamedTempFile::new().unwrap();
2654        let log_path = tmp.path().to_path_buf();
2655        drop(tmp);
2656        let mut log = EventLog::open(&log_path).unwrap();
2657
2658        let venta_id = Uuid::new_v4();
2659        let inputs = vec![("stock", stock_id), ("caja", caja_id)];
2660        let params = json!({
2661            "venta_id": venta_id.to_string(),
2662            "cantidad": 5_i64,
2663            "precio_unitario": 200_i64,
2664            "timestamp": "2026-05-04T10:00:00Z",
2665        });
2666
2667        let ops = execute_and_log_with_recovery(
2668            &executor,
2669            &mut store,
2670            &mut log,
2671            "vender",
2672            &inputs,
2673            params,
2674        )
2675        .expect("morphism vender debe ejecutar limpio");
2676
2677        assert!(!ops.is_empty(), "vender debería producir ops");
2678
2679        // Sanity post-condiciones esperadas del manifest sales:
2680        // - stock.cantidad bajó (vendimos 5).
2681        let stock_after = store
2682            .load("Stock", stock_id)
2683            .and_then(|v| v.get("cantidad").and_then(Value::as_i64))
2684            .expect("stock con cantidad");
2685        assert_eq!(stock_after, 95, "stock debería bajar de 100 a 95");
2686        // - caja.saldo subió (cobramos 5*200 = 1000 sobre saldo
2687        // inicial 1_000_000).
2688        let caja_after = store
2689            .load("Caja", caja_id)
2690            .and_then(|v| v.get("saldo").and_then(Value::as_i64))
2691            .expect("caja con saldo");
2692        assert_eq!(caja_after, 1_001_000, "caja debería subir 5*200=1000");
2693
2694        let _ = std::fs::remove_file(&log_path);
2695    }
2696
2697    /// E2E del ciclo CRUD vía log:
2698    /// 1. Seed un record.
2699    /// 2. Morphism con Set ops (edit) — sobreescribe campos.
2700    /// 3. Morphism con Delete op — borra el record.
2701    /// 4. Replay desde cero: el store queda como tras el delete (vacío).
2702    #[test]
2703    fn event_log_replay_handles_full_crud_cycle() {
2704        use nakui_core::delta::{FieldOp, FieldPath};
2705        use nakui_core::event_log::{replay_into, EventLog, LogEntry};
2706        use nakui_core::store::{MemoryStore, Store};
2707
2708        let tmp = tempfile::NamedTempFile::new().unwrap();
2709        let path = tmp.path().to_path_buf();
2710        drop(tmp);
2711
2712        let id = Uuid::new_v4();
2713
2714        // 1. Escribir 3 entries: seed, edit, delete.
2715        {
2716            let mut log = EventLog::open(&path).unwrap();
2717            log.append(LogEntry::Seed {
2718                seq: 0,
2719                entity: "customer".into(),
2720                id,
2721                data: json!({"name": "Acme", "active": true}),
2722                schema_hash: None,
2723            })
2724            .unwrap();
2725            log.append(LogEntry::Morphism {
2726                seq: 1,
2727                morphism: "ui.edit_record".into(),
2728                inputs: Default::default(),
2729                params: json!({}),
2730                ops: vec![
2731                    FieldOp::Set {
2732                        path: FieldPath {
2733                            entity: "customer".into(),
2734                            id,
2735                            field: "name".into(),
2736                        },
2737                        value: json!("Acme S.A."),
2738                    },
2739                    FieldOp::Set {
2740                        path: FieldPath {
2741                            entity: "customer".into(),
2742                            id,
2743                            field: "active".into(),
2744                        },
2745                        value: json!(false),
2746                    },
2747                ],
2748                schema_hash: None,
2749            })
2750            .unwrap();
2751            log.append(LogEntry::Morphism {
2752                seq: 2,
2753                morphism: "ui.delete_record".into(),
2754                inputs: Default::default(),
2755                params: json!({}),
2756                ops: vec![FieldOp::Delete {
2757                    entity: "customer".into(),
2758                    id,
2759                }],
2760                schema_hash: None,
2761            })
2762            .unwrap();
2763        }
2764
2765        // 2. Replay desde cero — debe terminar con store vacío
2766        // (el delete fue el último op).
2767        let log = EventLog::open(&path).unwrap();
2768        let mut store = MemoryStore::new();
2769        replay_into(&log, &mut store).unwrap();
2770        assert_eq!(
2771            store.load("customer", id),
2772            None,
2773            "tras seed + edit + delete, el record no debería existir"
2774        );
2775
2776        // 3. Sanity: si paramos en seq=1 (snapshot post-edit), el
2777        // record debería tener los valores editados.
2778        // (Construimos un store fresh y aplicamos sólo seq 0 y 1
2779        // a mano para verificar.)
2780        let mut store_partial = MemoryStore::new();
2781        store_partial.seed("customer", id, json!({"name": "Acme", "active": true}));
2782        store_partial
2783            .apply(&[
2784                FieldOp::Set {
2785                    path: FieldPath {
2786                        entity: "customer".into(),
2787                        id,
2788                        field: "name".into(),
2789                    },
2790                    value: json!("Acme S.A."),
2791                },
2792                FieldOp::Set {
2793                    path: FieldPath {
2794                        entity: "customer".into(),
2795                        id,
2796                        field: "active".into(),
2797                    },
2798                    value: json!(false),
2799                },
2800            ])
2801            .unwrap();
2802        assert_eq!(
2803            store_partial.load("customer", id),
2804            Some(json!({"name": "Acme S.A.", "active": false})),
2805            "tras seed + edit, el record debería tener los nuevos valores"
2806        );
2807
2808        let _ = std::fs::remove_file(&path);
2809    }
2810}