refactor(yahweh): Fase 1 — nakui-ui-schema → yahweh-meta-schema

Primer paso del refactor yahweh. El schema de UI declarativa no
tiene acoplamiento real con Nakui (sólo dep en serde/thiserror) —
movemos a yahweh para que cualquier app metadata-driven lo use sin
pasar por nakui.

Mecánico:
- git mv crates/modules/nakui/ui-schema → crates/modules/ui_engine/libs/meta-schema.
- Crate name: nakui-ui-schema → yahweh-meta-schema.
- Workspace members[] actualizado (sección yahweh, no nakui).
- Consumers actualizados: brahman-cards (Cargo.toml + lib.rs +
  readers.rs), nakui-ui (Cargo.toml + main.rs).
- Self-test (example_modules.rs): import + path rebase (5 niveles
  arriba ahora).

Documental:
- Doc del crate ahora dice "metainterfaz (yahweh meta-schema)" +
  "backend-agnostic" en filosofía.
- Module.nakui_module_dir documentado como "path opaco al backend";
  se conserva el nombre por compat con módulos ya escritos +
  serde alias "backend_module_dir" para futuro rename suave.

Tests: 13 yahweh-meta-schema + 26 brahman-cards + 48 nakui-ui
verdes. Workspace build verde.

NO hace Fase 1: mover widgets a yahweh (Fase 2), trait MetaBackend
(Fase 3), renombrar nakui_module_dir.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-09 23:38:25 +00:00
parent f6361bbdca
commit f5987d9cfc
12 changed files with 149 additions and 65 deletions
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "nakui-ui-schema"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Nakui UI metainterface schema: módulos declaran menús, listas y formularios como datos (JSON); el runtime los carga y renderiza sin código compilado por módulo."
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
-634
View File
@@ -1,634 +0,0 @@
//! Schema declarativo de la metainterfaz Nakui.
//!
//! Cada **módulo** Nakui (customers, products, sales, ...) declara
//! aquí qué menús, vistas, listas y formularios expone, sin escribir
//! código GPUI ni Rust. El runtime ([`nakui-ui` crate]) lee estos
//! `module.json` desde un directorio y monta automáticamente la UI
//! correspondiente.
//!
//! ## Filosofía
//!
//! - **UI como datos**: agregar un módulo = escribir un JSON. Ningún
//! recompile, ningún acoplamiento con el binario del runtime.
//! - **Persistencia universal**: el runtime conecta cada vista al
//! `nakui_core::store::Store` actual; los formularios escriben
//! ops por la pipeline normal de Nakui (executor + event log).
//! - **Schema primero, semántica después**: este crate sólo define
//! la *forma* de los manifests. 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 de la metainterfaz Nakui.
#[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<String>,
/// 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<EntitySpec>,
/// Path a un módulo nakui-core (directorio con `nsmc.json` +
/// schemas KCL + scripts Rhai). Cuando está set, el runtime
/// carga un `Executor` para ese path y permite que las acciones
/// `Morphism { name }` despachen al pipeline real
/// (compute → log → apply).
///
/// Path resuelto relativo al directorio del `module.json`
/// o absoluto.
///
/// Si es `None`, las acciones `Morphism` quedan deshabilitadas
/// (toast informativo al usuario). Las acciones `SeedEntity`
/// siguen funcionando sin esto — son altas administrativas que
/// no necesitan validación de manifest.
#[serde(default)]
pub nakui_module_dir: Option<String>,
/// 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<MenuItem>,
/// Vistas indexadas por key. Las keys son referenciadas por
/// `MenuItem.view` y por `Action::OpenView.view`.
pub views: BTreeMap<String, View>,
}
/// 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<String>,
}
/// 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<Column>,
/// Acciones disponibles a nivel de la lista (ej. "Nuevo" → form).
/// Renderizadas como botones en el header.
#[serde(default)]
pub actions: Vec<Action>,
/// 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<String>,
}
#[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<FieldSpec>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
}
#[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<String>,
},
/// 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<String>,
},
/// 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<String, String>,
/// 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<String>,
#[serde(default)]
next_view: Option<String>,
},
}
#[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<FieldSpec>,
}
#[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<Path>) -> Result<Self, SchemaError> {
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/<modulo>/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<Path>) -> Result<Vec<Module>, 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 { .. }));
}
}
@@ -1,116 +0,0 @@
//! Validación de los 6 módulos demo en `examples/nakui-modules/`.
//!
//! Si esto verde, garantizamos que un usuario que clone el repo y
//! corra `NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui`
//! va a obtener los 6 módulos cargados sin tocar nada.
use nakui_ui_schema::{load_modules_from_dir, FieldKind, View};
fn examples_dir() -> std::path::PathBuf {
// Tests corren desde el dir del crate; el repo root está dos
// niveles arriba: crates/modules/nakui/ui-schema → repo.
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
here.join("../../../..").join("examples/nakui-modules")
}
#[test]
fn loads_all_demo_modules() {
let dir = examples_dir();
let mods = load_modules_from_dir(&dir).unwrap_or_else(|e| {
panic!("load failed for {}: {e}", dir.display());
});
let ids: Vec<&str> = mods.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
vec![
"customers",
"inventory_movements",
"invoices",
"products",
"sales_engine",
"sales_orders",
"suppliers",
],
"expected 7 modules in alphabetical order \
(sales_engine se sumó al wirear Action::Morphism)"
);
}
#[test]
fn sales_engine_declares_nakui_module_dir_and_morphism() {
// Sanity del módulo demo de morphism: nakui_module_dir set,
// y al menos una vista con Action::Morphism en su on_submit.
let mods = load_modules_from_dir(examples_dir()).unwrap();
let sales = mods
.iter()
.find(|m| m.id == "sales_engine")
.expect("sales_engine debe estar");
assert!(
sales.nakui_module_dir.is_some(),
"sales_engine debería declarar nakui_module_dir"
);
let has_morphism_view = sales.views.values().any(|v| match v {
nakui_ui_schema::View::Form(form) => {
matches!(form.on_submit, nakui_ui_schema::Action::Morphism { .. })
}
_ => false,
});
assert!(
has_morphism_view,
"sales_engine debería tener al menos una Form con Action::Morphism"
);
}
#[test]
fn every_demo_module_has_list_and_form_views() {
let mods = load_modules_from_dir(examples_dir()).unwrap();
for m in &mods {
let mut has_list = false;
let mut has_form = false;
for v in m.views.values() {
match v {
View::List(_) => has_list = true,
View::Form(_) => has_form = true,
}
}
assert!(
has_list && has_form,
"module {} should expose at least one list + one form view",
m.id
);
}
}
#[test]
fn every_demo_form_field_kind_is_recognized() {
// Sanity: ningún módulo demo usa un kind que no esté en el enum
// (sería rechazado al parsear, pero check explícito no daña).
let mods = load_modules_from_dir(examples_dir()).unwrap();
for m in &mods {
for v in m.views.values() {
if let View::Form(form) = v {
for f in &form.fields {
let _ok = matches!(
f.kind,
FieldKind::Text
| FieldKind::Multiline
| FieldKind::Number
| FieldKind::Boolean
| FieldKind::Date
);
}
}
}
}
}
#[test]
fn every_module_validates_clean() {
// validate() chequea que cada MenuItem.view exista en views.
// Un typo en cualquiera de los 6 módulos haría fallar este test.
let mods = load_modules_from_dir(examples_dir()).unwrap();
for m in &mods {
m.validate()
.unwrap_or_else(|e| panic!("module {} failed validate: {e}", m.id));
}
}