⎇ 932e7464d7574f32694619763f4561f17b63e5e7
crates/modules/nakui/ui-schema/src/lib.rs
Historia
Quién tocó qué
crudo
feat(nakui-ui): Action::Morphism wired al pipeline real (compute -> log -> apply)
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}