f5987d9cfc
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>
317 lines
12 KiB
Rust
317 lines
12 KiB
Rust
//! `brahman-cards` — brazo unificado de Cards.
|
|
//!
|
|
//! Brahman maneja varios formatos legítimos de "Card" (la unidad
|
|
//! declarativa que describe identidad, datos, módulos, widgets, ...).
|
|
//! Cada formato vive en su propio crate de origen y conserva su shape
|
|
//! público; lo que este crate aporta es **un único punto de entrada**
|
|
//! que sabe interpretar cada uno de ellos y proyectarlos a una sola
|
|
//! estructura interna canónica [`Card`].
|
|
//!
|
|
//! Diseño:
|
|
//!
|
|
//! ```text
|
|
//! ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
|
//! │ Ente JSON │ │ Monad JSON │ │ UiModule │ … futuro
|
|
//! │ (brahman- │ │ (nouser- │ │ (nakui-ui- │
|
|
//! │ card) │ │ card) │ │ schema) │
|
|
//! └─────┬───────┘ └──────┬───────┘ └──────┬──────┘
|
|
//! │ │ │
|
|
//! └────────┬────────┴────────┬────────┘
|
|
//! │ brahman-cards │
|
|
//! │ (este crate) │
|
|
//! └────────┬────────┘
|
|
//! │
|
|
//! ┌──────▼──────┐
|
|
//! │ `Card` │ ← único tipo canónico
|
|
//! │ wrapper │ que consumen UI runtime,
|
|
//! │ común + │ storage, DHT, wire.
|
|
//! │ variant │
|
|
//! │ body │
|
|
//! └─────────────┘
|
|
//! ```
|
|
//!
|
|
//! Los formatos NO se disuelven. Si en el futuro hay que soportar un
|
|
//! formato simplificado nuevo, se agrega un reader acá y nadie aguas
|
|
//! abajo se entera — siguen recibiendo `Card`.
|
|
//!
|
|
//! V1 (este commit) sólo soporta inputs JSON. La extensión a Nickel
|
|
//! (con templates de defaults vía merge nativo de Nickel) llega en un
|
|
//! commit separado para aislar la dependencia `nickel-lang-core`.
|
|
|
|
#![forbid(unsafe_code)]
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::Path;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use thiserror::Error;
|
|
|
|
pub use brahman_card::Card as EnteCard;
|
|
pub use nouser_card::MonadManifest;
|
|
pub use yahweh_meta_schema::Module as UiModuleSpec;
|
|
|
|
/// Estructura canónica única que consumen los downstream del sistema
|
|
/// (UI runtime, storage, DHT, wire). Cada formato input se proyecta
|
|
/// a ésta vía un reader del brazo.
|
|
///
|
|
/// El wrapper común agrupa lo que TODOS los formatos comparten
|
|
/// (identidad legible + extensiones forward-compat); el body preserva
|
|
/// el typing rico de cada dominio sin colapsarlos.
|
|
// PartialEq se omite porque algunos body variants vienen de crates
|
|
// que no lo implementan (MonadManifest, yahweh_meta_schema::Module).
|
|
// Si downstream necesita igualdad, comparar via JSON round-trip o
|
|
// agregar PartialEq en los crates origen.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Card {
|
|
/// Identificador opaco. String en el wrapper para no obligar a
|
|
/// los formatos a un mismo tipo concreto (Ente/Monad usan ULID,
|
|
/// UiModule usa slug human-friendly como `"sales_engine"`).
|
|
/// Cada reader documenta qué formato exige.
|
|
pub id: String,
|
|
|
|
/// Versión del schema canónico de este wrapper. Bump = romper
|
|
/// compat de los consumers downstream. Distinto de los
|
|
/// `schema_version` internos de cada body variant, que siguen
|
|
/// su propio versioning.
|
|
pub schema_version: u16,
|
|
|
|
/// Ancestro del que esta Card desciende (si aplica). Significado
|
|
/// específico al body variant (Ente: lineage del proceso; Monad:
|
|
/// split/merge de Mónada padre; UiModule: típicamente None).
|
|
#[serde(default)]
|
|
pub lineage: Option<String>,
|
|
|
|
/// Etiqueta humana legible. Cada reader la deriva del campo
|
|
/// equivalente del input (label/title/etc.).
|
|
pub label: String,
|
|
|
|
/// Campos no reconocidos del input se preservan acá. Permite
|
|
/// forward-compat: leer un input con campos nuevos no rompe la
|
|
/// carga, y volver a serializar conserva el extra.
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub extensions: BTreeMap<String, Value>,
|
|
|
|
/// Cuerpo tipado por dominio. La elección del variant es
|
|
/// responsabilidad del reader (basada en el input shape).
|
|
pub body: CardBody,
|
|
}
|
|
|
|
/// Versión actual del schema canónico de [`Card`]. Bump cuando cambie
|
|
/// la shape del wrapper o las invariantes que comparten todos los
|
|
/// variants.
|
|
pub const CARD_SCHEMA_VERSION: u16 = 1;
|
|
|
|
/// Variantes tipadas del body de [`Card`]. Una por dominio.
|
|
///
|
|
/// **Convención de extensión**: agregar un variant nuevo aquí + un
|
|
/// reader que produzca ese variant. Los consumers que sólo manejen
|
|
/// algunos variants pueden hacer `match { Ente(..) => ..., _ => skip }`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
pub enum CardBody {
|
|
/// Entidad runtime con proceso/payload/supervision (lo que era
|
|
/// `brahman_card::Card` directo).
|
|
Ente(EnteCard),
|
|
|
|
/// Agrupación semántica de archivos (Mónada de Nouser). No tiene
|
|
/// proceso; describe membership + signals semánticas (centroid,
|
|
/// keywords, lens).
|
|
Monad(MonadManifest),
|
|
|
|
/// Descriptor de módulo de UI: entities + views + menu + actions.
|
|
/// Lo que hoy lee la metainterface de Nakui desde
|
|
/// `examples/nakui-modules/<id>/module.json`.
|
|
UiModule(UiModuleSpec),
|
|
}
|
|
|
|
impl CardBody {
|
|
/// Etiqueta corta del variant — útil para mensajes de error y
|
|
/// dispatch en la UI sin necesitar match exhaustivo.
|
|
pub fn kind_name(&self) -> &'static str {
|
|
match self {
|
|
CardBody::Ente(_) => "ente",
|
|
CardBody::Monad(_) => "monad",
|
|
CardBody::UiModule(_) => "ui_module",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Errores de carga del brazo.
|
|
#[derive(Debug, Error)]
|
|
pub enum CardLoadError {
|
|
#[error("io: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
|
|
#[error("parse JSON: {0}")]
|
|
JsonParse(#[from] serde_json::Error),
|
|
|
|
#[error("ningún reader registrado matcheó el input (shape no reconocido)")]
|
|
NoMatchingReader,
|
|
|
|
#[error("reader '{reader}' falló: {message}")]
|
|
ReaderFailed { reader: &'static str, message: String },
|
|
|
|
#[error("formato no soportado: extensión '{ext}'. Soportadas: {supported:?}")]
|
|
UnsupportedExtension {
|
|
ext: String,
|
|
supported: Vec<&'static str>,
|
|
},
|
|
|
|
#[error("evaluación Nickel: {0}")]
|
|
Nickel(#[from] NickelEvalError),
|
|
}
|
|
|
|
/// Trait de reader. Cada formato implementa una instancia.
|
|
///
|
|
/// El dispatcher del brazo (`load_card`) prueba los readers en el
|
|
/// orden registrado y se queda con el primero cuyo `can_read`
|
|
/// devuelve `true`. Por eso el orden importa: poner los más
|
|
/// específicos antes que los más laxos.
|
|
pub trait CardReader: Send + Sync {
|
|
/// Nombre del reader, para mensajes de error.
|
|
fn name(&self) -> &'static str;
|
|
|
|
/// Dado un JSON Value (el input ya parseado a serde Value),
|
|
/// decide si este reader puede manejarlo. Heurística estructural
|
|
/// — el shape del input identifica el formato, no flags
|
|
/// explícitos (los inputs legacy no los tienen).
|
|
fn can_read(&self, input: &Value) -> bool;
|
|
|
|
/// Produce el [`Card`] canónico. Sólo se llama si `can_read`
|
|
/// devolvió `true`.
|
|
fn read(&self, input: Value) -> Result<Card, CardLoadError>;
|
|
}
|
|
|
|
mod nickel_eval;
|
|
mod readers;
|
|
pub use nickel_eval::{eval_nickel_file, NickelEvalError, BRAHMAN_CARDS_TEMPLATES_ENV};
|
|
pub use readers::{EnteJsonReader, MonadJsonReader, UiModuleJsonReader};
|
|
|
|
/// Construye el set default de readers para inputs JSON. El orden
|
|
/// es deliberado: el más específico (UiModule, que tiene `entities`
|
|
/// y `views` simultáneamente) antes que el más laxo. Si dos readers
|
|
/// matchean, gana el primero.
|
|
pub fn default_readers() -> Vec<Box<dyn CardReader>> {
|
|
vec![
|
|
Box::new(UiModuleJsonReader),
|
|
Box::new(MonadJsonReader),
|
|
Box::new(EnteJsonReader),
|
|
]
|
|
}
|
|
|
|
/// Carga un Card desde una ruta. Detecta formato por extensión, y
|
|
/// dentro de JSON detecta el shape probando los readers default en
|
|
/// orden.
|
|
///
|
|
/// Para custom reader sets, usar [`load_card_with`].
|
|
pub fn load_card(path: impl AsRef<Path>) -> Result<Card, CardLoadError> {
|
|
load_card_with(path, &default_readers())
|
|
}
|
|
|
|
/// Variante de [`load_card`] con readers custom. Útil para tests o
|
|
/// para apps que quieren restringir formatos soportados.
|
|
pub fn load_card_with(
|
|
path: impl AsRef<Path>,
|
|
readers: &[Box<dyn CardReader>],
|
|
) -> Result<Card, CardLoadError> {
|
|
let path = path.as_ref();
|
|
let ext = path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("")
|
|
.to_ascii_lowercase();
|
|
match ext.as_str() {
|
|
"json" => {
|
|
let bytes = std::fs::read(path)?;
|
|
let value: Value = serde_json::from_slice(&bytes)?;
|
|
dispatch_to_reader(value, readers)
|
|
}
|
|
"ncl" => {
|
|
// Nickel pipeline: leer archivo → evaluar deeply → exportar
|
|
// a JSON → parsear como Value → dispatch a los readers JSON
|
|
// estándar. Templates funcionan via los `import` nativos de
|
|
// Nickel; el evaluator resuelve relativo al input y al
|
|
// `BRAHMAN_CARDS_TEMPLATES_DIR` env (si está set).
|
|
let value = eval_nickel_file(path)?;
|
|
dispatch_to_reader(value, readers)
|
|
}
|
|
other => Err(CardLoadError::UnsupportedExtension {
|
|
ext: other.to_string(),
|
|
supported: vec!["json", "ncl"],
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Recorre los readers en orden, se queda con el primero que matchea
|
|
/// y delega la conversión.
|
|
fn dispatch_to_reader(
|
|
input: Value,
|
|
readers: &[Box<dyn CardReader>],
|
|
) -> Result<Card, CardLoadError> {
|
|
for r in readers {
|
|
if r.can_read(&input) {
|
|
return r.read(input);
|
|
}
|
|
}
|
|
Err(CardLoadError::NoMatchingReader)
|
|
}
|
|
|
|
/// Filenames convencionales que [`load_cards_from_dir`] busca dentro
|
|
/// de cada subdir, en orden de preferencia. Si `card.ncl` existe se
|
|
/// usa ese; sino `card.json`; sino los aliases legacy `module.*`. Los
|
|
/// últimos dos son por compat con el layout actual de
|
|
/// `examples/nakui-modules/<id>/module.json`.
|
|
pub const DEFAULT_CARD_FILENAMES: &[&str] =
|
|
&["card.ncl", "card.json", "module.ncl", "module.json"];
|
|
|
|
/// Carga todas las Cards encontradas como subdirs inmediatos de
|
|
/// `dir`. Por cada subdir, busca los filenames convencionales (ver
|
|
/// [`DEFAULT_CARD_FILENAMES`]) y carga el primero que existe. Subdirs
|
|
/// sin ningún filename matching se skipean silenciosamente — permite
|
|
/// que un dir contenga subdirs auxiliares (assets, fixtures, etc.).
|
|
///
|
|
/// Devuelve las Cards en orden lexicográfico por subdir name (estable
|
|
/// across runs). NO ordena por `Card.id` — el caller decide si quiere
|
|
/// re-ordenar y/o dedupar.
|
|
///
|
|
/// Errores: cualquier I/O al leer el `dir` mismo, o cualquier
|
|
/// `CardLoadError` de un archivo encontrado (NO continúa tras el
|
|
/// primer fallo — fallo loud > corrupción silenciosa).
|
|
pub fn load_cards_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Card>, CardLoadError> {
|
|
load_cards_from_dir_with(dir, DEFAULT_CARD_FILENAMES, &default_readers())
|
|
}
|
|
|
|
/// Variante de [`load_cards_from_dir`] con filenames y readers
|
|
/// custom. Útil para apps que quieren restringir formatos o usar un
|
|
/// nombre canónico distinto.
|
|
pub fn load_cards_from_dir_with(
|
|
dir: impl AsRef<Path>,
|
|
filenames: &[&str],
|
|
readers: &[Box<dyn CardReader>],
|
|
) -> Result<Vec<Card>, CardLoadError> {
|
|
let dir = dir.as_ref();
|
|
let mut subdir_paths: Vec<std::path::PathBuf> = std::fs::read_dir(dir)?
|
|
.flatten()
|
|
.filter_map(|e| {
|
|
let p = e.path();
|
|
if p.is_dir() { Some(p) } else { None }
|
|
})
|
|
.collect();
|
|
// Orden estable por subdir name — el output del brazo no debería
|
|
// depender del orden de read_dir (que varía por filesystem).
|
|
subdir_paths.sort();
|
|
|
|
let mut out: Vec<Card> = Vec::new();
|
|
for sub in subdir_paths {
|
|
for fname in filenames {
|
|
let candidate = sub.join(fname);
|
|
if candidate.exists() {
|
|
out.push(load_card_with(&candidate, readers)?);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|