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