gioser

tawasuyu/brahman

Rust 574TOML 218Markdown 68JSON 198352 Sutichaykuna

⎇ fc727266668baca3ad8d9bc6ac4c09ee62b360b3 crates/modules/nakui/ui-schema/src/lib.rs Rust · 634 siq'ikuna · 21.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

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

1//! Schema declarativo de la metainterfaz Nakui.
2//!
3//! Cada **módulo** Nakui (customers, products, sales, ...) declara
4//! aquí qué menús, vistas, listas y formularios expone, sin escribir
5//! código GPUI ni Rust. El runtime ([`nakui-ui` crate]) lee estos
6//! `module.json` desde un directorio y monta automáticamente la UI
7//! correspondiente.
8//!
9//! ## Filosofía
10//!
11//! - **UI como datos**: agregar un módulo = escribir un JSON. Ningún
12//!   recompile, ningún acoplamiento con el binario del runtime.
13//! - **Persistencia universal**: el runtime conecta cada vista al
14//!   `nakui_core::store::Store` actual; los formularios escriben
15//!   ops por la pipeline normal de Nakui (executor + event log).
16//! - **Schema primero, semántica después**: este crate sólo define
17//!   la *forma* de los manifests. Validación semántica
18//!   (referencias rotas a entities, campos faltantes, etc.) vive en
19//!   el runtime que lo carga, no acá.
20//!
21//! ## Anatomía de un módulo
22//!
23//! ```json
24//! {
25//!   "id": "customers",
26//!   "label": "Clientes",
27//!   "entities": [
28//!     { "name": "customer", "fields": [ ... ] }
29//!   ],
30//!   "menu": [
31//!     { "label": "Listar", "view": "list" },
32//!     { "label": "Nuevo", "view": "form" }
33//!   ],
34//!   "views": {
35//!     "list": { "kind": "list", "entity": "customer", "columns": [...] },
36//!     "form": { "kind": "form", "entity": "customer", "fields": [...] }
37//!   }
38//! }
39//! ```
40
41#![forbid(unsafe_code)]
42#![warn(rust_2018_idioms)]
43
44use std::collections::BTreeMap;
45use std::path::{Path, PathBuf};
46
47use serde::{Deserialize, Serialize};
48use thiserror::Error;
49
50/// Manifiesto de un módulo de la metainterfaz Nakui.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Module {
53    /// Identificador estable. Único dentro del directorio cargado.
54    pub id: String,
55
56    /// Nombre legible para mostrar en el sidebar.
57    pub label: String,
58
59    /// Descripción corta opcional (tooltip / subtítulo).
60    #[serde(default)]
61    pub description: Option<String>,
62
63    /// Entities que el módulo introduce o consume. El runtime las
64    /// usa para validar columns/fields y para inicializar el store
65    /// cuando son nuevas.
66    #[serde(default)]
67    pub entities: Vec<EntitySpec>,
68
69    /// Path a un módulo nakui-core (directorio con `nsmc.json` +
70    /// schemas KCL + scripts Rhai). Cuando está set, el runtime
71    /// carga un `Executor` para ese path y permite que las acciones
72    /// `Morphism { name }` despachen al pipeline real
73    /// (compute → log → apply).
74    ///
75    /// Path resuelto relativo al directorio del `module.json`
76    /// o absoluto.
77    ///
78    /// Si es `None`, las acciones `Morphism` quedan deshabilitadas
79    /// (toast informativo al usuario). Las acciones `SeedEntity`
80    /// siguen funcionando sin esto — son altas administrativas que
81    /// no necesitan validación de manifest.
82    #[serde(default)]
83    pub nakui_module_dir: Option<String>,
84
85    /// Items del menú. Cada uno apunta a una key de `views`. Orden
86    /// importa (es el orden en que se presentan en el sidebar).
87    pub menu: Vec<MenuItem>,
88
89    /// Vistas indexadas por key. Las keys son referenciadas por
90    /// `MenuItem.view` y por `Action::OpenView.view`.
91    pub views: BTreeMap<String, View>,
92}
93
94/// Item del menú lateral.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct MenuItem {
97    pub label: String,
98    /// Key de la vista a abrir. Debe existir en `Module.views`.
99    pub view: String,
100    /// Icono opcional (texto unicode o emoji; el runtime decide
101    /// renderización).
102    #[serde(default)]
103    pub icon: Option<String>,
104}
105
106/// Una vista renderizada en el área principal cuando su menú es seleccionado.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(tag = "kind", rename_all = "snake_case")]
109pub enum View {
110    /// Tabla de instancias de una entity, columnas + acciones por fila.
111    List(ListView),
112    /// Formulario de creación / edición.
113    Form(FormView),
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct ListView {
118    pub title: String,
119    /// Entity (del nakui store) cuyas instancias se listan.
120    pub entity: String,
121    /// Columnas a mostrar. Orden importa.
122    pub columns: Vec<Column>,
123    /// Acciones disponibles a nivel de la lista (ej. "Nuevo" → form).
124    /// Renderizadas como botones en el header.
125    #[serde(default)]
126    pub actions: Vec<Action>,
127    /// Cuando está set, se muestra una caja de búsqueda que filtra
128    /// las filas por substring contra los valores de estas columnas.
129    #[serde(default)]
130    pub search_in: Vec<String>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct Column {
135    /// Path del campo dentro del record. Para tipos planos: nombre.
136    /// Para nested: puntos (`address.city`). El runtime navega.
137    pub field: String,
138    /// Texto del header.
139    pub label: String,
140    /// Ancho relativo (peso flex). Default 1.
141    #[serde(default = "default_weight")]
142    pub weight: f32,
143}
144
145fn default_weight() -> f32 {
146    1.0
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct FormView {
151    pub title: String,
152    /// Entity destino del seed/morphism al submit.
153    pub entity: String,
154    pub fields: Vec<FieldSpec>,
155    /// Acción al submit. Típicamente `Action::SeedEntity` para alta
156    /// directa o `Action::Morphism` cuando hay validación/cálculo.
157    pub on_submit: Action,
158}
159
160/// Especificación de un campo de formulario o columna implícita.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct FieldSpec {
163    /// Nombre del campo en el record (clave del JSON).
164    pub name: String,
165    /// Etiqueta legible.
166    pub label: String,
167    /// Tipo del valor — define el widget de input + parseo.
168    pub kind: FieldKind,
169    /// Valor por defecto al abrir el form (string raw; el parseo
170    /// según `kind` lo hace el runtime).
171    #[serde(default)]
172    pub default: Option<String>,
173    /// Si `true`, el form rechaza submit con campo vacío.
174    #[serde(default)]
175    pub required: bool,
176    /// Texto de ayuda mostrado bajo el input.
177    #[serde(default)]
178    pub help: Option<String>,
179    /// Si `kind == EntityRef`, indica qué entity referencia. Sin
180    /// esto, el runtime no sabe qué records ofrecer en el selector
181    /// y la validación `Module::validate` rechaza el manifest.
182    /// Para los demás kinds, este campo se ignora.
183    #[serde(default)]
184    pub ref_entity: Option<String>,
185}
186
187#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
188#[serde(rename_all = "snake_case")]
189pub enum FieldKind {
190    /// Texto libre.
191    Text,
192    /// Texto multilínea.
193    Multiline,
194    /// Número (i64 o f64; runtime intenta parsear como i64 primero).
195    Number,
196    /// Booleano (renderizado como checkbox).
197    Boolean,
198    /// Fecha (formato ISO YYYY-MM-DD; almacenada como string).
199    Date,
200    /// Referencia a otro record. El runtime renderiza un selector
201    /// clickable de records existentes de la entity declarada en
202    /// `FieldSpec.ref_entity`; el value almacenado es el UUID del
203    /// seleccionado, parseable como cualquier text/UUID al submit.
204    EntityRef,
205}
206
207/// Acciones disparables por menús, botones o submit de formularios.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(tag = "kind", rename_all = "snake_case")]
210pub enum Action {
211    /// Cambia la vista activa a otra del mismo módulo.
212    OpenView {
213        view: String,
214        /// Etiqueta del botón / item; default = nombre humano de la vista.
215        #[serde(default)]
216        label: Option<String>,
217    },
218    /// Crea un seed directo en la entity con los valores del form.
219    /// Equivalente a `nakui_core::event_log::seed_and_log`.
220    /// Sin pipeline de morphism — alta administrativa.
221    SeedEntity {
222        entity: String,
223        /// Tras el submit exitoso, opcionalmente abrir esta vista
224        /// (por convención: `"list"` para volver al listado).
225        #[serde(default)]
226        next_view: Option<String>,
227    },
228    /// Ejecuta un morphism declarado en el manifest del módulo
229    /// nakui-core (cuyo path vive en `Module.nakui_module_dir`).
230    /// Inputs (records existentes) y params (valores escalares) se
231    /// mapean desde los campos del form.
232    Morphism {
233        /// Nombre del morphism declarado en `nsmc.json` del manifest
234        /// nakui apuntado por el módulo.
235        name: String,
236        /// Mapeo `role → field_name`: por cada input declarado en
237        /// el `MorphismSpec.inputs`, indica qué field del form
238        /// contiene el UUID del record. El runtime parsea el value
239        /// como `Uuid` y lo pasa como input al `execute_and_log`.
240        ///
241        /// Ej: `{ "stock": "stock_id", "caja": "caja_id" }` para un
242        /// morphism `vender` que toma roles `stock` y `caja`.
243        #[serde(default)]
244        inputs: BTreeMap<String, String>,
245        /// Lista de fields del form cuyos values van al `params`
246        /// JSON object pasado al morphism. Si está vacío, todos los
247        /// fields que no estén en `inputs` van a params.
248        #[serde(default)]
249        params: Vec<String>,
250        #[serde(default)]
251        next_view: Option<String>,
252    },
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct EntitySpec {
257    /// Nombre de la entity (clave en el store). Único dentro del módulo.
258    pub name: String,
259    /// Label legible (singular).
260    pub label: String,
261    /// Campos esperados en records de esta entity. Usados por el
262    /// runtime para inferir columnas / validar formularios.
263    #[serde(default)]
264    pub fields: Vec<FieldSpec>,
265}
266
267#[derive(Debug, Error)]
268pub enum SchemaError {
269    #[error("io leyendo {path}: {source}")]
270    Io {
271        path: PathBuf,
272        #[source]
273        source: std::io::Error,
274    },
275    #[error("parseo de {path}: {source}")]
276    Parse {
277        path: PathBuf,
278        #[source]
279        source: serde_json::Error,
280    },
281    #[error("módulo {id}: la vista '{view}' referenciada por menu_item '{item}' no existe")]
282    DanglingMenuView {
283        id: String,
284        item: String,
285        view: String,
286    },
287    #[error("módulos con id duplicado: '{id}' aparece en {first} y {second}")]
288    DuplicateModuleId {
289        id: String,
290        first: PathBuf,
291        second: PathBuf,
292    },
293    #[error(
294        "módulo {id} vista '{view}': field '{field}' tiene kind=entity_ref \
295         pero no declaró ref_entity"
296    )]
297    EntityRefMissingTarget {
298        id: String,
299        view: String,
300        field: String,
301    },
302}
303
304impl Module {
305    /// Carga un module.json desde disco. Validación estructural
306    /// posterior (vistas referenciadas existen, etc.) la ejecuta el
307    /// runtime — acá sólo parseamos.
308    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SchemaError> {
309        let path = path.as_ref();
310        let bytes = std::fs::read(path).map_err(|source| SchemaError::Io {
311            path: path.to_path_buf(),
312            source,
313        })?;
314        serde_json::from_slice(&bytes).map_err(|source| SchemaError::Parse {
315            path: path.to_path_buf(),
316            source,
317        })
318    }
319
320    /// Validación post-parse:
321    /// - Cada `MenuItem.view` debe existir en `views`.
322    /// - Cada `FieldSpec` con `kind=EntityRef` debe declarar
323    ///   `ref_entity`.
324    pub fn validate(&self) -> Result<(), SchemaError> {
325        for item in &self.menu {
326            if !self.views.contains_key(&item.view) {
327                return Err(SchemaError::DanglingMenuView {
328                    id: self.id.clone(),
329                    item: item.label.clone(),
330                    view: item.view.clone(),
331                });
332            }
333        }
334        for (view_key, view) in &self.views {
335            if let View::Form(form) = view {
336                for f in &form.fields {
337                    if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() {
338                        return Err(SchemaError::EntityRefMissingTarget {
339                            id: self.id.clone(),
340                            view: view_key.clone(),
341                            field: f.name.clone(),
342                        });
343                    }
344                }
345            }
346        }
347        Ok(())
348    }
349}
350
351/// Carga todos los `module.json` encontrados bajo `dir` (recursivo
352/// 1 nivel — espera `dir/<modulo>/module.json`). Devuelve la lista
353/// ordenada por id, validada.
354///
355/// Falla si: I/O, parseo, módulo inválido, o ids duplicados.
356pub fn load_modules_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Module>, SchemaError> {
357    let dir = dir.as_ref();
358    let mut modules: Vec<(PathBuf, Module)> = Vec::new();
359    let entries = std::fs::read_dir(dir).map_err(|source| SchemaError::Io {
360        path: dir.to_path_buf(),
361        source,
362    })?;
363    for entry in entries.flatten() {
364        let p = entry.path();
365        if p.is_dir() {
366            let manifest = p.join("module.json");
367            if manifest.exists() {
368                let m = Module::from_path(&manifest)?;
369                m.validate()?;
370                modules.push((manifest, m));
371            }
372        }
373    }
374    // Detectar duplicados de id.
375    modules.sort_by(|a, b| a.1.id.cmp(&b.1.id));
376    let mut prev: Option<&(PathBuf, Module)> = None;
377    for cur in &modules {
378        if let Some(p) = prev {
379            if p.1.id == cur.1.id {
380                return Err(SchemaError::DuplicateModuleId {
381                    id: cur.1.id.clone(),
382                    first: p.0.clone(),
383                    second: cur.0.clone(),
384                });
385            }
386        }
387        prev = Some(cur);
388    }
389    Ok(modules.into_iter().map(|(_, m)| m).collect())
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    fn sample_module() -> Module {
397        Module {
398            id: "customers".into(),
399            label: "Clientes".into(),
400            description: Some("Gestión de clientes".into()),
401            nakui_module_dir: None,
402            entities: vec![EntitySpec {
403                name: "customer".into(),
404                label: "Cliente".into(),
405                fields: vec![
406                    FieldSpec {
407                        name: "name".into(),
408                        label: "Nombre".into(),
409                        kind: FieldKind::Text,
410                        default: None,
411                        required: true,
412                        help: None,
413                        ref_entity: None,
414                    },
415                    FieldSpec {
416                        name: "email".into(),
417                        label: "Email".into(),
418                        kind: FieldKind::Text,
419                        default: None,
420                        required: false,
421                        help: Some("Opcional".into()),
422                        ref_entity: None,
423                    },
424                ],
425            }],
426            menu: vec![
427                MenuItem {
428                    label: "Listar".into(),
429                    view: "list".into(),
430                    icon: None,
431                },
432                MenuItem {
433                    label: "Nuevo".into(),
434                    view: "form".into(),
435                    icon: None,
436                },
437            ],
438            views: BTreeMap::from([
439                (
440                    "list".into(),
441                    View::List(ListView {
442                        title: "Clientes".into(),
443                        entity: "customer".into(),
444                        columns: vec![
445                            Column {
446                                field: "name".into(),
447                                label: "Nombre".into(),
448                                weight: 2.0,
449                            },
450                            Column {
451                                field: "email".into(),
452                                label: "Email".into(),
453                                weight: 3.0,
454                            },
455                        ],
456                        actions: vec![Action::OpenView {
457                            view: "form".into(),
458                            label: Some("Nuevo".into()),
459                        }],
460                        search_in: vec!["name".into(), "email".into()],
461                    }),
462                ),
463                (
464                    "form".into(),
465                    View::Form(FormView {
466                        title: "Nuevo cliente".into(),
467                        entity: "customer".into(),
468                        fields: vec![FieldSpec {
469                            name: "name".into(),
470                            label: "Nombre".into(),
471                            kind: FieldKind::Text,
472                            default: None,
473                            required: true,
474                            help: None,
475                            ref_entity: None,
476                        }],
477                        on_submit: Action::SeedEntity {
478                            entity: "customer".into(),
479                            next_view: Some("list".into()),
480                        },
481                    }),
482                ),
483            ]),
484        }
485    }
486
487    #[test]
488    fn module_serialize_deserialize_roundtrip() {
489        let m = sample_module();
490        let s = serde_json::to_string_pretty(&m).unwrap();
491        let m2: Module = serde_json::from_str(&s).unwrap();
492        assert_eq!(m.id, m2.id);
493        assert_eq!(m.menu.len(), m2.menu.len());
494        assert_eq!(m.views.len(), m2.views.len());
495    }
496
497    #[test]
498    fn validate_passes_for_well_formed_module() {
499        sample_module().validate().unwrap();
500    }
501
502    #[test]
503    fn validate_catches_dangling_menu_view() {
504        let mut m = sample_module();
505        m.menu.push(MenuItem {
506            label: "Roto".into(),
507            view: "no_existe".into(),
508            icon: None,
509        });
510        let err = m.validate().unwrap_err();
511        assert!(matches!(err, SchemaError::DanglingMenuView { .. }));
512    }
513
514    #[test]
515    fn from_path_loads_real_file() {
516        let tmp = tempfile::tempdir().unwrap();
517        let path = tmp.path().join("module.json");
518        std::fs::write(&path, serde_json::to_vec_pretty(&sample_module()).unwrap()).unwrap();
519        let m = Module::from_path(&path).unwrap();
520        assert_eq!(m.id, "customers");
521    }
522
523    #[test]
524    fn load_modules_from_dir_finds_subdirs() {
525        let tmp = tempfile::tempdir().unwrap();
526        let cust_dir = tmp.path().join("customers");
527        std::fs::create_dir(&cust_dir).unwrap();
528        let mut m1 = sample_module();
529        m1.id = "customers".into();
530        std::fs::write(
531            cust_dir.join("module.json"),
532            serde_json::to_vec_pretty(&m1).unwrap(),
533        )
534        .unwrap();
535        let prod_dir = tmp.path().join("products");
536        std::fs::create_dir(&prod_dir).unwrap();
537        let mut m2 = sample_module();
538        m2.id = "products".into();
539        std::fs::write(
540            prod_dir.join("module.json"),
541            serde_json::to_vec_pretty(&m2).unwrap(),
542        )
543        .unwrap();
544
545        let mods = load_modules_from_dir(tmp.path()).unwrap();
546        assert_eq!(mods.len(), 2);
547        assert_eq!(mods[0].id, "customers");
548        assert_eq!(mods[1].id, "products");
549    }
550
551    #[test]
552    fn validate_catches_entity_ref_without_target() {
553        let mut m = sample_module();
554        // Inyectamos un form con un campo EntityRef sin ref_entity.
555        m.views.insert(
556            "broken_form".into(),
557            View::Form(FormView {
558                title: "Roto".into(),
559                entity: "customer".into(),
560                fields: vec![FieldSpec {
561                    name: "ref_to_nowhere".into(),
562                    label: "Referencia".into(),
563                    kind: FieldKind::EntityRef,
564                    default: None,
565                    required: true,
566                    help: None,
567                    ref_entity: None,
568                }],
569                on_submit: Action::SeedEntity {
570                    entity: "customer".into(),
571                    next_view: None,
572                },
573            }),
574        );
575        let err = m.validate().unwrap_err();
576        assert!(
577            matches!(err, SchemaError::EntityRefMissingTarget { .. }),
578            "got: {err:?}"
579        );
580    }
581
582    #[test]
583    fn entity_ref_with_target_validates_clean() {
584        let mut m = sample_module();
585        m.views.insert(
586            "ok_form".into(),
587            View::Form(FormView {
588                title: "OK".into(),
589                entity: "customer".into(),
590                fields: vec![FieldSpec {
591                    name: "supplier".into(),
592                    label: "Proveedor".into(),
593                    kind: FieldKind::EntityRef,
594                    default: None,
595                    required: true,
596                    help: None,
597                    ref_entity: Some("supplier".into()),
598                }],
599                on_submit: Action::SeedEntity {
600                    entity: "customer".into(),
601                    next_view: None,
602                },
603            }),
604        );
605        m.menu.push(MenuItem {
606            label: "OK".into(),
607            view: "ok_form".into(),
608            icon: None,
609        });
610        m.validate().unwrap();
611    }
612
613    #[test]
614    fn load_modules_detects_duplicate_id() {
615        let tmp = tempfile::tempdir().unwrap();
616        let a_dir = tmp.path().join("a");
617        let b_dir = tmp.path().join("b");
618        std::fs::create_dir_all(&a_dir).unwrap();
619        std::fs::create_dir_all(&b_dir).unwrap();
620        let m = sample_module(); // id = "customers"
621        std::fs::write(
622            a_dir.join("module.json"),
623            serde_json::to_vec(&m).unwrap(),
624        )
625        .unwrap();
626        std::fs::write(
627            b_dir.join("module.json"),
628            serde_json::to_vec(&m).unwrap(),
629        )
630        .unwrap();
631        let err = load_modules_from_dir(tmp.path()).unwrap_err();
632        assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
633    }
634}