gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definitions

⎇ 932e7464d7574f32694619763f4561f17b63e5e7 crates/apps/nakui-ui/src/main.rs Rust · 1569 lines · 56.4 KB History Who touched what raw

feat(nakui-ui): Action::Morphism wired al pipeline real (compute -> log -> apply) 932e7464d · 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    Render, SharedString, Window, WindowBounds, WindowOptions,
32};
33use nakui_core::delta::{FieldOp, FieldPath};
34use nakui_core::event_log::{execute_and_log_with_recovery, replay_into, EventLog, LogEntry};
35use nakui_core::executor::Executor;
36use nakui_core::store::{MemoryStore, Store};
37use nakui_ui_schema::{
38    Action, FieldKind, FieldSpec, FormView, ListView, Module, View,
39};
40use serde_json::{json, Value};
41use uuid::Uuid;
42use yahweh_theme::Theme;
43use yahweh_widget_text_input::TextInput;
44
45fn main() {
46    Application::new().run(|cx: &mut App| {
47        // El text input pide Theme::global; instalarlo antes de
48        // crear el window evita que panicee.
49        Theme::install_default(cx);
50
51        let bounds = Bounds::centered(None, gpui::size(px(1100.), px(720.)), cx);
52        cx.open_window(
53            WindowOptions {
54                window_bounds: Some(WindowBounds::Windowed(bounds)),
55                titlebar: Some(gpui::TitlebarOptions {
56                    title: Some(SharedString::from("Nakui")),
57                    ..Default::default()
58                }),
59                ..Default::default()
60            },
61            |_w, cx| cx.new(MetaUi::new),
62        )
63        .expect("open window");
64        cx.activate(true);
65    });
66}
67
68/// Estado del runtime.
69struct MetaUi {
70    /// Módulos cargados, ordenados por id.
71    modules: Vec<Module>,
72    /// Store compartido. Mutado por el submit de los forms.
73    store: Arc<Mutex<MemoryStore>>,
74    /// Event log persistente compartido. Cada `seed_entity` se appende
75    /// acá antes de mutar el store (WAL). `None` si la apertura del
76    /// log falló — en ese caso el runtime degrada a in-memory only y
77    /// loggea un toast informativo.
78    event_log: Option<Arc<Mutex<EventLog>>>,
79    /// Executors nakui cargados, indexados por `module.id`. Sólo
80    /// existen los módulos que declaran `nakui_module_dir`. Las
81    /// acciones `Morphism` requieren que el módulo activo tenga
82    /// uno; sin él, despachan un toast informativo.
83    executors: BTreeMap<String, Arc<Executor>>,
84    /// (módulo idx, vista key) actualmente activos.
85    active: Option<(usize, String)>,
86    /// Inputs vivos para el form actual: nombre del campo → TextInput.
87    /// Se reemplaza al cambiar de vista (drop de los anteriores).
88    form_inputs: BTreeMap<String, Entity<TextInput>>,
89    /// Si está set, el próximo render del Form pre-llena los inputs
90    /// con los valores del record indicado, y `commit_seed` emite
91    /// un `LogEntry::Morphism { name: "ui.edit_record", ops: [Set...] }`
92    /// en lugar de un Seed nuevo. Limpia al cambiar de view o tras
93    /// submit exitoso.
94    editing: Option<(String, Uuid)>,
95    /// Mensaje toast al pie (success de submit, error de carga, etc.).
96    toast: Option<SharedString>,
97    /// Si la carga de módulos falló al inicio.
98    load_error: Option<SharedString>,
99}
100
101impl MetaUi {
102    fn new(_cx: &mut Context<Self>) -> Self {
103        let modules_dir = std::env::var("NAKUI_MODULES_DIR")
104            .ok()
105            .map(PathBuf::from)
106            .unwrap_or_else(|| PathBuf::from("nakui-modules"));
107
108        let (modules, mut load_error) =
109            match nakui_ui_schema::load_modules_from_dir(&modules_dir) {
110                Ok(m) => (m, None),
111                Err(e) => (
112                    Vec::new(),
113                    Some(SharedString::from(format!(
114                        "no pude cargar módulos de {}: {e}",
115                        modules_dir.display()
116                    ))),
117                ),
118            };
119
120        // Persistencia: abrir/crear el event log y hacer replay al
121        // store. Path por env `NAKUI_EVENT_LOG`, default
122        // `./nakui-ui-state.jsonl`. Si abrir o replay falla, el
123        // runtime sigue en modo in-memory (sin persistencia) y el
124        // load_error se acumula al banner.
125        let log_path = std::env::var("NAKUI_EVENT_LOG")
126            .map(PathBuf::from)
127            .unwrap_or_else(|_| PathBuf::from("nakui-ui-state.jsonl"));
128        let mut store = MemoryStore::new();
129        let mut initial_toast: Option<SharedString> = None;
130        let event_log = match EventLog::open(&log_path) {
131            Ok(log) => {
132                match replay_into(&log, &mut store) {
133                    Ok(()) => {
134                        let n = log.next_seq();
135                        if n > 0 {
136                            initial_toast = Some(SharedString::from(format!(
137                                "log {} cargado: {n} evento(s) replayed",
138                                log_path.display()
139                            )));
140                        } else {
141                            initial_toast = Some(SharedString::from(format!(
142                                "log nuevo en {}",
143                                log_path.display()
144                            )));
145                        }
146                        Some(Arc::new(Mutex::new(log)))
147                    }
148                    Err(e) => {
149                        let msg = format!(
150                            "replay del log {} falló: {e} — running in-memory",
151                            log_path.display()
152                        );
153                        match &load_error {
154                            Some(prev) => {
155                                load_error = Some(SharedString::from(format!("{prev}; {msg}")));
156                            }
157                            None => load_error = Some(SharedString::from(msg)),
158                        }
159                        None
160                    }
161                }
162            }
163            Err(e) => {
164                let msg = format!(
165                    "abrir log {}: {e} — running in-memory only",
166                    log_path.display()
167                );
168                match &load_error {
169                    Some(prev) => {
170                        load_error = Some(SharedString::from(format!("{prev}; {msg}")));
171                    }
172                    None => load_error = Some(SharedString::from(msg)),
173                }
174                None
175            }
176        };
177
178        // Cargar Executors para los módulos que declararon
179        // `nakui_module_dir`. Resolvemos paths relativos al
180        // directorio del modules (NAKUI_MODULES_DIR/<id>/), no al
181        // pwd. Cualquier error de carga deja la entry afuera y
182        // anota al banner — Action::Morphism queda no-op para ese
183        // módulo pero el resto sigue funcionando.
184        let mut executors: BTreeMap<String, Arc<Executor>> = BTreeMap::new();
185        for m in &modules {
186            let Some(rel) = &m.nakui_module_dir else {
187                continue;
188            };
189            let module_root = modules_dir.join(&m.id);
190            let nakui_dir = if std::path::Path::new(rel).is_absolute() {
191                PathBuf::from(rel)
192            } else {
193                module_root.join(rel)
194            };
195            match Executor::load_module(&nakui_dir) {
196                Ok(exec) => {
197                    executors.insert(m.id.clone(), Arc::new(exec));
198                }
199                Err(e) => {
200                    let msg = format!(
201                        "módulo {}: no pude cargar executor nakui en {}: {e}",
202                        m.id,
203                        nakui_dir.display()
204                    );
205                    match &load_error {
206                        Some(prev) => {
207                            load_error = Some(SharedString::from(format!("{prev}; {msg}")));
208                        }
209                        None => load_error = Some(SharedString::from(msg)),
210                    }
211                }
212            }
213        }
214
215        let active = modules
216            .first()
217            .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
218
219        Self {
220            modules,
221            store: Arc::new(Mutex::new(store)),
222            event_log,
223            executors,
224            active,
225            form_inputs: BTreeMap::new(),
226            editing: None,
227            toast: initial_toast,
228            load_error,
229        }
230    }
231
232    /// Cambia la vista activa. Si la nueva vista es un Form, crea
233    /// `TextInput` entities para cada field. Pre-llena con valores
234    /// del record si hay `editing` para esa entity; si no, usa el
235    /// `default` del schema.
236    /// Drop de los inputs anteriores ocurre al sobreescribir el map.
237    fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
238        self.active = Some((mod_idx, view_key.clone()));
239        self.toast = None;
240        self.form_inputs = BTreeMap::new();
241        if let Some(module) = self.modules.get(mod_idx) {
242            if let Some(View::Form(form)) = module.views.get(&view_key) {
243                // Snapshot del record si estamos editando esta entity.
244                let editing_record: Option<Value> = self.editing.as_ref().and_then(|(e, id)| {
245                    if e == &form.entity {
246                        let store = self.store.lock().ok()?;
247                        store.load(e, *id)
248                    } else {
249                        None
250                    }
251                });
252                for f in &form.fields {
253                    let initial = if let Some(rec) = &editing_record {
254                        rec.get(&f.name)
255                            .map(value_to_input_text)
256                            .unwrap_or_else(|| f.default.clone().unwrap_or_default())
257                    } else {
258                        f.default.clone().unwrap_or_default()
259                    };
260                    let input = cx.new(|cx| TextInput::new(initial, cx));
261                    self.form_inputs.insert(f.name.clone(), input);
262                }
263            } else {
264                // Cambiar a una view que no es Form invalida el editing
265                // pendiente.
266                self.editing = None;
267            }
268        }
269        cx.notify();
270    }
271
272    /// Inicia un edit del record: setea `editing` y abre la primera
273    /// view de tipo Form del módulo (convención: la del schema).
274    fn open_edit(
275        &mut self,
276        mod_idx: usize,
277        entity: String,
278        id: Uuid,
279        cx: &mut Context<Self>,
280    ) {
281        self.editing = Some((entity.clone(), id));
282        let form_view_key = self.modules.get(mod_idx).and_then(|m| {
283            m.views
284                .iter()
285                .find_map(|(key, v)| match v {
286                    View::Form(form) if form.entity == entity => Some(key.clone()),
287                    _ => None,
288                })
289        });
290        match form_view_key {
291            Some(key) => self.select_view(mod_idx, key, cx),
292            None => {
293                self.toast = Some(SharedString::from(format!(
294                    "no hay form view para entity '{entity}' en este módulo"
295                )));
296                self.editing = None;
297                cx.notify();
298            }
299        }
300    }
301
302    /// Borra un record. Emite Morphism con un FieldOp::Delete + lo
303    /// aplica al store via `apply` (no via remove directo, mantiene
304    /// el modelo de "todo cambio post-seed pasa por ops").
305    fn commit_delete(
306        &mut self,
307        entity: &str,
308        id: Uuid,
309    ) -> Result<(), String> {
310        let ops = vec![FieldOp::Delete {
311            entity: entity.to_string(),
312            id,
313        }];
314        if let Some(log_arc) = self.event_log.as_ref() {
315            let mut log = log_arc
316                .lock()
317                .map_err(|_| "log mutex envenenado".to_string())?;
318            let seq = log.next_seq();
319            log.append(LogEntry::Morphism {
320                seq,
321                morphism: "ui.delete_record".into(),
322                inputs: Default::default(),
323                params: json!({ "entity": entity, "id": id.to_string() }),
324                ops: ops.clone(),
325                schema_hash: None,
326            })
327            .map_err(|e| format!("append al log: {e}"))?;
328        }
329        let mut store = self
330            .store
331            .lock()
332            .map_err(|_| "store mutex envenenado".to_string())?;
333        store
334            .apply(&ops)
335            .map_err(|e| format!("apply Delete: {e}"))?;
336        Ok(())
337    }
338
339    /// Aplica una acción (click en menú, botón de form, action de
340    /// list). Mutaciones contra el store ocurren acá.
341    fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
342        let mod_idx = match self.active.as_ref() {
343            Some((i, _)) => *i,
344            None => return,
345        };
346        // Snapshot del editing al entrar — si commit_seed modifica
347        // self.editing antes del toast, el mensaje refleja el modo
348        // correcto.
349        let was_editing = self.editing.is_some();
350        match action {
351            Action::OpenView { view, .. } => {
352                // Salir a otra view cancela el edit pendiente.
353                self.editing = None;
354                self.select_view(mod_idx, view, cx);
355            }
356            Action::SeedEntity { entity, next_view } => {
357                match self.commit_seed(mod_idx, &entity, cx) {
358                    Ok(id) => {
359                        let action_label = if was_editing { "actualizado" } else { "creado" };
360                        self.toast = Some(SharedString::from(format!(
361                            "{action_label} {entity} {}",
362                            short_uuid(&id)
363                        )));
364                        // Limpia editing tras un commit exitoso —
365                        // el record ya está sincronizado.
366                        self.editing = None;
367                        if let Some(v) = next_view {
368                            self.select_view(mod_idx, v, cx);
369                        } else {
370                            for input in self.form_inputs.values() {
371                                input.update(cx, |inp, cx| inp.set_text("", cx));
372                            }
373                        }
374                    }
375                    Err(e) => {
376                        self.toast = Some(SharedString::from(format!("error: {e}")));
377                    }
378                }
379                cx.notify();
380            }
381            Action::Morphism {
382                name,
383                inputs,
384                params,
385                next_view,
386            } => {
387                match self.commit_morphism(mod_idx, &name, &inputs, &params, cx) {
388                    Ok(op_count) => {
389                        self.toast = Some(SharedString::from(format!(
390                            "morphism '{name}' OK ({op_count} op(s) aplicadas)"
391                        )));
392                        if let Some(v) = next_view {
393                            self.select_view(mod_idx, v, cx);
394                        }
395                    }
396                    Err(e) => {
397                        self.toast = Some(SharedString::from(format!(
398                            "morphism '{name}' falló: {e}"
399                        )));
400                    }
401                }
402                cx.notify();
403            }
404        }
405    }
406
407    /// Despacha un morphism al pipeline real de nakui-core: lee
408    /// inputs (UUIDs) + params (Value object) del form, llama
409    /// `execute_and_log_with_recovery`. Devuelve la cantidad de ops
410    /// que el morphism produjo (para feedback).
411    fn commit_morphism(
412        &mut self,
413        mod_idx: usize,
414        morphism: &str,
415        inputs_map: &BTreeMap<String, String>,
416        params_fields: &[String],
417        cx: &mut Context<Self>,
418    ) -> Result<usize, String> {
419        let _ = cx;
420        let module = self
421            .modules
422            .get(mod_idx)
423            .ok_or_else(|| "módulo inválido".to_string())?;
424        let executor = self
425            .executors
426            .get(&module.id)
427            .ok_or_else(|| {
428                format!(
429                    "módulo '{}' no tiene executor nakui (falta nakui_module_dir o falló la carga)",
430                    module.id
431                )
432            })?
433            .clone();
434        let log_arc = self
435            .event_log
436            .as_ref()
437            .ok_or_else(|| "morphism requiere event log activo".to_string())?
438            .clone();
439
440        // Resolver inputs: por cada (role, field_name), parsear el
441        // value del input como Uuid.
442        let mut input_pairs: Vec<(String, Uuid)> = Vec::with_capacity(inputs_map.len());
443        for (role, field_name) in inputs_map {
444            let raw = self
445                .form_inputs
446                .get(field_name)
447                .map(|inp| inp.read(&*cx).text().to_string())
448                .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
449            let id = Uuid::parse_str(raw.trim()).map_err(|_| {
450                format!(
451                    "input '{role}' (field '{field_name}'): '{raw}' no es UUID válido"
452                )
453            })?;
454            input_pairs.push((role.clone(), id));
455        }
456
457        // Resolver params: si la lista está vacía, todos los fields
458        // del form que no estén en `inputs_map` van a params. Si
459        // hay lista, sólo esos.
460        let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect();
461        let mut params_obj = serde_json::Map::new();
462        let field_iter: Vec<String> = if params_fields.is_empty() {
463            self.form_inputs
464                .keys()
465                .filter(|k| !input_field_set.contains(*k))
466                .cloned()
467                .collect()
468        } else {
469            params_fields.to_vec()
470        };
471        for field_name in field_iter {
472            let raw = self
473                .form_inputs
474                .get(&field_name)
475                .map(|inp| inp.read(&*cx).text().to_string())
476                .unwrap_or_default();
477            // Inferencia ligera: número si parsea, bool en
478            // true/false, string en cualquier otro caso. Coherente
479            // con el modelo "el morphism Rhai espera tipos", pero
480            // simple — para casos finos, el caller puede declarar
481            // `kind: Number` en el FieldSpec, y el form lo respeta.
482            let value = infer_param_value(&raw);
483            params_obj.insert(field_name, value);
484        }
485
486        let inputs_ref: Vec<(&str, Uuid)> = input_pairs
487            .iter()
488            .map(|(r, id)| (r.as_str(), *id))
489            .collect();
490
491        let mut log = log_arc
492            .lock()
493            .map_err(|_| "log mutex envenenado".to_string())?;
494        let mut store = self
495            .store
496            .lock()
497            .map_err(|_| "store mutex envenenado".to_string())?;
498
499        let ops = execute_and_log_with_recovery(
500            &executor,
501            &mut *store,
502            &mut *log,
503            morphism,
504            &inputs_ref,
505            Value::Object(params_obj),
506        )
507        .map_err(|e| format!("{e}"))?;
508        Ok(ops.len())
509    }
510
511    /// Construye un Value desde los TextInput vivos y lo seedea al store.
512    fn commit_seed(
513        &mut self,
514        mod_idx: usize,
515        entity: &str,
516        cx: &mut Context<Self>,
517    ) -> Result<Uuid, String> {
518        let module = &self.modules[mod_idx];
519        let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
520            Some((_, view_key)) => match module.views.get(view_key) {
521                Some(View::Form(f)) => f.fields.clone(),
522                _ => return Err("la vista activa no es un Form".into()),
523            },
524            None => return Err("ninguna vista activa".into()),
525        };
526        let mut obj = serde_json::Map::new();
527        for f in &spec_fields {
528            let raw = self
529                .form_inputs
530                .get(&f.name)
531                .map(|input| input.read(cx).text().to_string())
532                .unwrap_or_default();
533            if f.required && raw.trim().is_empty() {
534                return Err(format!("campo '{}' es obligatorio", f.label));
535            }
536            if raw.is_empty() && !f.required {
537                continue;
538            }
539            let value = parse_field_value(f.kind, &raw)
540                .map_err(|e| format!("campo '{}': {e}", f.label))?;
541            obj.insert(f.name.clone(), value);
542        }
543        // Ramificación: si `editing` está set para esta entity, es un
544        // edit de un record existente — emitimos Morphism con un
545        // FieldOp::Set por cada campo del form (sobreescribe). Si no,
546        // es alta nueva — emitimos Seed con UUID fresco.
547        let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
548
549        if let Some((_, id)) = editing_match {
550            // EDIT path: Morphism { ui.edit_record, ops: [Set...] }
551            let ops: Vec<FieldOp> = obj
552                .iter()
553                .map(|(field, value)| FieldOp::Set {
554                    path: FieldPath {
555                        entity: entity.to_string(),
556                        id,
557                        field: field.clone(),
558                    },
559                    value: value.clone(),
560                })
561                .collect();
562
563            if let Some(log_arc) = self.event_log.as_ref() {
564                let mut log = log_arc
565                    .lock()
566                    .map_err(|_| "log mutex envenenado".to_string())?;
567                let seq = log.next_seq();
568                log.append(LogEntry::Morphism {
569                    seq,
570                    morphism: "ui.edit_record".into(),
571                    inputs: Default::default(),
572                    params: json!({
573                        "entity": entity,
574                        "id": id.to_string(),
575                        "fields": Value::Object(obj.clone()),
576                    }),
577                    ops: ops.clone(),
578                    schema_hash: None,
579                })
580                .map_err(|e| format!("append al log: {e}"))?;
581            }
582            let mut store = self
583                .store
584                .lock()
585                .map_err(|_| "store mutex envenenado".to_string())?;
586            store.apply(&ops).map_err(|e| format!("apply Set: {e}"))?;
587            Ok(id)
588        } else {
589            // SEED path: alta nueva.
590            let id = Uuid::new_v4();
591            let data = Value::Object(obj);
592            if let Some(log_arc) = self.event_log.as_ref() {
593                let mut log = log_arc
594                    .lock()
595                    .map_err(|_| "log mutex envenenado".to_string())?;
596                let seq = log.next_seq();
597                log.append(LogEntry::Seed {
598                    seq,
599                    entity: entity.to_string(),
600                    id,
601                    data: data.clone(),
602                    schema_hash: None,
603                })
604                .map_err(|e| format!("append al log: {e}"))?;
605            }
606            let mut store = self
607                .store
608                .lock()
609                .map_err(|_| "store mutex envenenado".to_string())?;
610            store.seed(entity, id, data);
611            Ok(id)
612        }
613    }
614
615    /// Snapshot ordenado de records de una entity.
616    fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
617        let store = match self.store.lock() {
618            Ok(g) => g,
619            Err(_) => return Vec::new(),
620        };
621        let it = match store.iter() {
622            Ok(i) => i,
623            Err(_) => return Vec::new(),
624        };
625        it.filter(|(e, _, _)| e == entity)
626            .map(|(_, id, v)| (id, v))
627            .collect()
628    }
629}
630
631fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
632    match kind {
633        FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
634        FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
635            "true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
636            "" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
637            other => Err(format!("'{other}' no es booleano")),
638        },
639        FieldKind::Number => {
640            if let Ok(i) = raw.parse::<i64>() {
641                Ok(json!(i))
642            } else if let Ok(f) = raw.parse::<f64>() {
643                Ok(json!(f))
644            } else {
645                Err(format!("'{raw}' no es número"))
646            }
647        }
648    }
649}
650
651fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
652    let mut cur = v;
653    for seg in path.split('.') {
654        cur = cur.get(seg)?;
655    }
656    Some(cur)
657}
658
659fn render_value(v: Option<&Value>) -> String {
660    match v {
661        None | Some(Value::Null) => String::new(),
662        Some(Value::String(s)) => s.clone(),
663        Some(Value::Bool(b)) => if *b { "✓" } else { "✗" }.to_string(),
664        Some(Value::Number(n)) => n.to_string(),
665        Some(other) => other.to_string(),
666    }
667}
668
669/// Inferencia de tipo para values pasados como `params` a un
670/// morphism. Usada cuando el form no declara `FieldKind` explícito
671/// (Action::Morphism toma `params: Vec<String>` con sólo los nombres,
672/// no los kinds).
673///
674/// Heurística simple: int → i64, float → f64, "true"/"false" → bool,
675/// resto → string.
676fn infer_param_value(raw: &str) -> Value {
677    if raw.is_empty() {
678        return Value::Null;
679    }
680    if let Ok(i) = raw.parse::<i64>() {
681        return json!(i);
682    }
683    if let Ok(f) = raw.parse::<f64>() {
684        return json!(f);
685    }
686    match raw {
687        "true" => return json!(true),
688        "false" => return json!(false),
689        _ => {}
690    }
691    json!(raw)
692}
693
694/// Conversión inversa a `parse_field_value`: del JSON al texto raw
695/// que un input puede tomar y volver a parsearse igual al submit.
696/// Usado para pre-llenar inputs en modo edit.
697fn value_to_input_text(v: &Value) -> String {
698    match v {
699        Value::Null => String::new(),
700        Value::String(s) => s.clone(),
701        Value::Bool(b) => if *b { "true" } else { "false" }.to_string(),
702        Value::Number(n) => n.to_string(),
703        other => other.to_string(),
704    }
705}
706
707fn short_uuid(id: &Uuid) -> String {
708    id.to_string().chars().take(8).collect()
709}
710
711impl Render for MetaUi {
712    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
713        let bg = gpui::rgb(0x14171c);
714        let panel = gpui::rgb(0x1d2128);
715        let border = gpui::rgb(0x2a2f38);
716        let text = gpui::rgb(0xe6e8ec);
717        let text_dim = gpui::rgb(0x9ba1ad);
718        let accent = gpui::rgb(0x88c0d0);
719        let accent_active = gpui::rgb(0xa3be8c);
720
721        let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
722        let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
723        let toast_div = self.toast.as_ref().map(|t| {
724            div()
725                .px(px(12.))
726                .py(px(6.))
727                .bg(gpui::rgb(0x2d3a2a))
728                .text_color(gpui::rgb(0xc0e0a0))
729                .text_size(px(11.))
730                .child(t.clone())
731        });
732        let error_banner = self.load_error.as_ref().map(|e| {
733            div()
734                .px(px(12.))
735                .py(px(6.))
736                .bg(gpui::rgb(0x4a2020))
737                .text_color(gpui::rgb(0xffd0d0))
738                .text_size(px(11.))
739                .child(e.clone())
740        });
741
742        div()
743            .flex()
744            .flex_col()
745            .size_full()
746            .bg(bg)
747            .when_some(error_banner, |d, b| d.child(b))
748            .child(
749                div()
750                    .flex()
751                    .flex_row()
752                    .flex_grow()
753                    .child(sidebar)
754                    .child(main_panel),
755            )
756            .when_some(toast_div, |d, t| d.child(t))
757    }
758}
759
760impl MetaUi {
761    fn render_sidebar(
762        &self,
763        cx: &mut Context<Self>,
764        panel: gpui::Rgba,
765        border: gpui::Rgba,
766        text: gpui::Rgba,
767        text_dim: gpui::Rgba,
768        accent_active: gpui::Rgba,
769    ) -> gpui::Div {
770        let mut sidebar = div()
771            .w(px(240.))
772            .h_full()
773            .bg(panel)
774            .border_r_1()
775            .border_color(border)
776            .flex()
777            .flex_col();
778
779        sidebar = sidebar.child(
780            div()
781                .px(px(12.))
782                .py(px(10.))
783                .text_color(text)
784                .text_size(px(13.))
785                .child("Nakui"),
786        );
787
788        if self.modules.is_empty() {
789            return sidebar.child(
790                div()
791                    .px(px(12.))
792                    .py(px(8.))
793                    .text_color(text_dim)
794                    .text_size(px(11.))
795                    .child("(no hay módulos cargados)"),
796            );
797        }
798
799        // Snapshot del active para evitar borrow del self adentro de la closure.
800        let active_snapshot = self.active.clone();
801
802        for (mod_idx, m) in self.modules.iter().enumerate() {
803            sidebar = sidebar.child(
804                div()
805                    .px(px(12.))
806                    .py(px(8.))
807                    .border_t_1()
808                    .border_color(border)
809                    .text_color(text)
810                    .text_size(px(12.))
811                    .child(m.label.clone()),
812            );
813
814            for item in &m.menu {
815                let is_active = active_snapshot
816                    .as_ref()
817                    .map(|(i, v)| *i == mod_idx && v == &item.view)
818                    .unwrap_or(false);
819                let label = item
820                    .icon
821                    .as_deref()
822                    .map(|ic| format!("{ic}  {}", item.label))
823                    .unwrap_or_else(|| item.label.clone());
824
825                let view_key = item.view.clone();
826                sidebar = sidebar.child(
827                    div()
828                        .id(SharedString::from(format!(
829                            "menu-{}-{}",
830                            mod_idx, item.view
831                        )))
832                        .px(px(20.))
833                        .py(px(6.))
834                        .text_size(px(12.))
835                        .text_color(if is_active { accent_active } else { text_dim })
836                        .when(is_active, |d| {
837                            d.bg(gpui::rgb(0x232a36)).text_color(text)
838                        })
839                        .hover(|d| d.bg(gpui::rgb(0x1f2630)))
840                        .child(label)
841                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
842                            this.select_view(mod_idx, view_key.clone(), cx);
843                        })),
844                );
845            }
846        }
847        sidebar
848    }
849
850    fn render_main(
851        &mut self,
852        cx: &mut Context<Self>,
853        panel: gpui::Rgba,
854        border: gpui::Rgba,
855        text: gpui::Rgba,
856        text_dim: gpui::Rgba,
857        accent: gpui::Rgba,
858    ) -> gpui::Div {
859        let main = div()
860            .flex_grow()
861            .h_full()
862            .bg(panel)
863            .flex()
864            .flex_col()
865            .p(px(16.));
866
867        let (mod_idx, view_key) = match &self.active {
868            Some(a) => (a.0, a.1.clone()),
869            None => {
870                return main.child(
871                    div()
872                        .text_color(text_dim)
873                        .child("Seleccioná un menú a la izquierda."),
874                );
875            }
876        };
877
878        let view = match self
879            .modules
880            .get(mod_idx)
881            .and_then(|m| m.views.get(&view_key))
882        {
883            Some(v) => v.clone(),
884            None => {
885                return main.child(
886                    div()
887                        .text_color(text_dim)
888                        .child(format!("Vista no encontrada: {view_key}")),
889                );
890            }
891        };
892
893        match view {
894            View::List(lv) => self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent),
895            View::Form(fv) => self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent),
896        }
897    }
898
899    #[allow(clippy::too_many_arguments)]
900    fn render_list(
901        &self,
902        cx: &mut Context<Self>,
903        mut main: gpui::Div,
904        lv: &ListView,
905        mod_idx: usize,
906        border: gpui::Rgba,
907        text: gpui::Rgba,
908        text_dim: gpui::Rgba,
909        accent: gpui::Rgba,
910    ) -> gpui::Div {
911        let mut header = div()
912            .flex()
913            .flex_row()
914            .items_center()
915            .gap(px(12.))
916            .mb(px(12.))
917            .child(
918                div()
919                    .text_color(text)
920                    .text_size(px(18.))
921                    .child(lv.title.clone()),
922            );
923        for (idx, action) in lv.actions.iter().enumerate() {
924            let label = match action {
925                Action::OpenView { label, view } => {
926                    label.clone().unwrap_or_else(|| format!("→ {view}"))
927                }
928                Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
929                Action::Morphism { name, .. } => format!("⚡ {name}"),
930            };
931            let action_clone = action.clone();
932            header = header.child(
933                div()
934                    .id(SharedString::from(format!(
935                        "list-action-{mod_idx}-{idx}"
936                    )))
937                    .px(px(10.))
938                    .py(px(4.))
939                    .bg(gpui::rgb(0x232a36))
940                    .text_color(accent)
941                    .text_size(px(11.))
942                    .rounded(px(3.))
943                    .hover(|d| d.bg(gpui::rgb(0x2c3540)))
944                    .child(label)
945                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
946                        this.apply_action(action_clone.clone(), cx);
947                    })),
948            );
949        }
950        main = main.child(header);
951
952        let rows = self.list_rows(&lv.entity);
953        let total = rows.len();
954
955        let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
956        let mut col_header = div()
957            .flex()
958            .flex_row()
959            .py(px(6.))
960            .border_b_1()
961            .border_color(border)
962            .text_color(text_dim)
963            .text_size(px(11.));
964        for c in &lv.columns {
965            let frac = c.weight / total_weight;
966            col_header = col_header.child(
967                div()
968                    .flex_grow()
969                    .flex_basis(px(100. * frac))
970                    .child(c.label.clone()),
971            );
972        }
973        col_header = col_header
974            .child(div().w(px(80.)).text_color(text_dim).child("id"))
975            .child(div().w(px(70.)).text_color(text_dim).child("acciones"));
976        main = main.child(col_header);
977
978        let entity_name = lv.entity.clone();
979        for (id, value) in &rows {
980            let id_copy = *id;
981            let entity_for_edit = entity_name.clone();
982            let entity_for_delete = entity_name.clone();
983            let mut row = div()
984                .flex()
985                .flex_row()
986                .py(px(6.))
987                .border_b_1()
988                .border_color(gpui::rgb(0x232a36))
989                .text_color(text)
990                .text_size(px(12.));
991            for c in &lv.columns {
992                let frac = c.weight / total_weight;
993                let v = lookup_field(value, &c.field);
994                row = row.child(
995                    div()
996                        .flex_grow()
997                        .flex_basis(px(100. * frac))
998                        .child(render_value(v)),
999                );
1000            }
1001            row = row.child(
1002                div()
1003                    .w(px(80.))
1004                    .text_color(text_dim)
1005                    .text_size(px(11.))
1006                    .child(short_uuid(id)),
1007            );
1008            // Acciones: ✎ edit + ✕ delete por fila.
1009            row = row.child(
1010                div()
1011                    .w(px(70.))
1012                    .flex()
1013                    .flex_row()
1014                    .gap(px(4.))
1015                    .child(
1016                        div()
1017                            .id(SharedString::from(format!(
1018                                "row-edit-{mod_idx}-{id_copy}"
1019                            )))
1020                            .px(px(6.))
1021                            .text_color(accent)
1022                            .text_size(px(13.))
1023                            .hover(|d| d.bg(gpui::rgb(0x2c3540)))
1024                            .child("✎")
1025                            .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1026                                this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
1027                            })),
1028                    )
1029                    .child(
1030                        div()
1031                            .id(SharedString::from(format!(
1032                                "row-del-{mod_idx}-{id_copy}"
1033                            )))
1034                            .px(px(6.))
1035                            .text_color(gpui::rgb(0xd07070))
1036                            .text_size(px(13.))
1037                            .hover(|d| d.bg(gpui::rgb(0x4a2020)))
1038                            .child("✕")
1039                            .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1040                                match this.commit_delete(&entity_for_delete, id_copy) {
1041                                    Ok(()) => {
1042                                        this.toast = Some(SharedString::from(format!(
1043                                            "borrado {entity_for_delete} {}",
1044                                            short_uuid(&id_copy)
1045                                        )));
1046                                    }
1047                                    Err(e) => {
1048                                        this.toast = Some(SharedString::from(format!(
1049                                            "error borrando: {e}"
1050                                        )));
1051                                    }
1052                                }
1053                                cx.notify();
1054                            })),
1055                    ),
1056            );
1057            main = main.child(row);
1058        }
1059
1060        if rows.is_empty() {
1061            main = main.child(
1062                div()
1063                    .py(px(12.))
1064                    .text_color(text_dim)
1065                    .text_size(px(12.))
1066                    .child(format!("(sin {})", lv.entity)),
1067            );
1068        } else {
1069            main = main.child(
1070                div()
1071                    .mt(px(8.))
1072                    .text_color(text_dim)
1073                    .text_size(px(11.))
1074                    .child(format!("{total} fila(s)")),
1075            );
1076        }
1077
1078        main
1079    }
1080
1081    #[allow(clippy::too_many_arguments)]
1082    fn render_form(
1083        &self,
1084        cx: &mut Context<Self>,
1085        mut main: gpui::Div,
1086        fv: &FormView,
1087        mod_idx: usize,
1088        _border: gpui::Rgba,
1089        text: gpui::Rgba,
1090        text_dim: gpui::Rgba,
1091        accent: gpui::Rgba,
1092    ) -> gpui::Div {
1093        // En modo edit, el título refleja eso para que el user no
1094        // se confunda creyendo que hace alta nueva.
1095        let title = match self.editing.as_ref() {
1096            Some((e, id)) if e == &fv.entity => {
1097                format!("Editar {} {}", fv.entity, short_uuid(id))
1098            }
1099            _ => fv.title.clone(),
1100        };
1101        main = main.child(
1102            div()
1103                .text_color(text)
1104                .text_size(px(18.))
1105                .mb(px(12.))
1106                .child(title),
1107        );
1108        for f in &fv.fields {
1109            let label = if f.required {
1110                format!("{} *", f.label)
1111            } else {
1112                f.label.clone()
1113            };
1114
1115            let mut field_box = div().flex().flex_col().mb(px(10.)).child(
1116                div()
1117                    .text_color(text_dim)
1118                    .text_size(px(11.))
1119                    .mb(px(2.))
1120                    .child(label),
1121            );
1122
1123            // Mount del TextInput vivo (creado en select_view).
1124            if let Some(input) = self.form_inputs.get(&f.name) {
1125                field_box = field_box.child(input.clone());
1126            } else {
1127                // No debería pasar — select_view crea inputs por cada
1128                // field. Fallback display estático por seguridad.
1129                field_box = field_box.child(
1130                    div()
1131                        .px(px(8.))
1132                        .py(px(6.))
1133                        .bg(gpui::rgb(0x171a20))
1134                        .text_color(text_dim)
1135                        .child("(input no inicializado)"),
1136                );
1137            }
1138
1139            if let Some(help) = &f.help {
1140                field_box = field_box.child(
1141                    div()
1142                        .mt(px(2.))
1143                        .text_color(text_dim)
1144                        .text_size(px(10.))
1145                        .child(help.clone()),
1146                );
1147            }
1148            main = main.child(field_box);
1149        }
1150
1151        let editing_this = matches!(
1152            self.editing.as_ref(),
1153            Some((e, _)) if e == &fv.entity
1154        );
1155        let submit_label = match &fv.on_submit {
1156            Action::SeedEntity { entity, .. } => {
1157                if editing_this {
1158                    format!("Guardar cambios en {entity}")
1159                } else {
1160                    format!("Crear {entity}")
1161                }
1162            }
1163            Action::Morphism { name, .. } => format!("Ejecutar {name}"),
1164            Action::OpenView { label, view } => {
1165                label.clone().unwrap_or_else(|| format!("Ir a {view}"))
1166            }
1167        };
1168        let submit_action = fv.on_submit.clone();
1169        main = main.child(
1170            div().mt(px(12.)).child(
1171                div()
1172                    .id(SharedString::from(format!("form-submit-{mod_idx}")))
1173                    .px(px(12.))
1174                    .py(px(6.))
1175                    .bg(gpui::rgb(0x2c3540))
1176                    .text_color(accent)
1177                    .text_size(px(12.))
1178                    .rounded(px(3.))
1179                    .hover(|d| d.bg(gpui::rgb(0x3a4555)))
1180                    .child(submit_label)
1181                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1182                        this.apply_action(submit_action.clone(), cx);
1183                    })),
1184            ),
1185        );
1186
1187        main = main.child(
1188            div()
1189                .mt(px(20.))
1190                .text_color(text_dim)
1191                .text_size(px(10.))
1192                .child(
1193                    "Tip: click en el campo para enfocar; Enter no envía (todavía), \
1194                     usá el botón. Backspace borra el último carácter.",
1195                ),
1196        );
1197        main
1198    }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use super::*;
1204
1205    #[test]
1206    fn parse_field_text_returns_string() {
1207        assert_eq!(parse_field_value(FieldKind::Text, "hola").unwrap(), json!("hola"));
1208    }
1209
1210    #[test]
1211    fn parse_field_number_int_then_float() {
1212        assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42));
1213        assert_eq!(parse_field_value(FieldKind::Number, "3.14").unwrap(), json!(3.14));
1214    }
1215
1216    #[test]
1217    fn parse_field_number_invalid_errors() {
1218        assert!(parse_field_value(FieldKind::Number, "not-a-number").is_err());
1219    }
1220
1221    #[test]
1222    fn parse_field_boolean_variants() {
1223        assert_eq!(parse_field_value(FieldKind::Boolean, "true").unwrap(), json!(true));
1224        assert_eq!(parse_field_value(FieldKind::Boolean, "yes").unwrap(), json!(true));
1225        assert_eq!(parse_field_value(FieldKind::Boolean, "false").unwrap(), json!(false));
1226        assert_eq!(parse_field_value(FieldKind::Boolean, "").unwrap(), json!(false));
1227        assert!(parse_field_value(FieldKind::Boolean, "maybe").is_err());
1228    }
1229
1230    #[test]
1231    fn lookup_field_simple_and_nested() {
1232        let v = json!({
1233            "name": "Acme",
1234            "address": { "city": "Bogotá", "country": "CO" }
1235        });
1236        assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
1237        assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
1238        assert!(lookup_field(&v, "missing").is_none());
1239        assert!(lookup_field(&v, "address.zipcode").is_none());
1240    }
1241
1242    #[test]
1243    fn infer_param_value_int_then_float_then_bool_then_string() {
1244        assert_eq!(infer_param_value(""), json!(null));
1245        assert_eq!(infer_param_value("42"), json!(42));
1246        assert_eq!(infer_param_value("3.14"), json!(3.14));
1247        assert_eq!(infer_param_value("true"), json!(true));
1248        assert_eq!(infer_param_value("false"), json!(false));
1249        assert_eq!(infer_param_value("hola"), json!("hola"));
1250    }
1251
1252    #[test]
1253    fn render_value_handles_null_string_bool() {
1254        assert_eq!(render_value(None), "");
1255        assert_eq!(render_value(Some(&json!(null))), "");
1256        assert_eq!(render_value(Some(&json!("x"))), "x");
1257        assert_eq!(render_value(Some(&json!(true))), "✓");
1258        assert_eq!(render_value(Some(&json!(false))), "✗");
1259        assert_eq!(render_value(Some(&json!(42))), "42");
1260    }
1261
1262    #[test]
1263    fn value_to_input_text_inverse_of_parse() {
1264        // text → text
1265        assert_eq!(value_to_input_text(&json!("hola")), "hola");
1266        // bool → "true"/"false" (parse_field_value lo acepta)
1267        assert_eq!(value_to_input_text(&json!(true)), "true");
1268        assert_eq!(value_to_input_text(&json!(false)), "false");
1269        // number → string
1270        assert_eq!(value_to_input_text(&json!(42)), "42");
1271        assert_eq!(value_to_input_text(&json!(3.14)), "3.14");
1272        // null → ""
1273        assert_eq!(value_to_input_text(&json!(null)), "");
1274    }
1275
1276    #[test]
1277    fn value_to_input_then_parse_round_trip() {
1278        // El round-trip es la propiedad fundamental: edit → text →
1279        // parse → mismo Value (modulo casts numéricos).
1280        let cases = vec![
1281            (FieldKind::Text, json!("hola")),
1282            (FieldKind::Boolean, json!(true)),
1283            (FieldKind::Boolean, json!(false)),
1284            (FieldKind::Number, json!(42)),
1285        ];
1286        for (kind, original) in cases {
1287            let text = value_to_input_text(&original);
1288            let parsed = parse_field_value(kind, &text).unwrap();
1289            assert_eq!(
1290                parsed, original,
1291                "round-trip text→parse falló para {original:?}"
1292            );
1293        }
1294    }
1295
1296    /// E2E mínimo del WAL: armamos un log a mano con dos seeds,
1297    /// abrimos con `EventLog::open` + `replay_into`, y verificamos
1298    /// que el `MemoryStore` queda con esos records aplicados.
1299    /// Esto reproduce el flujo del startup de `MetaUi::new` sin
1300    /// necesitar GPUI.
1301    #[test]
1302    fn event_log_replay_restores_memory_store() {
1303        use nakui_core::event_log::{replay_into, EventLog, LogEntry};
1304        use nakui_core::store::{MemoryStore, Store};
1305
1306        let tmp = tempfile::NamedTempFile::new().unwrap();
1307        let path = tmp.path().to_path_buf();
1308        // Cerramos el handle de tempfile pero conservamos el path
1309        // para que EventLog pueda re-abrir.
1310        drop(tmp);
1311
1312        // Escribimos dos seeds via EventLog::append.
1313        let id_a = Uuid::new_v4();
1314        let id_b = Uuid::new_v4();
1315        {
1316            let mut log = EventLog::open(&path).unwrap();
1317            log.append(LogEntry::Seed {
1318                seq: 0,
1319                entity: "customer".into(),
1320                id: id_a,
1321                data: json!({"name": "Acme"}),
1322                schema_hash: None,
1323            })
1324            .unwrap();
1325            log.append(LogEntry::Seed {
1326                seq: 1,
1327                entity: "customer".into(),
1328                id: id_b,
1329                data: json!({"name": "Globex"}),
1330                schema_hash: None,
1331            })
1332            .unwrap();
1333        }
1334
1335        // Re-abrir + replay (simula startup de MetaUi).
1336        let log = EventLog::open(&path).unwrap();
1337        assert_eq!(log.next_seq(), 2, "next_seq debe ser 2 tras 2 entries");
1338        let mut store = MemoryStore::new();
1339        replay_into(&log, &mut store).unwrap();
1340
1341        // Verificar que ambos records están en el store.
1342        assert_eq!(
1343            store.load("customer", id_a),
1344            Some(json!({"name": "Acme"})),
1345            "Acme debería estar tras replay"
1346        );
1347        assert_eq!(
1348            store.load("customer", id_b),
1349            Some(json!({"name": "Globex"})),
1350            "Globex debería estar tras replay"
1351        );
1352
1353        let _ = std::fs::remove_file(&path);
1354    }
1355
1356    /// E2E del Action::Morphism: carga el módulo nakui-core real
1357    /// `sales` (que vive en `crates/modules/nakui/modules/sales`),
1358    /// arma store + log, y ejecuta el morphism `vender` vía
1359    /// `execute_and_log_with_recovery` (la misma función que
1360    /// `commit_morphism` invoca). Verifica que las ops esperadas
1361    /// se loguean y aplican (stock decrementa, caja incrementa).
1362    ///
1363    /// Reproduce el flujo del runtime sin necesitar GPUI.
1364    #[test]
1365    fn morphism_pipeline_executes_real_sales_vender() {
1366        use nakui_core::event_log::{execute_and_log_with_recovery, EventLog};
1367        use nakui_core::executor::Executor;
1368        use nakui_core::store::{MemoryStore, Store};
1369
1370        // Path al módulo real (3 dirs arriba: crates/apps/nakui-ui/
1371        // → crates/modules/nakui/modules/sales).
1372        let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1373        let sales_dir = here
1374            .join("../../..")
1375            .join("crates/modules/nakui/modules/sales");
1376        if !sales_dir.join("nsmc.json").exists() {
1377            // Si el módulo demo no está donde esperamos, skipeamos
1378            // — no es regresión del feature, es ambiente.
1379            eprintln!(
1380                "skip: sales module no encontrado en {}",
1381                sales_dir.display()
1382            );
1383            return;
1384        }
1385
1386        let executor = Executor::load_module(&sales_dir).expect("cargar sales executor");
1387
1388        let mut store = MemoryStore::new();
1389        let stock_id = Uuid::new_v4();
1390        let caja_id = Uuid::new_v4();
1391        store.seed(
1392            "Stock",
1393            stock_id,
1394            json!({
1395                "id": stock_id.to_string(),
1396                "sku_id": "test-sku",
1397                "ubicacion": "loc-1",
1398                "cantidad": 100_i64,
1399            }),
1400        );
1401        store.seed(
1402            "Caja",
1403            caja_id,
1404            json!({
1405                "id": caja_id.to_string(),
1406                "name": "Caja Test",
1407                "currency": "USD",
1408                "saldo": 1_000_000_i64,
1409            }),
1410        );
1411
1412        let tmp = tempfile::NamedTempFile::new().unwrap();
1413        let log_path = tmp.path().to_path_buf();
1414        drop(tmp);
1415        let mut log = EventLog::open(&log_path).unwrap();
1416
1417        let venta_id = Uuid::new_v4();
1418        let inputs = vec![("stock", stock_id), ("caja", caja_id)];
1419        let params = json!({
1420            "venta_id": venta_id.to_string(),
1421            "cantidad": 5_i64,
1422            "precio_unitario": 200_i64,
1423            "timestamp": "2026-05-04T10:00:00Z",
1424        });
1425
1426        let ops = execute_and_log_with_recovery(
1427            &executor,
1428            &mut store,
1429            &mut log,
1430            "vender",
1431            &inputs,
1432            params,
1433        )
1434        .expect("morphism vender debe ejecutar limpio");
1435
1436        assert!(!ops.is_empty(), "vender debería producir ops");
1437
1438        // Sanity post-condiciones esperadas del manifest sales:
1439        // - stock.cantidad bajó (vendimos 5).
1440        let stock_after = store
1441            .load("Stock", stock_id)
1442            .and_then(|v| v.get("cantidad").and_then(Value::as_i64))
1443            .expect("stock con cantidad");
1444        assert_eq!(stock_after, 95, "stock debería bajar de 100 a 95");
1445        // - caja.saldo subió (cobramos 5*200 = 1000 sobre saldo
1446        // inicial 1_000_000).
1447        let caja_after = store
1448            .load("Caja", caja_id)
1449            .and_then(|v| v.get("saldo").and_then(Value::as_i64))
1450            .expect("caja con saldo");
1451        assert_eq!(caja_after, 1_001_000, "caja debería subir 5*200=1000");
1452
1453        let _ = std::fs::remove_file(&log_path);
1454    }
1455
1456    /// E2E del ciclo CRUD vía log:
1457    /// 1. Seed un record.
1458    /// 2. Morphism con Set ops (edit) — sobreescribe campos.
1459    /// 3. Morphism con Delete op — borra el record.
1460    /// 4. Replay desde cero: el store queda como tras el delete (vacío).
1461    #[test]
1462    fn event_log_replay_handles_full_crud_cycle() {
1463        use nakui_core::delta::{FieldOp, FieldPath};
1464        use nakui_core::event_log::{replay_into, EventLog, LogEntry};
1465        use nakui_core::store::{MemoryStore, Store};
1466
1467        let tmp = tempfile::NamedTempFile::new().unwrap();
1468        let path = tmp.path().to_path_buf();
1469        drop(tmp);
1470
1471        let id = Uuid::new_v4();
1472
1473        // 1. Escribir 3 entries: seed, edit, delete.
1474        {
1475            let mut log = EventLog::open(&path).unwrap();
1476            log.append(LogEntry::Seed {
1477                seq: 0,
1478                entity: "customer".into(),
1479                id,
1480                data: json!({"name": "Acme", "active": true}),
1481                schema_hash: None,
1482            })
1483            .unwrap();
1484            log.append(LogEntry::Morphism {
1485                seq: 1,
1486                morphism: "ui.edit_record".into(),
1487                inputs: Default::default(),
1488                params: json!({}),
1489                ops: vec![
1490                    FieldOp::Set {
1491                        path: FieldPath {
1492                            entity: "customer".into(),
1493                            id,
1494                            field: "name".into(),
1495                        },
1496                        value: json!("Acme S.A."),
1497                    },
1498                    FieldOp::Set {
1499                        path: FieldPath {
1500                            entity: "customer".into(),
1501                            id,
1502                            field: "active".into(),
1503                        },
1504                        value: json!(false),
1505                    },
1506                ],
1507                schema_hash: None,
1508            })
1509            .unwrap();
1510            log.append(LogEntry::Morphism {
1511                seq: 2,
1512                morphism: "ui.delete_record".into(),
1513                inputs: Default::default(),
1514                params: json!({}),
1515                ops: vec![FieldOp::Delete {
1516                    entity: "customer".into(),
1517                    id,
1518                }],
1519                schema_hash: None,
1520            })
1521            .unwrap();
1522        }
1523
1524        // 2. Replay desde cero — debe terminar con store vacío
1525        // (el delete fue el último op).
1526        let log = EventLog::open(&path).unwrap();
1527        let mut store = MemoryStore::new();
1528        replay_into(&log, &mut store).unwrap();
1529        assert_eq!(
1530            store.load("customer", id),
1531            None,
1532            "tras seed + edit + delete, el record no debería existir"
1533        );
1534
1535        // 3. Sanity: si paramos en seq=1 (snapshot post-edit), el
1536        // record debería tener los valores editados.
1537        // (Construimos un store fresh y aplicamos sólo seq 0 y 1
1538        // a mano para verificar.)
1539        let mut store_partial = MemoryStore::new();
1540        store_partial.seed("customer", id, json!({"name": "Acme", "active": true}));
1541        store_partial
1542            .apply(&[
1543                FieldOp::Set {
1544                    path: FieldPath {
1545                        entity: "customer".into(),
1546                        id,
1547                        field: "name".into(),
1548                    },
1549                    value: json!("Acme S.A."),
1550                },
1551                FieldOp::Set {
1552                    path: FieldPath {
1553                        entity: "customer".into(),
1554                        id,
1555                        field: "active".into(),
1556                    },
1557                    value: json!(false),
1558                },
1559            ])
1560            .unwrap();
1561        assert_eq!(
1562            store_partial.load("customer", id),
1563            Some(json!({"name": "Acme S.A.", "active": false})),
1564            "tras seed + edit, el record debería tener los nuevos valores"
1565        );
1566
1567        let _ = std::fs::remove_file(&path);
1568    }
1569}