gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definiciones

⎇ 932e7464d7574f32694619763f4561f17b63e5e7 crates/modules/nakui/ui-schema/src/lib.rs Rust · 534 líneas · 18.0 KB Historia Quién tocó qué crudo

feat(nakui-ui): Action::Morphism wired al pipeline real (compute -> log -> apply) 932e7464d · 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}
180
181#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
182#[serde(rename_all = "snake_case")]
183pub enum FieldKind {
184    /// Texto libre.
185    Text,
186    /// Texto multilínea.
187    Multiline,
188    /// Número (i64 o f64; runtime intenta parsear como i64 primero).
189    Number,
190    /// Booleano (renderizado como checkbox).
191    Boolean,
192    /// Fecha (formato ISO YYYY-MM-DD; almacenada como string).
193    Date,
194}
195
196/// Acciones disparables por menús, botones o submit de formularios.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(tag = "kind", rename_all = "snake_case")]
199pub enum Action {
200    /// Cambia la vista activa a otra del mismo módulo.
201    OpenView {
202        view: String,
203        /// Etiqueta del botón / item; default = nombre humano de la vista.
204        #[serde(default)]
205        label: Option<String>,
206    },
207    /// Crea un seed directo en la entity con los valores del form.
208    /// Equivalente a `nakui_core::event_log::seed_and_log`.
209    /// Sin pipeline de morphism — alta administrativa.
210    SeedEntity {
211        entity: String,
212        /// Tras el submit exitoso, opcionalmente abrir esta vista
213        /// (por convención: `"list"` para volver al listado).
214        #[serde(default)]
215        next_view: Option<String>,
216    },
217    /// Ejecuta un morphism declarado en el manifest del módulo
218    /// nakui-core (cuyo path vive en `Module.nakui_module_dir`).
219    /// Inputs (records existentes) y params (valores escalares) se
220    /// mapean desde los campos del form.
221    Morphism {
222        /// Nombre del morphism declarado en `nsmc.json` del manifest
223        /// nakui apuntado por el módulo.
224        name: String,
225        /// Mapeo `role → field_name`: por cada input declarado en
226        /// el `MorphismSpec.inputs`, indica qué field del form
227        /// contiene el UUID del record. El runtime parsea el value
228        /// como `Uuid` y lo pasa como input al `execute_and_log`.
229        ///
230        /// Ej: `{ "stock": "stock_id", "caja": "caja_id" }` para un
231        /// morphism `vender` que toma roles `stock` y `caja`.
232        #[serde(default)]
233        inputs: BTreeMap<String, String>,
234        /// Lista de fields del form cuyos values van al `params`
235        /// JSON object pasado al morphism. Si está vacío, todos los
236        /// fields que no estén en `inputs` van a params.
237        #[serde(default)]
238        params: Vec<String>,
239        #[serde(default)]
240        next_view: Option<String>,
241    },
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct EntitySpec {
246    /// Nombre de la entity (clave en el store). Único dentro del módulo.
247    pub name: String,
248    /// Label legible (singular).
249    pub label: String,
250    /// Campos esperados en records de esta entity. Usados por el
251    /// runtime para inferir columnas / validar formularios.
252    #[serde(default)]
253    pub fields: Vec<FieldSpec>,
254}
255
256#[derive(Debug, Error)]
257pub enum SchemaError {
258    #[error("io leyendo {path}: {source}")]
259    Io {
260        path: PathBuf,
261        #[source]
262        source: std::io::Error,
263    },
264    #[error("parseo de {path}: {source}")]
265    Parse {
266        path: PathBuf,
267        #[source]
268        source: serde_json::Error,
269    },
270    #[error("módulo {id}: la vista '{view}' referenciada por menu_item '{item}' no existe")]
271    DanglingMenuView {
272        id: String,
273        item: String,
274        view: String,
275    },
276    #[error("módulos con id duplicado: '{id}' aparece en {first} y {second}")]
277    DuplicateModuleId {
278        id: String,
279        first: PathBuf,
280        second: PathBuf,
281    },
282}
283
284impl Module {
285    /// Carga un module.json desde disco. Validación estructural
286    /// posterior (vistas referenciadas existen, etc.) la ejecuta el
287    /// runtime — acá sólo parseamos.
288    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SchemaError> {
289        let path = path.as_ref();
290        let bytes = std::fs::read(path).map_err(|source| SchemaError::Io {
291            path: path.to_path_buf(),
292            source,
293        })?;
294        serde_json::from_slice(&bytes).map_err(|source| SchemaError::Parse {
295            path: path.to_path_buf(),
296            source,
297        })
298    }
299
300    /// Validación post-parse: cada `MenuItem.view` debe existir en
301    /// `views`. Retorna el primer error encontrado.
302    pub fn validate(&self) -> Result<(), SchemaError> {
303        for item in &self.menu {
304            if !self.views.contains_key(&item.view) {
305                return Err(SchemaError::DanglingMenuView {
306                    id: self.id.clone(),
307                    item: item.label.clone(),
308                    view: item.view.clone(),
309                });
310            }
311        }
312        Ok(())
313    }
314}
315
316/// Carga todos los `module.json` encontrados bajo `dir` (recursivo
317/// 1 nivel — espera `dir/<modulo>/module.json`). Devuelve la lista
318/// ordenada por id, validada.
319///
320/// Falla si: I/O, parseo, módulo inválido, o ids duplicados.
321pub fn load_modules_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Module>, SchemaError> {
322    let dir = dir.as_ref();
323    let mut modules: Vec<(PathBuf, Module)> = Vec::new();
324    let entries = std::fs::read_dir(dir).map_err(|source| SchemaError::Io {
325        path: dir.to_path_buf(),
326        source,
327    })?;
328    for entry in entries.flatten() {
329        let p = entry.path();
330        if p.is_dir() {
331            let manifest = p.join("module.json");
332            if manifest.exists() {
333                let m = Module::from_path(&manifest)?;
334                m.validate()?;
335                modules.push((manifest, m));
336            }
337        }
338    }
339    // Detectar duplicados de id.
340    modules.sort_by(|a, b| a.1.id.cmp(&b.1.id));
341    let mut prev: Option<&(PathBuf, Module)> = None;
342    for cur in &modules {
343        if let Some(p) = prev {
344            if p.1.id == cur.1.id {
345                return Err(SchemaError::DuplicateModuleId {
346                    id: cur.1.id.clone(),
347                    first: p.0.clone(),
348                    second: cur.0.clone(),
349                });
350            }
351        }
352        prev = Some(cur);
353    }
354    Ok(modules.into_iter().map(|(_, m)| m).collect())
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    fn sample_module() -> Module {
362        Module {
363            id: "customers".into(),
364            label: "Clientes".into(),
365            description: Some("Gestión de clientes".into()),
366            nakui_module_dir: None,
367            entities: vec![EntitySpec {
368                name: "customer".into(),
369                label: "Cliente".into(),
370                fields: vec![
371                    FieldSpec {
372                        name: "name".into(),
373                        label: "Nombre".into(),
374                        kind: FieldKind::Text,
375                        default: None,
376                        required: true,
377                        help: None,
378                    },
379                    FieldSpec {
380                        name: "email".into(),
381                        label: "Email".into(),
382                        kind: FieldKind::Text,
383                        default: None,
384                        required: false,
385                        help: Some("Opcional".into()),
386                    },
387                ],
388            }],
389            menu: vec![
390                MenuItem {
391                    label: "Listar".into(),
392                    view: "list".into(),
393                    icon: None,
394                },
395                MenuItem {
396                    label: "Nuevo".into(),
397                    view: "form".into(),
398                    icon: None,
399                },
400            ],
401            views: BTreeMap::from([
402                (
403                    "list".into(),
404                    View::List(ListView {
405                        title: "Clientes".into(),
406                        entity: "customer".into(),
407                        columns: vec![
408                            Column {
409                                field: "name".into(),
410                                label: "Nombre".into(),
411                                weight: 2.0,
412                            },
413                            Column {
414                                field: "email".into(),
415                                label: "Email".into(),
416                                weight: 3.0,
417                            },
418                        ],
419                        actions: vec![Action::OpenView {
420                            view: "form".into(),
421                            label: Some("Nuevo".into()),
422                        }],
423                        search_in: vec!["name".into(), "email".into()],
424                    }),
425                ),
426                (
427                    "form".into(),
428                    View::Form(FormView {
429                        title: "Nuevo cliente".into(),
430                        entity: "customer".into(),
431                        fields: vec![FieldSpec {
432                            name: "name".into(),
433                            label: "Nombre".into(),
434                            kind: FieldKind::Text,
435                            default: None,
436                            required: true,
437                            help: None,
438                        }],
439                        on_submit: Action::SeedEntity {
440                            entity: "customer".into(),
441                            next_view: Some("list".into()),
442                        },
443                    }),
444                ),
445            ]),
446        }
447    }
448
449    #[test]
450    fn module_serialize_deserialize_roundtrip() {
451        let m = sample_module();
452        let s = serde_json::to_string_pretty(&m).unwrap();
453        let m2: Module = serde_json::from_str(&s).unwrap();
454        assert_eq!(m.id, m2.id);
455        assert_eq!(m.menu.len(), m2.menu.len());
456        assert_eq!(m.views.len(), m2.views.len());
457    }
458
459    #[test]
460    fn validate_passes_for_well_formed_module() {
461        sample_module().validate().unwrap();
462    }
463
464    #[test]
465    fn validate_catches_dangling_menu_view() {
466        let mut m = sample_module();
467        m.menu.push(MenuItem {
468            label: "Roto".into(),
469            view: "no_existe".into(),
470            icon: None,
471        });
472        let err = m.validate().unwrap_err();
473        assert!(matches!(err, SchemaError::DanglingMenuView { .. }));
474    }
475
476    #[test]
477    fn from_path_loads_real_file() {
478        let tmp = tempfile::tempdir().unwrap();
479        let path = tmp.path().join("module.json");
480        std::fs::write(&path, serde_json::to_vec_pretty(&sample_module()).unwrap()).unwrap();
481        let m = Module::from_path(&path).unwrap();
482        assert_eq!(m.id, "customers");
483    }
484
485    #[test]
486    fn load_modules_from_dir_finds_subdirs() {
487        let tmp = tempfile::tempdir().unwrap();
488        let cust_dir = tmp.path().join("customers");
489        std::fs::create_dir(&cust_dir).unwrap();
490        let mut m1 = sample_module();
491        m1.id = "customers".into();
492        std::fs::write(
493            cust_dir.join("module.json"),
494            serde_json::to_vec_pretty(&m1).unwrap(),
495        )
496        .unwrap();
497        let prod_dir = tmp.path().join("products");
498        std::fs::create_dir(&prod_dir).unwrap();
499        let mut m2 = sample_module();
500        m2.id = "products".into();
501        std::fs::write(
502            prod_dir.join("module.json"),
503            serde_json::to_vec_pretty(&m2).unwrap(),
504        )
505        .unwrap();
506
507        let mods = load_modules_from_dir(tmp.path()).unwrap();
508        assert_eq!(mods.len(), 2);
509        assert_eq!(mods[0].id, "customers");
510        assert_eq!(mods[1].id, "products");
511    }
512
513    #[test]
514    fn load_modules_detects_duplicate_id() {
515        let tmp = tempfile::tempdir().unwrap();
516        let a_dir = tmp.path().join("a");
517        let b_dir = tmp.path().join("b");
518        std::fs::create_dir_all(&a_dir).unwrap();
519        std::fs::create_dir_all(&b_dir).unwrap();
520        let m = sample_module(); // id = "customers"
521        std::fs::write(
522            a_dir.join("module.json"),
523            serde_json::to_vec(&m).unwrap(),
524        )
525        .unwrap();
526        std::fs::write(
527            b_dir.join("module.json"),
528            serde_json::to_vec(&m).unwrap(),
529        )
530        .unwrap();
531        let err = load_modules_from_dir(tmp.path()).unwrap_err();
532        assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
533    }
534}