gioser

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definiciones

⎇ 6588d0ed6c355a57194a34a488c1ff2b3d8eb6b3 crates/modules/nahual/libs/meta-schema/src/lib.rs Rust · 854 líneas · 29.7 KB Historia Quién tocó qué crudo

feat(nakui): Fase 3 del ERP — ficha de detalle 6588d0ed6 · sergio ·

1//! Schema declarativo de la metainterfaz (nahual meta-schema).
2//!
3//! Cada **módulo** declara aquí qué menús, vistas, listas y
4//! formularios expone, sin escribir código GPUI ni Rust. Cualquier
5//! runtime de UI dirigida por datos (Nakui hoy, otros mañana) lo
6//! carga y monta la UI correspondiente.
7//!
8//! ## Filosofía
9//!
10//! - **UI como datos**: agregar un módulo = escribir un JSON o un
11//!   `.ncl`. Ningún recompile, ningún acoplamiento con el binario
12//!   del runtime.
13//! - **Backend-agnostic**: este crate sólo describe la *forma* de
14//!   la UI. La conexión a un store/log/executor concretos vive en
15//!   el runtime que lo consume (ej: el meta-runtime de Nakui que
16//!   wirea esto a `nakui_core` + KCL post-checks).
17//! - **Schema primero, semántica después**: validación semántica
18//!   (referencias rotas a entities, campos faltantes, etc.) vive
19//!   en 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 declarativo de UI.
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 opaco al backend que va a manejar este módulo. Lo
70    /// interpreta el runtime concreto (no este schema).
71    ///
72    /// Convención actual de Nakui: directorio con `nsmc.json` +
73    /// schemas KCL + scripts Rhai. Cuando está set, el runtime
74    /// Nakui carga un `Executor` para ese path y permite que las
75    /// acciones `Morphism { name }` despachen al pipeline real
76    /// (compute → log → apply). Otro backend puede ignorar este
77    /// campo o darle un significado distinto.
78    ///
79    /// Path resuelto relativo al directorio del `module.json`
80    /// o absoluto.
81    ///
82    /// Si es `None`, los backends que requieren manifest deberían
83    /// degradar (toast informativo, deshabilitar morphisms, etc.);
84    /// los `SeedEntity` siguen funcionando — son altas
85    /// administrativas que no necesitan validación de manifest.
86    ///
87    /// Nombre conservado por compat con módulos ya escritos.
88    /// Renombrar a `backend_module_dir` o similar si emerge un
89    /// segundo backend que también lo use.
90    #[serde(default, alias = "backend_module_dir")]
91    pub nakui_module_dir: Option<String>,
92
93    /// Items del menú. Cada uno apunta a una key de `views`. Orden
94    /// importa (es el orden en que se presentan en el sidebar).
95    pub menu: Vec<MenuItem>,
96
97    /// Vistas indexadas por key. Las keys son referenciadas por
98    /// `MenuItem.view` y por `Action::OpenView.view`.
99    pub views: BTreeMap<String, View>,
100}
101
102/// Item del menú lateral.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct MenuItem {
105    pub label: String,
106    /// Key de la vista a abrir. Debe existir en `Module.views`.
107    pub view: String,
108    /// Icono opcional (texto unicode o emoji; el runtime decide
109    /// renderización).
110    #[serde(default)]
111    pub icon: Option<String>,
112}
113
114/// Una vista renderizada en el área principal cuando su menú es seleccionado.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(tag = "kind", rename_all = "snake_case")]
117pub enum View {
118    /// Tabla de instancias de una entity, columnas + acciones por fila.
119    List(ListView),
120    /// Formulario de creación / edición.
121    Form(FormView),
122    /// Ficha de un record: sus campos + listas de records relacionados.
123    Detail(DetailView),
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ListView {
128    pub title: String,
129    /// Entity (del nakui store) cuyas instancias se listan.
130    pub entity: String,
131    /// Columnas a mostrar. Orden importa.
132    pub columns: Vec<Column>,
133    /// Acciones disponibles a nivel de la lista (ej. "Nuevo" → form).
134    /// Renderizadas como botones en el header.
135    #[serde(default)]
136    pub actions: Vec<Action>,
137    /// Cuando está set, se muestra una caja de búsqueda que filtra
138    /// las filas por substring contra los valores de estas columnas.
139    #[serde(default)]
140    pub search_in: Vec<String>,
141    /// Si está set, cada fila gana un botón 👁 que abre esta vista
142    /// (debe ser una `View::Detail`) para el record de la fila.
143    #[serde(default)]
144    pub row_detail: Option<String>,
145}
146
147/// Ficha de un record: sus campos + listas de records relacionados.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct DetailView {
150    pub title: String,
151    /// Entity del record que se muestra.
152    pub entity: String,
153    /// Campos a mostrar, en orden. Reusa [`Column`] (label + field +
154    /// `ref_entity` + `format`; el `weight` se ignora en la ficha).
155    #[serde(default)]
156    pub fields: Vec<Column>,
157    /// Listas de records relacionados (back-references).
158    #[serde(default)]
159    pub related: Vec<RelatedList>,
160}
161
162/// Una lista de records relacionados dentro de una [`DetailView`]: los
163/// records de otra entity cuyo campo `via_field` apunta al record que
164/// se está viendo.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct RelatedList {
167    pub title: String,
168    /// Entity de los records relacionados.
169    pub entity: String,
170    /// Campo de esa entity cuyo valor (UUID) referencia al record
171    /// actual. El runtime filtra `record[via_field] == id_actual`.
172    pub via_field: String,
173    pub columns: Vec<Column>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct Column {
178    /// Path del campo dentro del record. Para tipos planos: nombre.
179    /// Para nested: puntos (`address.city`). El runtime navega.
180    pub field: String,
181    /// Texto del header.
182    pub label: String,
183    /// Ancho relativo (peso flex). Default 1.
184    #[serde(default = "default_weight")]
185    pub weight: f32,
186    /// Si está set, la celda resuelve su valor (un UUID) al label
187    /// legible del record de esta entity, en vez de mostrar el UUID
188    /// crudo. Para columnas que son referencias a otra entity.
189    #[serde(default)]
190    pub ref_entity: Option<String>,
191    /// Formato de presentación del valor de la celda.
192    #[serde(default)]
193    pub format: ValueFormat,
194}
195
196fn default_weight() -> f32 {
197    1.0
198}
199
200/// Formato de presentación de un valor en una celda de lista.
201#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(tag = "kind", rename_all = "snake_case")]
203pub enum ValueFormat {
204    /// Sin formato — el valor se muestra crudo. Default.
205    #[default]
206    Plain,
207    /// Entero/decimal con separador de miles (`12000` → `12,000`).
208    Number,
209    /// Moneda: separador de miles + símbolo prefijo (`12000` → `$12,000`).
210    Currency { symbol: String },
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct FormView {
215    pub title: String,
216    /// Entity destino del seed/morphism al submit.
217    pub entity: String,
218    pub fields: Vec<FieldSpec>,
219    /// Acción al submit. Típicamente `Action::SeedEntity` para alta
220    /// directa o `Action::Morphism` cuando hay validación/cálculo.
221    pub on_submit: Action,
222}
223
224/// Especificación de un campo de formulario o columna implícita.
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct FieldSpec {
227    /// Nombre del campo en el record (clave del JSON).
228    pub name: String,
229    /// Etiqueta legible.
230    pub label: String,
231    /// Tipo del valor — define el widget de input + parseo.
232    pub kind: FieldKind,
233    /// Valor por defecto al abrir el form (string raw; el parseo
234    /// según `kind` lo hace el runtime).
235    #[serde(default)]
236    pub default: Option<String>,
237    /// Si `true`, el form rechaza submit con campo vacío.
238    #[serde(default)]
239    pub required: bool,
240    /// Texto de ayuda mostrado bajo el input.
241    #[serde(default)]
242    pub help: Option<String>,
243    /// Si `kind == EntityRef`, indica qué entity referencia. Sin
244    /// esto, el runtime no sabe qué records ofrecer en el selector
245    /// y la validación `Module::validate` rechaza el manifest.
246    /// Para los demás kinds, este campo se ignora.
247    #[serde(default)]
248    pub ref_entity: Option<String>,
249    /// Opciones de un campo `kind == Select`. Ignorado para los demás
250    /// kinds. `Module::validate` exige que un Select las tenga.
251    #[serde(default)]
252    pub options: Vec<SelectOption>,
253}
254
255#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
256#[serde(rename_all = "snake_case")]
257pub enum FieldKind {
258    /// Texto libre.
259    Text,
260    /// Texto multilínea.
261    Multiline,
262    /// Número (i64 o f64; runtime intenta parsear como i64 primero).
263    Number,
264    /// Booleano (renderizado como checkbox).
265    Boolean,
266    /// Fecha (formato ISO YYYY-MM-DD; almacenada como string).
267    Date,
268    /// Referencia a otro record. El runtime renderiza un selector
269    /// clickable de records existentes de la entity declarada en
270    /// `FieldSpec.ref_entity`; el value almacenado es el UUID del
271    /// seleccionado, parseable como cualquier text/UUID al submit.
272    EntityRef,
273    /// Valor elegido de un conjunto cerrado declarado en
274    /// `FieldSpec.options`. El runtime lo renderiza como selección
275    /// (no texto libre). `Module::validate` exige `options` no vacío.
276    Select,
277    /// Identificador autogenerado (UUID v4). El runtime lo rellena al
278    /// abrir el formulario; el usuario no lo teclea ni lo edita. Para
279    /// los ids de idempotencia que piden los morfismos.
280    AutoId,
281}
282
283/// Una opción de un campo [`FieldKind::Select`].
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct SelectOption {
286    /// Valor que se guarda (lo que recibe el backend o el morfismo).
287    pub value: String,
288    /// Etiqueta legible. Si se omite, se muestra el `value` crudo.
289    #[serde(default)]
290    pub label: Option<String>,
291}
292
293impl SelectOption {
294    /// Texto a mostrar: `label` si está, sino el `value`.
295    pub fn display(&self) -> &str {
296        self.label.as_deref().unwrap_or(&self.value)
297    }
298}
299
300/// Acciones disparables por menús, botones o submit de formularios.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(tag = "kind", rename_all = "snake_case")]
303pub enum Action {
304    /// Cambia la vista activa a otra del mismo módulo.
305    OpenView {
306        view: String,
307        /// Etiqueta del botón / item; default = nombre humano de la vista.
308        #[serde(default)]
309        label: Option<String>,
310    },
311    /// Crea un seed directo en la entity con los valores del form.
312    /// Equivalente a `nakui_core::event_log::seed_and_log`.
313    /// Sin pipeline de morphism — alta administrativa.
314    SeedEntity {
315        entity: String,
316        /// Tras el submit exitoso, opcionalmente abrir esta vista
317        /// (por convención: `"list"` para volver al listado).
318        #[serde(default)]
319        next_view: Option<String>,
320    },
321    /// Ejecuta un morphism declarado en el manifest del módulo
322    /// nakui-core (cuyo path vive en `Module.nakui_module_dir`).
323    /// Inputs (records existentes) y params (valores escalares) se
324    /// mapean desde los campos del form.
325    Morphism {
326        /// Nombre del morphism declarado en `nsmc.json` del manifest
327        /// nakui apuntado por el módulo.
328        name: String,
329        /// Mapeo `role → field_name`: por cada input declarado en
330        /// el `MorphismSpec.inputs`, indica qué field del form
331        /// contiene el UUID del record. El runtime parsea el value
332        /// como `Uuid` y lo pasa como input al `execute_and_log`.
333        ///
334        /// Ej: `{ "stock": "stock_id", "caja": "caja_id" }` para un
335        /// morphism `vender` que toma roles `stock` y `caja`.
336        #[serde(default)]
337        inputs: BTreeMap<String, String>,
338        /// Lista de fields del form cuyos values van al `params`
339        /// JSON object pasado al morphism. Si está vacío, todos los
340        /// fields que no estén en `inputs` van a params.
341        #[serde(default)]
342        params: Vec<String>,
343        #[serde(default)]
344        next_view: Option<String>,
345    },
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct EntitySpec {
350    /// Nombre de la entity (clave en el store). Único dentro del módulo.
351    pub name: String,
352    /// Label legible (singular).
353    pub label: String,
354    /// Campos esperados en records de esta entity. Usados por el
355    /// runtime para inferir columnas / validar formularios.
356    #[serde(default)]
357    pub fields: Vec<FieldSpec>,
358}
359
360#[derive(Debug, Error)]
361pub enum SchemaError {
362    #[error("io leyendo {path}: {source}")]
363    Io {
364        path: PathBuf,
365        #[source]
366        source: std::io::Error,
367    },
368    #[error("parseo de {path}: {source}")]
369    Parse {
370        path: PathBuf,
371        #[source]
372        source: serde_json::Error,
373    },
374    #[error("módulo {id}: la vista '{view}' referenciada por menu_item '{item}' no existe")]
375    DanglingMenuView {
376        id: String,
377        item: String,
378        view: String,
379    },
380    #[error("módulos con id duplicado: '{id}' aparece en {first} y {second}")]
381    DuplicateModuleId {
382        id: String,
383        first: PathBuf,
384        second: PathBuf,
385    },
386    #[error(
387        "módulo {id} vista '{view}': field '{field}' tiene kind=entity_ref \
388         pero no declaró ref_entity"
389    )]
390    EntityRefMissingTarget {
391        id: String,
392        view: String,
393        field: String,
394    },
395    #[error(
396        "módulo {id} vista '{view}': field '{field}' tiene kind=select \
397         pero no declaró options"
398    )]
399    SelectMissingOptions {
400        id: String,
401        view: String,
402        field: String,
403    },
404    #[error(
405        "módulo {id} vista '{view}': row_detail='{target}' no apunta a \
406         una vista kind=detail"
407    )]
408    RowDetailInvalid {
409        id: String,
410        view: String,
411        target: String,
412    },
413}
414
415impl Module {
416    /// Carga un module.json desde disco. Validación estructural
417    /// posterior (vistas referenciadas existen, etc.) la ejecuta el
418    /// runtime — acá sólo parseamos.
419    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SchemaError> {
420        let path = path.as_ref();
421        let bytes = std::fs::read(path).map_err(|source| SchemaError::Io {
422            path: path.to_path_buf(),
423            source,
424        })?;
425        serde_json::from_slice(&bytes).map_err(|source| SchemaError::Parse {
426            path: path.to_path_buf(),
427            source,
428        })
429    }
430
431    /// Validación post-parse:
432    /// - Cada `MenuItem.view` debe existir en `views`.
433    /// - Cada `FieldSpec` con `kind=EntityRef` debe declarar
434    ///   `ref_entity`.
435    pub fn validate(&self) -> Result<(), SchemaError> {
436        for item in &self.menu {
437            if !self.views.contains_key(&item.view) {
438                return Err(SchemaError::DanglingMenuView {
439                    id: self.id.clone(),
440                    item: item.label.clone(),
441                    view: item.view.clone(),
442                });
443            }
444        }
445        for (view_key, view) in &self.views {
446            match view {
447                View::Form(form) => {
448                    for f in &form.fields {
449                        if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() {
450                            return Err(SchemaError::EntityRefMissingTarget {
451                                id: self.id.clone(),
452                                view: view_key.clone(),
453                                field: f.name.clone(),
454                            });
455                        }
456                        if f.kind == FieldKind::Select && f.options.is_empty() {
457                            return Err(SchemaError::SelectMissingOptions {
458                                id: self.id.clone(),
459                                view: view_key.clone(),
460                                field: f.name.clone(),
461                            });
462                        }
463                    }
464                }
465                View::List(list) => {
466                    if let Some(target) = &list.row_detail {
467                        if !matches!(self.views.get(target), Some(View::Detail(_))) {
468                            return Err(SchemaError::RowDetailInvalid {
469                                id: self.id.clone(),
470                                view: view_key.clone(),
471                                target: target.clone(),
472                            });
473                        }
474                    }
475                }
476                View::Detail(_) => {}
477            }
478        }
479        Ok(())
480    }
481}
482
483/// Carga todos los `module.json` encontrados bajo `dir` (recursivo
484/// 1 nivel — espera `dir/<modulo>/module.json`). Devuelve la lista
485/// ordenada por id, validada.
486///
487/// Falla si: I/O, parseo, módulo inválido, o ids duplicados.
488pub fn load_modules_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Module>, SchemaError> {
489    let dir = dir.as_ref();
490    let mut modules: Vec<(PathBuf, Module)> = Vec::new();
491    let entries = std::fs::read_dir(dir).map_err(|source| SchemaError::Io {
492        path: dir.to_path_buf(),
493        source,
494    })?;
495    for entry in entries.flatten() {
496        let p = entry.path();
497        if p.is_dir() {
498            let manifest = p.join("module.json");
499            if manifest.exists() {
500                let m = Module::from_path(&manifest)?;
501                m.validate()?;
502                modules.push((manifest, m));
503            }
504        }
505    }
506    // Detectar duplicados de id.
507    modules.sort_by(|a, b| a.1.id.cmp(&b.1.id));
508    let mut prev: Option<&(PathBuf, Module)> = None;
509    for cur in &modules {
510        if let Some(p) = prev {
511            if p.1.id == cur.1.id {
512                return Err(SchemaError::DuplicateModuleId {
513                    id: cur.1.id.clone(),
514                    first: p.0.clone(),
515                    second: cur.0.clone(),
516                });
517            }
518        }
519        prev = Some(cur);
520    }
521    Ok(modules.into_iter().map(|(_, m)| m).collect())
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    fn sample_module() -> Module {
529        Module {
530            id: "customers".into(),
531            label: "Clientes".into(),
532            description: Some("Gestión de clientes".into()),
533            nakui_module_dir: None,
534            entities: vec![EntitySpec {
535                name: "customer".into(),
536                label: "Cliente".into(),
537                fields: vec![
538                    FieldSpec {
539                        name: "name".into(),
540                        label: "Nombre".into(),
541                        kind: FieldKind::Text,
542                        default: None,
543                        required: true,
544                        help: None,
545                        ref_entity: None,
546                        options: Vec::new(),
547                    },
548                    FieldSpec {
549                        name: "email".into(),
550                        label: "Email".into(),
551                        kind: FieldKind::Text,
552                        default: None,
553                        required: false,
554                        help: Some("Opcional".into()),
555                        ref_entity: None,
556                        options: Vec::new(),
557                    },
558                ],
559            }],
560            menu: vec![
561                MenuItem {
562                    label: "Listar".into(),
563                    view: "list".into(),
564                    icon: None,
565                },
566                MenuItem {
567                    label: "Nuevo".into(),
568                    view: "form".into(),
569                    icon: None,
570                },
571            ],
572            views: BTreeMap::from([
573                (
574                    "list".into(),
575                    View::List(ListView {
576                        title: "Clientes".into(),
577                        entity: "customer".into(),
578                        columns: vec![
579                            Column {
580                                field: "name".into(),
581                                label: "Nombre".into(),
582                                weight: 2.0,
583                                ref_entity: None,
584                                format: ValueFormat::Plain,
585                            },
586                            Column {
587                                field: "email".into(),
588                                label: "Email".into(),
589                                weight: 3.0,
590                                ref_entity: None,
591                                format: ValueFormat::Plain,
592                            },
593                        ],
594                        actions: vec![Action::OpenView {
595                            view: "form".into(),
596                            label: Some("Nuevo".into()),
597                        }],
598                        search_in: vec!["name".into(), "email".into()],
599                        row_detail: None,
600                    }),
601                ),
602                (
603                    "form".into(),
604                    View::Form(FormView {
605                        title: "Nuevo cliente".into(),
606                        entity: "customer".into(),
607                        fields: vec![FieldSpec {
608                            name: "name".into(),
609                            label: "Nombre".into(),
610                            kind: FieldKind::Text,
611                            default: None,
612                            required: true,
613                            help: None,
614                            ref_entity: None,
615                            options: Vec::new(),
616                        }],
617                        on_submit: Action::SeedEntity {
618                            entity: "customer".into(),
619                            next_view: Some("list".into()),
620                        },
621                    }),
622                ),
623            ]),
624        }
625    }
626
627    #[test]
628    fn module_serialize_deserialize_roundtrip() {
629        let m = sample_module();
630        let s = serde_json::to_string_pretty(&m).unwrap();
631        let m2: Module = serde_json::from_str(&s).unwrap();
632        assert_eq!(m.id, m2.id);
633        assert_eq!(m.menu.len(), m2.menu.len());
634        assert_eq!(m.views.len(), m2.views.len());
635    }
636
637    #[test]
638    fn validate_passes_for_well_formed_module() {
639        sample_module().validate().unwrap();
640    }
641
642    #[test]
643    fn validate_catches_dangling_menu_view() {
644        let mut m = sample_module();
645        m.menu.push(MenuItem {
646            label: "Roto".into(),
647            view: "no_existe".into(),
648            icon: None,
649        });
650        let err = m.validate().unwrap_err();
651        assert!(matches!(err, SchemaError::DanglingMenuView { .. }));
652    }
653
654    #[test]
655    fn from_path_loads_real_file() {
656        let tmp = tempfile::tempdir().unwrap();
657        let path = tmp.path().join("module.json");
658        std::fs::write(&path, serde_json::to_vec_pretty(&sample_module()).unwrap()).unwrap();
659        let m = Module::from_path(&path).unwrap();
660        assert_eq!(m.id, "customers");
661    }
662
663    #[test]
664    fn load_modules_from_dir_finds_subdirs() {
665        let tmp = tempfile::tempdir().unwrap();
666        let cust_dir = tmp.path().join("customers");
667        std::fs::create_dir(&cust_dir).unwrap();
668        let mut m1 = sample_module();
669        m1.id = "customers".into();
670        std::fs::write(
671            cust_dir.join("module.json"),
672            serde_json::to_vec_pretty(&m1).unwrap(),
673        )
674        .unwrap();
675        let prod_dir = tmp.path().join("products");
676        std::fs::create_dir(&prod_dir).unwrap();
677        let mut m2 = sample_module();
678        m2.id = "products".into();
679        std::fs::write(
680            prod_dir.join("module.json"),
681            serde_json::to_vec_pretty(&m2).unwrap(),
682        )
683        .unwrap();
684
685        let mods = load_modules_from_dir(tmp.path()).unwrap();
686        assert_eq!(mods.len(), 2);
687        assert_eq!(mods[0].id, "customers");
688        assert_eq!(mods[1].id, "products");
689    }
690
691    #[test]
692    fn validate_catches_entity_ref_without_target() {
693        let mut m = sample_module();
694        // Inyectamos un form con un campo EntityRef sin ref_entity.
695        m.views.insert(
696            "broken_form".into(),
697            View::Form(FormView {
698                title: "Roto".into(),
699                entity: "customer".into(),
700                fields: vec![FieldSpec {
701                    name: "ref_to_nowhere".into(),
702                    label: "Referencia".into(),
703                    kind: FieldKind::EntityRef,
704                    default: None,
705                    required: true,
706                    help: None,
707                    ref_entity: None,
708                    options: Vec::new(),
709                }],
710                on_submit: Action::SeedEntity {
711                    entity: "customer".into(),
712                    next_view: None,
713                },
714            }),
715        );
716        let err = m.validate().unwrap_err();
717        assert!(
718            matches!(err, SchemaError::EntityRefMissingTarget { .. }),
719            "got: {err:?}"
720        );
721    }
722
723    #[test]
724    fn entity_ref_with_target_validates_clean() {
725        let mut m = sample_module();
726        m.views.insert(
727            "ok_form".into(),
728            View::Form(FormView {
729                title: "OK".into(),
730                entity: "customer".into(),
731                fields: vec![FieldSpec {
732                    name: "supplier".into(),
733                    label: "Proveedor".into(),
734                    kind: FieldKind::EntityRef,
735                    default: None,
736                    required: true,
737                    help: None,
738                    ref_entity: Some("supplier".into()),
739                    options: Vec::new(),
740                }],
741                on_submit: Action::SeedEntity {
742                    entity: "customer".into(),
743                    next_view: None,
744                },
745            }),
746        );
747        m.menu.push(MenuItem {
748            label: "OK".into(),
749            view: "ok_form".into(),
750            icon: None,
751        });
752        m.validate().unwrap();
753    }
754
755    #[test]
756    fn select_without_options_is_rejected() {
757        let mut m = sample_module();
758        m.views.insert(
759            "sel_form".into(),
760            View::Form(FormView {
761                title: "Select roto".into(),
762                entity: "customer".into(),
763                fields: vec![FieldSpec {
764                    name: "estado".into(),
765                    label: "Estado".into(),
766                    kind: FieldKind::Select,
767                    default: None,
768                    required: true,
769                    help: None,
770                    ref_entity: None,
771                    options: Vec::new(),
772                }],
773                on_submit: Action::SeedEntity {
774                    entity: "customer".into(),
775                    next_view: None,
776                },
777            }),
778        );
779        let err = m.validate().unwrap_err();
780        assert!(
781            matches!(err, SchemaError::SelectMissingOptions { .. }),
782            "got: {err:?}"
783        );
784    }
785
786    #[test]
787    fn select_with_options_validates_clean() {
788        let mut m = sample_module();
789        m.views.insert(
790            "sel_form".into(),
791            View::Form(FormView {
792                title: "Select OK".into(),
793                entity: "customer".into(),
794                fields: vec![FieldSpec {
795                    name: "estado".into(),
796                    label: "Estado".into(),
797                    kind: FieldKind::Select,
798                    default: None,
799                    required: true,
800                    help: None,
801                    ref_entity: None,
802                    options: vec![
803                        SelectOption {
804                            value: "activo".into(),
805                            label: Some("Activo".into()),
806                        },
807                        SelectOption {
808                            value: "baja".into(),
809                            label: None,
810                        },
811                    ],
812                }],
813                on_submit: Action::SeedEntity {
814                    entity: "customer".into(),
815                    next_view: None,
816                },
817            }),
818        );
819        m.menu.push(MenuItem {
820            label: "Sel".into(),
821            view: "sel_form".into(),
822            icon: None,
823        });
824        m.validate().unwrap();
825    }
826
827    #[test]
828    fn select_option_display_falls_back_to_value() {
829        let with_label = SelectOption {
830            value: "x".into(),
831            label: Some("Equis".into()),
832        };
833        let bare = SelectOption {
834            value: "y".into(),
835            label: None,
836        };
837        assert_eq!(with_label.display(), "Equis");
838        assert_eq!(bare.display(), "y");
839    }
840
841    #[test]
842    fn load_modules_detects_duplicate_id() {
843        let tmp = tempfile::tempdir().unwrap();
844        let a_dir = tmp.path().join("a");
845        let b_dir = tmp.path().join("b");
846        std::fs::create_dir_all(&a_dir).unwrap();
847        std::fs::create_dir_all(&b_dir).unwrap();
848        let m = sample_module(); // id = "customers"
849        std::fs::write(a_dir.join("module.json"), serde_json::to_vec(&m).unwrap()).unwrap();
850        std::fs::write(b_dir.join("module.json"), serde_json::to_vec(&m).unwrap()).unwrap();
851        let err = load_modules_from_dir(tmp.path()).unwrap_err();
852        assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
853    }
854}