gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198629 Definitions

⎇ 61044844980dd4f8505d9ba94600d9b92a85423e crates/apps/nakui-ui/src/main.rs Rust · 2399 lines · 90.6 KB History Who touched what raw

refactor(yahweh): Fase 2 — extraer helpers puros a yahweh-meta-runtime 610448449 · Sergio ·

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