gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definiciones

⎇ fc727266668baca3ad8d9bc6ac4c09ee62b360b3 crates/apps/nakui-ui/src/main.rs Rust · 1723 líneas · 62.2 KB Historia Quién tocó qué crudo

feat(nakui-ui): FieldKind::EntityRef — selector clickable de records existentes fc7272666 · Sergio ·

1//! `nakui-ui` — runtime GPUI de la metainterfaz Nakui.
2//!
3//! Carga módulos desde un directorio (cada módulo = un
4//! `module.json`), monta sidebar con sus menús, y renderea la vista
5//! activa en el panel principal:
6//!
7//! - **List**: tabla de instancias del entity. Botones de acción en
8//!   el header (típicamente "Nuevo" → form).
9//! - **Form**: campos editables (con `yahweh-widget-text-input` para
10//!   teclado real); al submit, escribe al `MemoryStore` in-process
11//!   via `seed_entity` (alta directa) o por morphism (TODO en este
12//!   iter).
13//!
14//! Todo el storage es in-memory por ahora — el escenario "save to
15//! disk" se materializa cuando el daemon Nakui exista. La
16//! arquitectura permite swap sin tocar la UI.
17//!
18//! ## Uso
19//!
20//! ```sh
21//! NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
22//! # default sin env: ./nakui-modules en pwd.
23//! ```
24
25use std::collections::BTreeMap;
26use std::path::PathBuf;
27use std::sync::{Arc, Mutex};
28
29use gpui::{
30    div, prelude::*, px, App, Application, Bounds, ClickEvent, Context, Entity, IntoElement,
31    Render, SharedString, Window, WindowBounds, WindowOptions,
32};
33use nakui_core::delta::{FieldOp, FieldPath};
34use nakui_core::event_log::{execute_and_log_with_recovery, replay_into, EventLog, LogEntry};
35use nakui_core::executor::Executor;
36use nakui_core::store::{MemoryStore, Store};
37use nakui_ui_schema::{
38    Action, FieldKind, FieldSpec, FormView, ListView, Module, View,
39};
40use serde_json::{json, Value};
41use uuid::Uuid;
42use yahweh_theme::Theme;
43use yahweh_widget_text_input::TextInput;
44
45fn main() {
46    Application::new().run(|cx: &mut App| {
47        // El text input pide Theme::global; instalarlo antes de
48        // crear el window evita que panicee.
49        Theme::install_default(cx);
50
51        let bounds = Bounds::centered(None, gpui::size(px(1100.), px(720.)), cx);
52        cx.open_window(
53            WindowOptions {
54                window_bounds: Some(WindowBounds::Windowed(bounds)),
55                titlebar: Some(gpui::TitlebarOptions {
56                    title: Some(SharedString::from("Nakui")),
57                    ..Default::default()
58                }),
59                ..Default::default()
60            },
61            |_w, cx| cx.new(MetaUi::new),
62        )
63        .expect("open window");
64        cx.activate(true);
65    });
66}
67
68/// Estado del runtime.
69struct MetaUi {
70    /// Módulos cargados, ordenados por id.
71    modules: Vec<Module>,
72    /// Store compartido. Mutado por el submit de los forms.
73    store: Arc<Mutex<MemoryStore>>,
74    /// Event log persistente compartido. Cada `seed_entity` se appende
75    /// acá antes de mutar el store (WAL). `None` si la apertura del
76    /// log falló — en ese caso el runtime degrada a in-memory only y
77    /// loggea un toast informativo.
78    event_log: Option<Arc<Mutex<EventLog>>>,
79    /// Executors nakui cargados, indexados por `module.id`. Sólo
80    /// existen los módulos que declaran `nakui_module_dir`. Las
81    /// acciones `Morphism` requieren que el módulo activo tenga
82    /// uno; sin él, despachan un toast informativo.
83    executors: BTreeMap<String, Arc<Executor>>,
84    /// (módulo idx, vista key) actualmente activos.
85    active: Option<(usize, String)>,
86    /// Inputs vivos para el form actual: nombre del campo → TextInput.
87    /// Se reemplaza al cambiar de vista (drop de los anteriores).
88    form_inputs: BTreeMap<String, Entity<TextInput>>,
89    /// Si está set, el próximo render del Form pre-llena los inputs
90    /// con los valores del record indicado, y `commit_seed` emite
91    /// un `LogEntry::Morphism { name: "ui.edit_record", ops: [Set...] }`
92    /// en lugar de un Seed nuevo. Limpia al cambiar de view o tras
93    /// submit exitoso.
94    editing: Option<(String, Uuid)>,
95    /// Mensaje toast al pie (success de submit, error de carga, etc.).
96    toast: Option<SharedString>,
97    /// Si la carga de módulos falló al inicio.
98    load_error: Option<SharedString>,
99}
100
101impl MetaUi {
102    fn new(_cx: &mut Context<Self>) -> Self {
103        let modules_dir = std::env::var("NAKUI_MODULES_DIR")
104            .ok()
105            .map(PathBuf::from)
106            .unwrap_or_else(|| PathBuf::from("nakui-modules"));
107
108        let (modules, mut load_error) =
109            match nakui_ui_schema::load_modules_from_dir(&modules_dir) {
110                Ok(m) => (m, None),
111                Err(e) => (
112                    Vec::new(),
113                    Some(SharedString::from(format!(
114                        "no pude cargar módulos de {}: {e}",
115                        modules_dir.display()
116                    ))),
117                ),
118            };
119
120        // Persistencia: abrir/crear el event log y hacer replay al
121        // store. Path por env `NAKUI_EVENT_LOG`, default
122        // `./nakui-ui-state.jsonl`. Si abrir o replay falla, el
123        // runtime sigue en modo in-memory (sin persistencia) y el
124        // load_error se acumula al banner.
125        let log_path = std::env::var("NAKUI_EVENT_LOG")
126            .map(PathBuf::from)
127            .unwrap_or_else(|_| PathBuf::from("nakui-ui-state.jsonl"));
128        let mut store = MemoryStore::new();
129        let mut initial_toast: Option<SharedString> = None;
130        let event_log = match EventLog::open(&log_path) {
131            Ok(log) => {
132                match replay_into(&log, &mut store) {
133                    Ok(()) => {
134                        let n = log.next_seq();
135                        if n > 0 {
136                            initial_toast = Some(SharedString::from(format!(
137                                "log {} cargado: {n} evento(s) replayed",
138                                log_path.display()
139                            )));
140                        } else {
141                            initial_toast = Some(SharedString::from(format!(
142                                "log nuevo en {}",
143                                log_path.display()
144                            )));
145                        }
146                        Some(Arc::new(Mutex::new(log)))
147                    }
148                    Err(e) => {
149                        let msg = format!(
150                            "replay del log {} falló: {e} — running in-memory",
151                            log_path.display()
152                        );
153                        match &load_error {
154                            Some(prev) => {
155                                load_error = Some(SharedString::from(format!("{prev}; {msg}")));
156                            }
157                            None => load_error = Some(SharedString::from(msg)),
158                        }
159                        None
160                    }
161                }
162            }
163            Err(e) => {
164                let msg = format!(
165                    "abrir log {}: {e} — running in-memory only",
166                    log_path.display()
167                );
168                match &load_error {
169                    Some(prev) => {
170                        load_error = Some(SharedString::from(format!("{prev}; {msg}")));
171                    }
172                    None => load_error = Some(SharedString::from(msg)),
173                }
174                None
175            }
176        };
177
178        // Cargar Executors para los módulos que declararon
179        // `nakui_module_dir`. Resolvemos paths relativos al
180        // directorio del modules (NAKUI_MODULES_DIR/<id>/), no al
181        // pwd. Cualquier error de carga deja la entry afuera y
182        // anota al banner — Action::Morphism queda no-op para ese
183        // módulo pero el resto sigue funcionando.
184        let mut executors: BTreeMap<String, Arc<Executor>> = BTreeMap::new();
185        for m in &modules {
186            let Some(rel) = &m.nakui_module_dir else {
187                continue;
188            };
189            let module_root = modules_dir.join(&m.id);
190            let nakui_dir = if std::path::Path::new(rel).is_absolute() {
191                PathBuf::from(rel)
192            } else {
193                module_root.join(rel)
194            };
195            match Executor::load_module(&nakui_dir) {
196                Ok(exec) => {
197                    executors.insert(m.id.clone(), Arc::new(exec));
198                }
199                Err(e) => {
200                    let msg = format!(
201                        "módulo {}: no pude cargar executor nakui en {}: {e}",
202                        m.id,
203                        nakui_dir.display()
204                    );
205                    match &load_error {
206                        Some(prev) => {
207                            load_error = Some(SharedString::from(format!("{prev}; {msg}")));
208                        }
209                        None => load_error = Some(SharedString::from(msg)),
210                    }
211                }
212            }
213        }
214
215        let active = modules
216            .first()
217            .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
218
219        Self {
220            modules,
221            store: Arc::new(Mutex::new(store)),
222            event_log,
223            executors,
224            active,
225            form_inputs: BTreeMap::new(),
226            editing: None,
227            toast: initial_toast,
228            load_error,
229        }
230    }
231
232    /// Cambia la vista activa. Si la nueva vista es un Form, crea
233    /// `TextInput` entities para cada field. Pre-llena con valores
234    /// del record si hay `editing` para esa entity; si no, usa el
235    /// `default` del schema.
236    /// Drop de los inputs anteriores ocurre al sobreescribir el map.
237    fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
238        self.active = Some((mod_idx, view_key.clone()));
239        self.toast = None;
240        self.form_inputs = BTreeMap::new();
241        if let Some(module) = self.modules.get(mod_idx) {
242            if let Some(View::Form(form)) = module.views.get(&view_key) {
243                // Snapshot del record si estamos editando esta entity.
244                let editing_record: Option<Value> = self.editing.as_ref().and_then(|(e, id)| {
245                    if e == &form.entity {
246                        let store = self.store.lock().ok()?;
247                        store.load(e, *id)
248                    } else {
249                        None
250                    }
251                });
252                for f in &form.fields {
253                    let initial = if let Some(rec) = &editing_record {
254                        rec.get(&f.name)
255                            .map(value_to_input_text)
256                            .unwrap_or_else(|| f.default.clone().unwrap_or_default())
257                    } else {
258                        f.default.clone().unwrap_or_default()
259                    };
260                    let input = cx.new(|cx| TextInput::new(initial, cx));
261                    self.form_inputs.insert(f.name.clone(), input);
262                }
263            } else {
264                // Cambiar a una view que no es Form invalida el editing
265                // pendiente.
266                self.editing = None;
267            }
268        }
269        cx.notify();
270    }
271
272    /// Inicia un edit del record: setea `editing` y abre la primera
273    /// view de tipo Form del módulo (convención: la del schema).
274    fn open_edit(
275        &mut self,
276        mod_idx: usize,
277        entity: String,
278        id: Uuid,
279        cx: &mut Context<Self>,
280    ) {
281        self.editing = Some((entity.clone(), id));
282        let form_view_key = self.modules.get(mod_idx).and_then(|m| {
283            m.views
284                .iter()
285                .find_map(|(key, v)| match v {
286                    View::Form(form) if form.entity == entity => Some(key.clone()),
287                    _ => None,
288                })
289        });
290        match form_view_key {
291            Some(key) => self.select_view(mod_idx, key, cx),
292            None => {
293                self.toast = Some(SharedString::from(format!(
294                    "no hay form view para entity '{entity}' en este módulo"
295                )));
296                self.editing = None;
297                cx.notify();
298            }
299        }
300    }
301
302    /// Borra un record. Emite Morphism con un FieldOp::Delete + lo
303    /// aplica al store via `apply` (no via remove directo, mantiene
304    /// el modelo de "todo cambio post-seed pasa por ops").
305    fn commit_delete(
306        &mut self,
307        entity: &str,
308        id: Uuid,
309    ) -> Result<(), String> {
310        let ops = vec![FieldOp::Delete {
311            entity: entity.to_string(),
312            id,
313        }];
314        if let Some(log_arc) = self.event_log.as_ref() {
315            let mut log = log_arc
316                .lock()
317                .map_err(|_| "log mutex envenenado".to_string())?;
318            let seq = log.next_seq();
319            log.append(LogEntry::Morphism {
320                seq,
321                morphism: "ui.delete_record".into(),
322                inputs: Default::default(),
323                params: json!({ "entity": entity, "id": id.to_string() }),
324                ops: ops.clone(),
325                schema_hash: None,
326            })
327            .map_err(|e| format!("append al log: {e}"))?;
328        }
329        let mut store = self
330            .store
331            .lock()
332            .map_err(|_| "store mutex envenenado".to_string())?;
333        store
334            .apply(&ops)
335            .map_err(|e| format!("apply Delete: {e}"))?;
336        Ok(())
337    }
338
339    /// Aplica una acción (click en menú, botón de form, action de
340    /// list). Mutaciones contra el store ocurren acá.
341    fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
342        let mod_idx = match self.active.as_ref() {
343            Some((i, _)) => *i,
344            None => return,
345        };
346        // Snapshot del editing al entrar — si commit_seed modifica
347        // self.editing antes del toast, el mensaje refleja el modo
348        // correcto.
349        let was_editing = self.editing.is_some();
350        match action {
351            Action::OpenView { view, .. } => {
352                // Salir a otra view cancela el edit pendiente.
353                self.editing = None;
354                self.select_view(mod_idx, view, cx);
355            }
356            Action::SeedEntity { entity, next_view } => {
357                match self.commit_seed(mod_idx, &entity, cx) {
358                    Ok(id) => {
359                        let action_label = if was_editing { "actualizado" } else { "creado" };
360                        self.toast = Some(SharedString::from(format!(
361                            "{action_label} {entity} {}",
362                            short_uuid(&id)
363                        )));
364                        // Limpia editing tras un commit exitoso —
365                        // el record ya está sincronizado.
366                        self.editing = None;
367                        if let Some(v) = next_view {
368                            self.select_view(mod_idx, v, cx);
369                        } else {
370                            for input in self.form_inputs.values() {
371                                input.update(cx, |inp, cx| inp.set_text("", cx));
372                            }
373                        }
374                    }
375                    Err(e) => {
376                        self.toast = Some(SharedString::from(format!("error: {e}")));
377                    }
378                }
379                cx.notify();
380            }
381            Action::Morphism {
382                name,
383                inputs,
384                params,
385                next_view,
386            } => {
387                match self.commit_morphism(mod_idx, &name, &inputs, &params, cx) {
388                    Ok(op_count) => {
389                        self.toast = Some(SharedString::from(format!(
390                            "morphism '{name}' OK ({op_count} op(s) aplicadas)"
391                        )));
392                        if let Some(v) = next_view {
393                            self.select_view(mod_idx, v, cx);
394                        }
395                    }
396                    Err(e) => {
397                        self.toast = Some(SharedString::from(format!(
398                            "morphism '{name}' falló: {e}"
399                        )));
400                    }
401                }
402                cx.notify();
403            }
404        }
405    }
406
407    /// Despacha un morphism al pipeline real de nakui-core: lee
408    /// inputs (UUIDs) + params (Value object) del form, llama
409    /// `execute_and_log_with_recovery`. Devuelve la cantidad de ops
410    /// que el morphism produjo (para feedback).
411    fn commit_morphism(
412        &mut self,
413        mod_idx: usize,
414        morphism: &str,
415        inputs_map: &BTreeMap<String, String>,
416        params_fields: &[String],
417        cx: &mut Context<Self>,
418    ) -> Result<usize, String> {
419        let _ = cx;
420        let module = self
421            .modules
422            .get(mod_idx)
423            .ok_or_else(|| "módulo inválido".to_string())?;
424        let executor = self
425            .executors
426            .get(&module.id)
427            .ok_or_else(|| {
428                format!(
429                    "módulo '{}' no tiene executor nakui (falta nakui_module_dir o falló la carga)",
430                    module.id
431                )
432            })?
433            .clone();
434        let log_arc = self
435            .event_log
436            .as_ref()
437            .ok_or_else(|| "morphism requiere event log activo".to_string())?
438            .clone();
439
440        // Resolver inputs: por cada (role, field_name), parsear el
441        // value del input como Uuid.
442        let mut input_pairs: Vec<(String, Uuid)> = Vec::with_capacity(inputs_map.len());
443        for (role, field_name) in inputs_map {
444            let raw = self
445                .form_inputs
446                .get(field_name)
447                .map(|inp| inp.read(&*cx).text().to_string())
448                .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
449            let id = Uuid::parse_str(raw.trim()).map_err(|_| {
450                format!(
451                    "input '{role}' (field '{field_name}'): '{raw}' no es UUID válido"
452                )
453            })?;
454            input_pairs.push((role.clone(), id));
455        }
456
457        // Resolver params: si la lista está vacía, todos los fields
458        // del form que no estén en `inputs_map` van a params. Si
459        // hay lista, sólo esos.
460        let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect();
461        let mut params_obj = serde_json::Map::new();
462        let field_iter: Vec<String> = if params_fields.is_empty() {
463            self.form_inputs
464                .keys()
465                .filter(|k| !input_field_set.contains(*k))
466                .cloned()
467                .collect()
468        } else {
469            params_fields.to_vec()
470        };
471        for field_name in field_iter {
472            let raw = self
473                .form_inputs
474                .get(&field_name)
475                .map(|inp| inp.read(&*cx).text().to_string())
476                .unwrap_or_default();
477            // Inferencia ligera: número si parsea, bool en
478            // true/false, string en cualquier otro caso. Coherente
479            // con el modelo "el morphism Rhai espera tipos", pero
480            // simple — para casos finos, el caller puede declarar
481            // `kind: Number` en el FieldSpec, y el form lo respeta.
482            let value = infer_param_value(&raw);
483            params_obj.insert(field_name, value);
484        }
485
486        let inputs_ref: Vec<(&str, Uuid)> = input_pairs
487            .iter()
488            .map(|(r, id)| (r.as_str(), *id))
489            .collect();
490
491        let mut log = log_arc
492            .lock()
493            .map_err(|_| "log mutex envenenado".to_string())?;
494        let mut store = self
495            .store
496            .lock()
497            .map_err(|_| "store mutex envenenado".to_string())?;
498
499        let ops = execute_and_log_with_recovery(
500            &executor,
501            &mut *store,
502            &mut *log,
503            morphism,
504            &inputs_ref,
505            Value::Object(params_obj),
506        )
507        .map_err(|e| format!("{e}"))?;
508        Ok(ops.len())
509    }
510
511    /// Construye un Value desde los TextInput vivos y lo seedea al store.
512    fn commit_seed(
513        &mut self,
514        mod_idx: usize,
515        entity: &str,
516        cx: &mut Context<Self>,
517    ) -> Result<Uuid, String> {
518        let module = &self.modules[mod_idx];
519        let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
520            Some((_, view_key)) => match module.views.get(view_key) {
521                Some(View::Form(f)) => f.fields.clone(),
522                _ => return Err("la vista activa no es un Form".into()),
523            },
524            None => return Err("ninguna vista activa".into()),
525        };
526        let mut obj = serde_json::Map::new();
527        for f in &spec_fields {
528            let raw = self
529                .form_inputs
530                .get(&f.name)
531                .map(|input| input.read(cx).text().to_string())
532                .unwrap_or_default();
533            if f.required && raw.trim().is_empty() {
534                return Err(format!("campo '{}' es obligatorio", f.label));
535            }
536            if raw.is_empty() && !f.required {
537                continue;
538            }
539            let value = parse_field_value(f.kind, &raw)
540                .map_err(|e| format!("campo '{}': {e}", f.label))?;
541            obj.insert(f.name.clone(), value);
542        }
543        // Ramificación: si `editing` está set para esta entity, es un
544        // edit de un record existente — emitimos Morphism con un
545        // FieldOp::Set por cada campo del form (sobreescribe). Si no,
546        // es alta nueva — emitimos Seed con UUID fresco.
547        let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
548
549        if let Some((_, id)) = editing_match {
550            // EDIT path: Morphism { ui.edit_record, ops: [Set...] }
551            let ops: Vec<FieldOp> = obj
552                .iter()
553                .map(|(field, value)| FieldOp::Set {
554                    path: FieldPath {
555                        entity: entity.to_string(),
556                        id,
557                        field: field.clone(),
558                    },
559                    value: value.clone(),
560                })
561                .collect();
562
563            if let Some(log_arc) = self.event_log.as_ref() {
564                let mut log = log_arc
565                    .lock()
566                    .map_err(|_| "log mutex envenenado".to_string())?;
567                let seq = log.next_seq();
568                log.append(LogEntry::Morphism {
569                    seq,
570                    morphism: "ui.edit_record".into(),
571                    inputs: Default::default(),
572                    params: json!({
573                        "entity": entity,
574                        "id": id.to_string(),
575                        "fields": Value::Object(obj.clone()),
576                    }),
577                    ops: ops.clone(),
578                    schema_hash: None,
579                })
580                .map_err(|e| format!("append al log: {e}"))?;
581            }
582            let mut store = self
583                .store
584                .lock()
585                .map_err(|_| "store mutex envenenado".to_string())?;
586            store.apply(&ops).map_err(|e| format!("apply Set: {e}"))?;
587            Ok(id)
588        } else {
589            // SEED path: alta nueva.
590            let id = Uuid::new_v4();
591            let data = Value::Object(obj);
592            if let Some(log_arc) = self.event_log.as_ref() {
593                let mut log = log_arc
594                    .lock()
595                    .map_err(|_| "log mutex envenenado".to_string())?;
596                let seq = log.next_seq();
597                log.append(LogEntry::Seed {
598                    seq,
599                    entity: entity.to_string(),
600                    id,
601                    data: data.clone(),
602                    schema_hash: None,
603                })
604                .map_err(|e| format!("append al log: {e}"))?;
605            }
606            let mut store = self
607                .store
608                .lock()
609                .map_err(|_| "store mutex envenenado".to_string())?;
610            store.seed(entity, id, data);
611            Ok(id)
612        }
613    }
614
615    /// Snapshot ordenado de records de una entity.
616    fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
617        let store = match self.store.lock() {
618            Ok(g) => g,
619            Err(_) => return Vec::new(),
620        };
621        let it = match store.iter() {
622            Ok(i) => i,
623            Err(_) => return Vec::new(),
624        };
625        it.filter(|(e, _, _)| e == entity)
626            .map(|(_, id, v)| (id, v))
627            .collect()
628    }
629}
630
631fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
632    match kind {
633        FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
634        // 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}