//! `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- │ │ (chasqui- │ │ (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, PathBuf}; use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; pub use brahman_card::Card as EnteCard; pub use chasqui_card::MonadManifest; pub use nahual_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, nahual_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, /// 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, /// 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//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; } mod entity_loader; mod nickel_eval; mod readers; pub use entity_loader::{extract_card_from_json, load_card_file}; pub use nickel_eval::{eval_nickel_file, NickelEvalError, BRAHMAN_CARDS_TEMPLATES_ENV}; pub use readers::{EnteJsonReader, MonadJsonReader, UiModuleJsonReader}; /// Path al directorio de templates Nickel canónicos shipped con el /// crate (`crates/core/brahman-cards/templates/` en el repo). /// /// Este directorio contiene los `*_basic.ncl` para cada body kind: /// - `ente_basic.ncl` /// - `monad_basic.ncl` /// - `ui_module_basic.ncl` /// /// Usar como path para [`BRAHMAN_CARDS_TEMPLATES_ENV`] o pasarlo /// directo a Nickel via env. Resuelto via `CARGO_MANIFEST_DIR` — /// funciona en `cargo test`/`cargo run` desde el workspace. Para /// distribución del binary standalone (cuando emerja el caso de /// uso), incluir los templates como recursos via `include_dir!` o /// instalar el directorio junto al ejecutable. pub fn canonical_templates_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates") } /// 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> { 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) -> Result { 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, readers: &[Box], ) -> Result { 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], ) -> Result { 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//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) -> Result, 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, filenames: &[&str], readers: &[Box], ) -> Result, CardLoadError> { let dir = dir.as_ref(); let mut subdir_paths: Vec = 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 = 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) }