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