⎇ fc727266668baca3ad8d9bc6ac4c09ee62b360b3
crates/modules/nakui/ui-schema/src/lib.rs
Historia
Quién tocó qué
crudo
feat(nakui-ui): FieldKind::EntityRef — selector clickable de records existentes
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}