//! Schema declarativo de la metainterfaz (nahual meta-schema). //! //! Cada **módulo** declara aquí qué menús, vistas, listas y //! formularios expone, sin escribir código GPUI ni Rust. Cualquier //! runtime de UI dirigida por datos (Nakui hoy, otros mañana) lo //! carga y monta la UI correspondiente. //! //! ## Filosofía //! //! - **UI como datos**: agregar un módulo = escribir un JSON o un //! `.ncl`. Ningún recompile, ningún acoplamiento con el binario //! del runtime. //! - **Backend-agnostic**: este crate sólo describe la *forma* de //! la UI. La conexión a un store/log/executor concretos vive en //! el runtime que lo consume (ej: el meta-runtime de Nakui que //! wirea esto a `nakui_core` + KCL post-checks). //! - **Schema primero, semántica después**: validación semántica //! (referencias rotas a entities, campos faltantes, etc.) vive //! en el runtime que lo carga, no acá. //! //! ## Anatomía de un módulo //! //! ```json //! { //! "id": "customers", //! "label": "Clientes", //! "entities": [ //! { "name": "customer", "fields": [ ... ] } //! ], //! "menu": [ //! { "label": "Listar", "view": "list" }, //! { "label": "Nuevo", "view": "form" } //! ], //! "views": { //! "list": { "kind": "list", "entity": "customer", "columns": [...] }, //! "form": { "kind": "form", "entity": "customer", "fields": [...] } //! } //! } //! ``` #![forbid(unsafe_code)] #![warn(rust_2018_idioms)] use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use thiserror::Error; /// Manifiesto de un módulo declarativo de UI. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Module { /// Identificador estable. Único dentro del directorio cargado. pub id: String, /// Nombre legible para mostrar en el sidebar. pub label: String, /// Descripción corta opcional (tooltip / subtítulo). #[serde(default)] pub description: Option, /// Entities que el módulo introduce o consume. El runtime las /// usa para validar columns/fields y para inicializar el store /// cuando son nuevas. #[serde(default)] pub entities: Vec, /// Path opaco al backend que va a manejar este módulo. Lo /// interpreta el runtime concreto (no este schema). /// /// Convención actual de Nakui: directorio con `nsmc.json` + /// schemas KCL + scripts Rhai. Cuando está set, el runtime /// Nakui carga un `Executor` para ese path y permite que las /// acciones `Morphism { name }` despachen al pipeline real /// (compute → log → apply). Otro backend puede ignorar este /// campo o darle un significado distinto. /// /// Path resuelto relativo al directorio del `module.json` /// o absoluto. /// /// Si es `None`, los backends que requieren manifest deberían /// degradar (toast informativo, deshabilitar morphisms, etc.); /// los `SeedEntity` siguen funcionando — son altas /// administrativas que no necesitan validación de manifest. /// /// Nombre conservado por compat con módulos ya escritos. /// Renombrar a `backend_module_dir` o similar si emerge un /// segundo backend que también lo use. #[serde(default, alias = "backend_module_dir")] pub nakui_module_dir: Option, /// Items del menú. Cada uno apunta a una key de `views`. Orden /// importa (es el orden en que se presentan en el sidebar). pub menu: Vec, /// Vistas indexadas por key. Las keys son referenciadas por /// `MenuItem.view` y por `Action::OpenView.view`. pub views: BTreeMap, } /// Item del menú lateral. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MenuItem { pub label: String, /// Key de la vista a abrir. Debe existir en `Module.views`. pub view: String, /// Icono opcional (texto unicode o emoji; el runtime decide /// renderización). #[serde(default)] pub icon: Option, } /// Una vista renderizada en el área principal cuando su menú es seleccionado. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum View { /// Tabla de instancias de una entity, columnas + acciones por fila. List(ListView), /// Formulario de creación / edición. Form(FormView), } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListView { pub title: String, /// Entity (del nakui store) cuyas instancias se listan. pub entity: String, /// Columnas a mostrar. Orden importa. pub columns: Vec, /// Acciones disponibles a nivel de la lista (ej. "Nuevo" → form). /// Renderizadas como botones en el header. #[serde(default)] pub actions: Vec, /// Cuando está set, se muestra una caja de búsqueda que filtra /// las filas por substring contra los valores de estas columnas. #[serde(default)] pub search_in: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Column { /// Path del campo dentro del record. Para tipos planos: nombre. /// Para nested: puntos (`address.city`). El runtime navega. pub field: String, /// Texto del header. pub label: String, /// Ancho relativo (peso flex). Default 1. #[serde(default = "default_weight")] pub weight: f32, } fn default_weight() -> f32 { 1.0 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FormView { pub title: String, /// Entity destino del seed/morphism al submit. pub entity: String, pub fields: Vec, /// Acción al submit. Típicamente `Action::SeedEntity` para alta /// directa o `Action::Morphism` cuando hay validación/cálculo. pub on_submit: Action, } /// Especificación de un campo de formulario o columna implícita. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FieldSpec { /// Nombre del campo en el record (clave del JSON). pub name: String, /// Etiqueta legible. pub label: String, /// Tipo del valor — define el widget de input + parseo. pub kind: FieldKind, /// Valor por defecto al abrir el form (string raw; el parseo /// según `kind` lo hace el runtime). #[serde(default)] pub default: Option, /// Si `true`, el form rechaza submit con campo vacío. #[serde(default)] pub required: bool, /// Texto de ayuda mostrado bajo el input. #[serde(default)] pub help: Option, /// Si `kind == EntityRef`, indica qué entity referencia. Sin /// esto, el runtime no sabe qué records ofrecer en el selector /// y la validación `Module::validate` rechaza el manifest. /// Para los demás kinds, este campo se ignora. #[serde(default)] pub ref_entity: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FieldKind { /// Texto libre. Text, /// Texto multilínea. Multiline, /// Número (i64 o f64; runtime intenta parsear como i64 primero). Number, /// Booleano (renderizado como checkbox). Boolean, /// Fecha (formato ISO YYYY-MM-DD; almacenada como string). Date, /// Referencia a otro record. El runtime renderiza un selector /// clickable de records existentes de la entity declarada en /// `FieldSpec.ref_entity`; el value almacenado es el UUID del /// seleccionado, parseable como cualquier text/UUID al submit. EntityRef, } /// Acciones disparables por menús, botones o submit de formularios. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Action { /// Cambia la vista activa a otra del mismo módulo. OpenView { view: String, /// Etiqueta del botón / item; default = nombre humano de la vista. #[serde(default)] label: Option, }, /// Crea un seed directo en la entity con los valores del form. /// Equivalente a `nakui_core::event_log::seed_and_log`. /// Sin pipeline de morphism — alta administrativa. SeedEntity { entity: String, /// Tras el submit exitoso, opcionalmente abrir esta vista /// (por convención: `"list"` para volver al listado). #[serde(default)] next_view: Option, }, /// Ejecuta un morphism declarado en el manifest del módulo /// nakui-core (cuyo path vive en `Module.nakui_module_dir`). /// Inputs (records existentes) y params (valores escalares) se /// mapean desde los campos del form. Morphism { /// Nombre del morphism declarado en `nsmc.json` del manifest /// nakui apuntado por el módulo. name: String, /// Mapeo `role → field_name`: por cada input declarado en /// el `MorphismSpec.inputs`, indica qué field del form /// contiene el UUID del record. El runtime parsea el value /// como `Uuid` y lo pasa como input al `execute_and_log`. /// /// Ej: `{ "stock": "stock_id", "caja": "caja_id" }` para un /// morphism `vender` que toma roles `stock` y `caja`. #[serde(default)] inputs: BTreeMap, /// Lista de fields del form cuyos values van al `params` /// JSON object pasado al morphism. Si está vacío, todos los /// fields que no estén en `inputs` van a params. #[serde(default)] params: Vec, #[serde(default)] next_view: Option, }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EntitySpec { /// Nombre de la entity (clave en el store). Único dentro del módulo. pub name: String, /// Label legible (singular). pub label: String, /// Campos esperados en records de esta entity. Usados por el /// runtime para inferir columnas / validar formularios. #[serde(default)] pub fields: Vec, } #[derive(Debug, Error)] pub enum SchemaError { #[error("io leyendo {path}: {source}")] Io { path: PathBuf, #[source] source: std::io::Error, }, #[error("parseo de {path}: {source}")] Parse { path: PathBuf, #[source] source: serde_json::Error, }, #[error("módulo {id}: la vista '{view}' referenciada por menu_item '{item}' no existe")] DanglingMenuView { id: String, item: String, view: String, }, #[error("módulos con id duplicado: '{id}' aparece en {first} y {second}")] DuplicateModuleId { id: String, first: PathBuf, second: PathBuf, }, #[error( "módulo {id} vista '{view}': field '{field}' tiene kind=entity_ref \ pero no declaró ref_entity" )] EntityRefMissingTarget { id: String, view: String, field: String, }, } impl Module { /// Carga un module.json desde disco. Validación estructural /// posterior (vistas referenciadas existen, etc.) la ejecuta el /// runtime — acá sólo parseamos. pub fn from_path(path: impl AsRef) -> Result { let path = path.as_ref(); let bytes = std::fs::read(path).map_err(|source| SchemaError::Io { path: path.to_path_buf(), source, })?; serde_json::from_slice(&bytes).map_err(|source| SchemaError::Parse { path: path.to_path_buf(), source, }) } /// Validación post-parse: /// - Cada `MenuItem.view` debe existir en `views`. /// - Cada `FieldSpec` con `kind=EntityRef` debe declarar /// `ref_entity`. pub fn validate(&self) -> Result<(), SchemaError> { for item in &self.menu { if !self.views.contains_key(&item.view) { return Err(SchemaError::DanglingMenuView { id: self.id.clone(), item: item.label.clone(), view: item.view.clone(), }); } } for (view_key, view) in &self.views { if let View::Form(form) = view { for f in &form.fields { if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() { return Err(SchemaError::EntityRefMissingTarget { id: self.id.clone(), view: view_key.clone(), field: f.name.clone(), }); } } } } Ok(()) } } /// Carga todos los `module.json` encontrados bajo `dir` (recursivo /// 1 nivel — espera `dir//module.json`). Devuelve la lista /// ordenada por id, validada. /// /// Falla si: I/O, parseo, módulo inválido, o ids duplicados. pub fn load_modules_from_dir(dir: impl AsRef) -> Result, SchemaError> { let dir = dir.as_ref(); let mut modules: Vec<(PathBuf, Module)> = Vec::new(); let entries = std::fs::read_dir(dir).map_err(|source| SchemaError::Io { path: dir.to_path_buf(), source, })?; for entry in entries.flatten() { let p = entry.path(); if p.is_dir() { let manifest = p.join("module.json"); if manifest.exists() { let m = Module::from_path(&manifest)?; m.validate()?; modules.push((manifest, m)); } } } // Detectar duplicados de id. modules.sort_by(|a, b| a.1.id.cmp(&b.1.id)); let mut prev: Option<&(PathBuf, Module)> = None; for cur in &modules { if let Some(p) = prev { if p.1.id == cur.1.id { return Err(SchemaError::DuplicateModuleId { id: cur.1.id.clone(), first: p.0.clone(), second: cur.0.clone(), }); } } prev = Some(cur); } Ok(modules.into_iter().map(|(_, m)| m).collect()) } #[cfg(test)] mod tests { use super::*; fn sample_module() -> Module { Module { id: "customers".into(), label: "Clientes".into(), description: Some("Gestión de clientes".into()), nakui_module_dir: None, entities: vec![EntitySpec { name: "customer".into(), label: "Cliente".into(), fields: vec![ FieldSpec { name: "name".into(), label: "Nombre".into(), kind: FieldKind::Text, default: None, required: true, help: None, ref_entity: None, }, FieldSpec { name: "email".into(), label: "Email".into(), kind: FieldKind::Text, default: None, required: false, help: Some("Opcional".into()), ref_entity: None, }, ], }], menu: vec![ MenuItem { label: "Listar".into(), view: "list".into(), icon: None, }, MenuItem { label: "Nuevo".into(), view: "form".into(), icon: None, }, ], views: BTreeMap::from([ ( "list".into(), View::List(ListView { title: "Clientes".into(), entity: "customer".into(), columns: vec![ Column { field: "name".into(), label: "Nombre".into(), weight: 2.0, }, Column { field: "email".into(), label: "Email".into(), weight: 3.0, }, ], actions: vec![Action::OpenView { view: "form".into(), label: Some("Nuevo".into()), }], search_in: vec!["name".into(), "email".into()], }), ), ( "form".into(), View::Form(FormView { title: "Nuevo cliente".into(), entity: "customer".into(), fields: vec![FieldSpec { name: "name".into(), label: "Nombre".into(), kind: FieldKind::Text, default: None, required: true, help: None, ref_entity: None, }], on_submit: Action::SeedEntity { entity: "customer".into(), next_view: Some("list".into()), }, }), ), ]), } } #[test] fn module_serialize_deserialize_roundtrip() { let m = sample_module(); let s = serde_json::to_string_pretty(&m).unwrap(); let m2: Module = serde_json::from_str(&s).unwrap(); assert_eq!(m.id, m2.id); assert_eq!(m.menu.len(), m2.menu.len()); assert_eq!(m.views.len(), m2.views.len()); } #[test] fn validate_passes_for_well_formed_module() { sample_module().validate().unwrap(); } #[test] fn validate_catches_dangling_menu_view() { let mut m = sample_module(); m.menu.push(MenuItem { label: "Roto".into(), view: "no_existe".into(), icon: None, }); let err = m.validate().unwrap_err(); assert!(matches!(err, SchemaError::DanglingMenuView { .. })); } #[test] fn from_path_loads_real_file() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("module.json"); std::fs::write(&path, serde_json::to_vec_pretty(&sample_module()).unwrap()).unwrap(); let m = Module::from_path(&path).unwrap(); assert_eq!(m.id, "customers"); } #[test] fn load_modules_from_dir_finds_subdirs() { let tmp = tempfile::tempdir().unwrap(); let cust_dir = tmp.path().join("customers"); std::fs::create_dir(&cust_dir).unwrap(); let mut m1 = sample_module(); m1.id = "customers".into(); std::fs::write( cust_dir.join("module.json"), serde_json::to_vec_pretty(&m1).unwrap(), ) .unwrap(); let prod_dir = tmp.path().join("products"); std::fs::create_dir(&prod_dir).unwrap(); let mut m2 = sample_module(); m2.id = "products".into(); std::fs::write( prod_dir.join("module.json"), serde_json::to_vec_pretty(&m2).unwrap(), ) .unwrap(); let mods = load_modules_from_dir(tmp.path()).unwrap(); assert_eq!(mods.len(), 2); assert_eq!(mods[0].id, "customers"); assert_eq!(mods[1].id, "products"); } #[test] fn validate_catches_entity_ref_without_target() { let mut m = sample_module(); // Inyectamos un form con un campo EntityRef sin ref_entity. m.views.insert( "broken_form".into(), View::Form(FormView { title: "Roto".into(), entity: "customer".into(), fields: vec![FieldSpec { name: "ref_to_nowhere".into(), label: "Referencia".into(), kind: FieldKind::EntityRef, default: None, required: true, help: None, ref_entity: None, }], on_submit: Action::SeedEntity { entity: "customer".into(), next_view: None, }, }), ); let err = m.validate().unwrap_err(); assert!( matches!(err, SchemaError::EntityRefMissingTarget { .. }), "got: {err:?}" ); } #[test] fn entity_ref_with_target_validates_clean() { let mut m = sample_module(); m.views.insert( "ok_form".into(), View::Form(FormView { title: "OK".into(), entity: "customer".into(), fields: vec![FieldSpec { name: "supplier".into(), label: "Proveedor".into(), kind: FieldKind::EntityRef, default: None, required: true, help: None, ref_entity: Some("supplier".into()), }], on_submit: Action::SeedEntity { entity: "customer".into(), next_view: None, }, }), ); m.menu.push(MenuItem { label: "OK".into(), view: "ok_form".into(), icon: None, }); m.validate().unwrap(); } #[test] fn load_modules_detects_duplicate_id() { let tmp = tempfile::tempdir().unwrap(); let a_dir = tmp.path().join("a"); let b_dir = tmp.path().join("b"); std::fs::create_dir_all(&a_dir).unwrap(); std::fs::create_dir_all(&b_dir).unwrap(); let m = sample_module(); // id = "customers" std::fs::write( a_dir.join("module.json"), serde_json::to_vec(&m).unwrap(), ) .unwrap(); std::fs::write( b_dir.join("module.json"), serde_json::to_vec(&m).unwrap(), ) .unwrap(); let err = load_modules_from_dir(tmp.path()).unwrap_err(); assert!(matches!(err, SchemaError::DuplicateModuleId { .. })); } }