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