diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ff428..f8995cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,93 @@ ratio/diff ver `git show `. ## 2026-05-09 +### feat(brahman-cards): brazo unificado V1 — readers JSON + estructura canónica +**Pivote arquitectónico** decidido en charla: Brahman maneja varios +formatos legítimos de "Card" (cada formato vive en su crate origen y +conserva su shape público), y un **único brazo** los lee, completa +desde templates si vienen simplificados, y los proyecta a UNA sola +estructura interna canónica que consumen UI runtime / storage / DHT / +wire. Agregar un formato nuevo = agregar un reader, sin tocar +consumers. + +**V1 en este commit**: estructura canónica + readers para los 3 +formatos JSON existentes en el monorepo. Sin Nickel todavía (aislado +para próximo commit). + +Crate nuevo `crates/core/brahman-cards/`: +- **`Card { id, schema_version, lineage, label, extensions, body }`**: + wrapper común con identidad legible + extensiones forward-compat. + `id` como String (no `Ulid`) porque cada body variant usa un tipo + de id distinto (Ulid para Ente/Monad, slug human-friendly para + UiModule). PartialEq omitido del derive porque `MonadManifest` y + `nakui_ui_schema::Module` no lo implementan en sus crates origen. +- **`CardBody`** enum etiquetado `kind`: + - `Ente(brahman_card::Card)` — entidad runtime con + payload/soma/supervision. + - `Monad(nouser_card::MonadManifest)` — agrupación semántica de + archivos. + - `UiModule(nakui_ui_schema::Module)` — descriptor de UI con + entities/views/menu. + - Convención: agregar variant nuevo + reader; los consumers que + sólo manejen algunos hacen `match { Ente(..) => ..., _ => skip }`. +- **`trait CardReader`**: `name()` + `can_read(&Value) -> bool` + + `read(Value) -> Result`. El dispatcher prueba en orden y + delega al primero que matchee. +- **3 readers concretos** (en `readers.rs`): + - `EnteJsonReader` — heurística: `payload` Y `supervision` + presentes simultáneamente. + - `MonadJsonReader` — heurística: `members` Y `cardinality`. + - `UiModuleJsonReader` — heurística: `entities` Y `views` Y + `menu`. El más específico, va primero en `default_readers()`. +- **Entry points**: + - `load_card(path)` — abre archivo, dispatcha por extensión, dentro + de JSON prueba los readers default. + - `load_card_with(path, readers)` — variante con set custom para + apps que quieren restringir formatos. +- **Errores tipados** vía `CardLoadError`: `Io`, `JsonParse`, + `NoMatchingReader`, `ReaderFailed { reader, message }`, + `UnsupportedExtension { ext, supported }`. + +13 tests integration: +- 3 detection tests (cada reader matchea sólo su shape, rechaza los + otros 2 + non-object). +- 3 dispatch+projection tests (cada formato JSON cargado produce el + variant esperado con campos del wrapper bien derivados). +- 2 negative cases (NoMatchingReader, non-object input). +- 1 sanity de orden (UiModule gana cuando el shape acepta múltiples + readers — defiende el contrato de orden documentado). +- 1 e2e desde disco con `load_card_with`. +- 1 unsupported extension. +- 1 custom reader set (restringir a sólo Ente). +- 1 documented invariant (extensions vacío en V1; si cambia, este + test se rompe como signal). + +13/13 verdes. Workspace build verde tras agregar el crate al +`members[]` del workspace Cargo.toml. + +**Lo que NO hace V1** (explícito): +- No carga Nickel — próximo commit. La dep `nickel-lang-core` queda + aislada para no inflar este commit. +- No define templates — los templates Nickel se diseñan junto al + reader Nickel (necesitan `merge` nativo de Nickel para fusionar + override + base). +- No migra consumers. `nakui-ui` sigue cargando `module.json` con + `nakui_ui_schema::load_modules_from_dir` directo. La migración a + `brahman_cards::load_card` viene cuando V1 + Nickel + templates + estén estables. +- No mueve los `extensions` del input a `Card.extensions` — los crates + origen ya tienen sus propios `extensions` internos (`#[serde(flatten)]`). + Documentado como decisión consciente. + +**Pendientes para próximos commits** (orden): +1. Reader Nickel + template merge. +2. Migrar consumers (`nakui-ui` consume `brahman_cards::load_card`). +3. Yahweh refactor: lift del MetaUi runtime a `crates/modules/ui_engine/` + (esperando hasta que el brazo + canónico estén estables). +4. KCL → Nickel: kcl_wrapper reemplazado por evaluación de Nickel + contracts; los 3 schemas .k de nakui modules pasan a .ncl. +5. card.k eliminado (es REFERENCE ONLY documentado). + ### feat(nakui-ui): validación cross-field del EntityRef (existence en store) Cierra otro pendiente. Hasta ahora `parse_field_value(EntityRef, raw)` sólo validaba **forma** (UUID parseable + trim de whitespace) — un diff --git a/Cargo.lock b/Cargo.lock index bac7bcf..bd4c96a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1194,6 +1194,19 @@ dependencies = [ "wit-parser 0.230.0", ] +[[package]] +name = "brahman-cards" +version = "0.1.0" +dependencies = [ + "brahman-card", + "nakui-ui-schema", + "nouser-card", + "serde", + "serde_json", + "thiserror 2.0.18", + "ulid", +] + [[package]] name = "brahman-handshake" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a4a8644..b0892c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ # ============================================================ "crates/core/brahman-card", "crates/core/brahman-card-wit", + "crates/core/brahman-cards", "crates/core/brahman-handshake", "crates/core/brahman-broker", "crates/core/brahman-admin", diff --git a/crates/core/brahman-cards/Cargo.toml b/crates/core/brahman-cards/Cargo.toml new file mode 100644 index 0000000..2ee3a6c --- /dev/null +++ b/crates/core/brahman-cards/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "brahman-cards" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Brahman — brazo unificado: lee múltiples formatos de Card (Ente/Monad/UiModule/...) y los proyecta a una estructura canónica única." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +ulid = { workspace = true } +brahman-card = { path = "../brahman-card" } +nouser-card = { path = "../../modules/nouser/card" } +nakui-ui-schema = { path = "../../modules/nakui/ui-schema" } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/core/brahman-cards/src/lib.rs b/crates/core/brahman-cards/src/lib.rs new file mode 100644 index 0000000..72cd18e --- /dev/null +++ b/crates/core/brahman-cards/src/lib.rs @@ -0,0 +1,244 @@ +//! `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 nakui_ui_schema::Module as UiModuleSpec; +pub use nouser_card::MonadManifest; + +/// 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, nakui_ui_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>, + }, +} + +/// 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 readers; +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> { + 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) + } + other => Err(CardLoadError::UnsupportedExtension { + ext: other.to_string(), + supported: vec!["json"], + }), + } +} + +/// 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) +} diff --git a/crates/core/brahman-cards/src/readers.rs b/crates/core/brahman-cards/src/readers.rs new file mode 100644 index 0000000..44b99cc --- /dev/null +++ b/crates/core/brahman-cards/src/readers.rs @@ -0,0 +1,168 @@ +//! Readers V1: tres formatos JSON ya existentes en el monorepo. +//! +//! Cada reader implementa: +//! - `can_read`: heurística estructural para decidir si el JSON es +//! suyo. No requiere flag explícito en el input — los inputs legacy +//! no los tienen. +//! - `read`: deserializa el JSON al tipo del crate origen (sin tocarlo) +//! y lo envuelve en [`Card`] derivando los campos del wrapper. +//! +//! Convenciones para derivar el wrapper: +//! - `id`: del campo `id` del input (cada formato lo expone). Si es +//! ULID se serializa a string canónico. +//! - `label`: del campo `label`. +//! - `lineage`: del campo `lineage` cuando existe (Ente/Monad). +//! - `extensions`: campos JSON desconocidos respecto a la struct del +//! crate origen. Hoy lo mantenemos vacío (los crates origen ya +//! tienen sus propios `extensions` internos via `#[serde(flatten)]`) +//! — no duplicamos. Si en el futuro queremos mover el "extras" del +//! crate origen al wrapper, esta es la palanca. + +use serde_json::Value; + +use crate::{Card, CardBody, CardLoadError, CardReader, EnteCard, MonadManifest, UiModuleSpec, CARD_SCHEMA_VERSION}; + +// ============================================================================ +// Ente (brahman-card) +// ============================================================================ + +/// Reader para el shape JSON de [`brahman_card::Card`]. +/// +/// Heurística de detección: el input tiene `payload` Y `supervision` +/// — son los campos requeridos del schema Ente que ningún otro +/// formato del monorepo tiene. +pub struct EnteJsonReader; + +impl CardReader for EnteJsonReader { + fn name(&self) -> &'static str { + "ente-json" + } + + fn can_read(&self, input: &Value) -> bool { + let obj = match input.as_object() { + Some(o) => o, + None => return false, + }; + obj.contains_key("payload") && obj.contains_key("supervision") + } + + fn read(&self, input: Value) -> Result { + let id = pull_string(&input, "id").unwrap_or_default(); + let label = pull_string(&input, "label").unwrap_or_default(); + let lineage = pull_string(&input, "lineage"); + + let ente: EnteCard = + serde_json::from_value(input).map_err(|e| CardLoadError::ReaderFailed { + reader: "ente-json", + message: e.to_string(), + })?; + + Ok(Card { + id, + schema_version: CARD_SCHEMA_VERSION, + lineage, + label, + extensions: Default::default(), + body: CardBody::Ente(ente), + }) + } +} + +// ============================================================================ +// Monad (nouser-card) +// ============================================================================ + +/// Reader para el shape JSON de [`nouser_card::MonadManifest`]. +/// +/// Heurística: tiene `members` (BTreeSet) Y `cardinality` +/// (u32). La combinación es exclusiva del MonadManifest. +pub struct MonadJsonReader; + +impl CardReader for MonadJsonReader { + fn name(&self) -> &'static str { + "monad-json" + } + + fn can_read(&self, input: &Value) -> bool { + let obj = match input.as_object() { + Some(o) => o, + None => return false, + }; + obj.contains_key("members") && obj.contains_key("cardinality") + } + + fn read(&self, input: Value) -> Result { + let id = pull_string(&input, "id").unwrap_or_default(); + let label = pull_string(&input, "label").unwrap_or_default(); + let lineage = pull_string(&input, "lineage"); + + let monad: MonadManifest = + serde_json::from_value(input).map_err(|e| CardLoadError::ReaderFailed { + reader: "monad-json", + message: e.to_string(), + })?; + + Ok(Card { + id, + schema_version: CARD_SCHEMA_VERSION, + lineage, + label, + extensions: Default::default(), + body: CardBody::Monad(monad), + }) + } +} + +// ============================================================================ +// UiModule (nakui-ui-schema) +// ============================================================================ + +/// Reader para el shape JSON de los `module.json` de la UI Nakui +/// ([`nakui_ui_schema::Module`]). +/// +/// Heurística: tiene `entities` Y `views` Y `menu`. Es el shape más +/// específico del repo, así que va primero en el orden default — si +/// matchea, ningún otro reader debería intentar. +pub struct UiModuleJsonReader; + +impl CardReader for UiModuleJsonReader { + fn name(&self) -> &'static str { + "ui-module-json" + } + + fn can_read(&self, input: &Value) -> bool { + let obj = match input.as_object() { + Some(o) => o, + None => return false, + }; + obj.contains_key("entities") && obj.contains_key("views") && obj.contains_key("menu") + } + + fn read(&self, input: Value) -> Result { + let id = pull_string(&input, "id").unwrap_or_default(); + let label = pull_string(&input, "label").unwrap_or_default(); + // UiModule no tiene lineage en su schema, queda None. + let module: UiModuleSpec = + serde_json::from_value(input).map_err(|e| CardLoadError::ReaderFailed { + reader: "ui-module-json", + message: e.to_string(), + })?; + + Ok(Card { + id, + schema_version: CARD_SCHEMA_VERSION, + lineage: None, + label, + extensions: Default::default(), + body: CardBody::UiModule(module), + }) + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +fn pull_string(v: &Value, key: &str) -> Option { + v.get(key)?.as_str().map(|s| s.to_string()) +} diff --git a/crates/core/brahman-cards/tests/integration.rs b/crates/core/brahman-cards/tests/integration.rs new file mode 100644 index 0000000..4d105c2 --- /dev/null +++ b/crates/core/brahman-cards/tests/integration.rs @@ -0,0 +1,283 @@ +//! Integration tests del brazo brahman-cards. +//! +//! Cubre: +//! 1. Cada reader matchea sólo el shape correcto. +//! 2. El dispatcher (`load_card`/`dispatch`) elige el reader +//! correcto sin ambigüedad. +//! 3. Round-trip: cada formato JSON cargado produce el variant +//! esperado del Card canónico con los campos del wrapper bien +//! derivados. +//! 4. Rechazo gracioso de inputs no-matched + extensiones no +//! soportadas. + +use std::collections::BTreeMap; + +use brahman_cards::{ + default_readers, load_card_with, Card, CardBody, CardLoadError, CardReader, EnteJsonReader, + MonadJsonReader, UiModuleJsonReader, +}; +use serde_json::{json, Value}; + +/// Helper: dispatch in-process desde un Value, sin tocar disco. +/// Reproduce la lógica interna del dispatcher para no exigir I/O en +/// los tests. +fn dispatch(input: Value, readers: &[Box]) -> Result { + for r in readers { + if r.can_read(&input) { + return r.read(input); + } + } + Err(CardLoadError::NoMatchingReader) +} + +// =========================================================================== +// Reader detection (can_read) +// =========================================================================== + +#[test] +fn ui_module_reader_detects_only_ui_module_shape() { + let r = UiModuleJsonReader; + let ui = json!({"id": "x", "label": "X", "menu": [], "views": {}, "entities": []}); + let ente = json!({"id": "x", "label": "X", "payload": "Virtual", "supervision": "OneShot"}); + let monad = json!({"id": "x", "label": "X", "members": [], "cardinality": 0}); + assert!(r.can_read(&ui), "UiModule reader debe matchear ui shape"); + assert!(!r.can_read(&ente), "no debe matchear Ente"); + assert!(!r.can_read(&monad), "no debe matchear Monad"); + assert!(!r.can_read(&Value::Null), "no debe matchear non-object"); +} + +#[test] +fn ente_reader_detects_only_ente_shape() { + let r = EnteJsonReader; + let ente = json!({"payload": "Virtual", "supervision": "OneShot"}); + let monad = json!({"members": [], "cardinality": 0}); + let ui = json!({"menu": [], "views": {}, "entities": []}); + assert!(r.can_read(&ente)); + assert!(!r.can_read(&monad)); + assert!(!r.can_read(&ui)); +} + +#[test] +fn monad_reader_detects_only_monad_shape() { + let r = MonadJsonReader; + let monad = json!({"members": [], "cardinality": 0}); + let ente = json!({"payload": "Virtual", "supervision": "OneShot"}); + let ui = json!({"menu": [], "views": {}, "entities": []}); + assert!(r.can_read(&monad)); + assert!(!r.can_read(&ente)); + assert!(!r.can_read(&ui)); +} + +// =========================================================================== +// Dispatch + variant projection +// =========================================================================== + +#[test] +fn loads_ui_module_to_card_ui_module_variant() { + let input = json!({ + "id": "sales_engine", + "label": "Ventas", + "description": "Demo", + "entities": [], + "menu": [{"label": "Stock", "view": "stock_list"}], + "views": { + "stock_list": { + "kind": "list", + "title": "Stock", + "entity": "Stock", + "columns": [] + } + } + }); + let card = dispatch(input, &default_readers()).expect("dispatch ok"); + assert_eq!(card.id, "sales_engine"); + assert_eq!(card.label, "Ventas"); + assert!(card.lineage.is_none(), "UiModule sin lineage"); + assert_eq!(card.body.kind_name(), "ui_module"); + match card.body { + CardBody::UiModule(m) => { + assert_eq!(m.id, "sales_engine"); + assert_eq!(m.menu.len(), 1); + } + other => panic!("variant inesperado: {:?}", other.kind_name()), + } +} + +#[test] +fn loads_ente_to_card_ente_variant() { + // Ulid mínimo: 26 chars Crockford. Usamos uno conocido. + let ulid = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; + let input = json!({ + "schema_version": 1, + "id": ulid, + "label": "test-ente", + "payload": "Virtual", + "supervision": "OneShot" + }); + let card = dispatch(input, &default_readers()).expect("dispatch ok"); + assert_eq!(card.id, ulid); + assert_eq!(card.label, "test-ente"); + assert_eq!(card.body.kind_name(), "ente"); + match card.body { + CardBody::Ente(e) => { + assert_eq!(e.label, "test-ente"); + assert_eq!(e.id.to_string(), ulid); + } + other => panic!("variant inesperado: {:?}", other.kind_name()), + } +} + +#[test] +fn loads_monad_to_card_monad_variant() { + let ulid = "01ARZ3NDEKTSV4RRFFQ69G5FB1"; + let input = json!({ + "schema_version": 1, + "id": ulid, + "label": "test-monad", + "members": [], + "cardinality": 0, + "created_at_ms": 0, + "updated_at_ms": 0 + }); + let card = dispatch(input, &default_readers()).expect("dispatch ok"); + assert_eq!(card.id, ulid); + assert_eq!(card.label, "test-monad"); + assert_eq!(card.body.kind_name(), "monad"); + match card.body { + CardBody::Monad(m) => { + assert_eq!(m.label, "test-monad"); + assert_eq!(m.cardinality, 0); + } + other => panic!("variant inesperado: {:?}", other.kind_name()), + } +} + +// =========================================================================== +// Negative cases +// =========================================================================== + +#[test] +fn rejects_input_no_matching_reader() { + let input = json!({"random": "shape", "without": "fingerprint"}); + let err = dispatch(input, &default_readers()).unwrap_err(); + assert!( + matches!(err, CardLoadError::NoMatchingReader), + "expected NoMatchingReader, got {err:?}" + ); +} + +#[test] +fn rejects_non_object_input() { + let input = json!([1, 2, 3]); + let err = dispatch(input, &default_readers()).unwrap_err(); + assert!(matches!(err, CardLoadError::NoMatchingReader)); +} + +#[test] +fn ui_module_takes_priority_when_shape_overlaps_partial() { + // Sanity del orden: si alguien armara un input híbrido con + // `menu`+`views`+`entities` Y también `payload`+`supervision`, + // el UiModuleReader (primero en orden) debería ganar. Esto no + // debería ocurrir con inputs reales pero defendemos el contrato + // de orden documentado. + let input = json!({ + "id": "weird", + "label": "Weird", + "menu": [], + "views": {}, + "entities": [], + "payload": "Virtual", + "supervision": "OneShot" + }); + let card = dispatch(input, &default_readers()).expect("dispatch ok"); + assert_eq!( + card.body.kind_name(), + "ui_module", + "el UiModuleReader debería ganar por orden" + ); +} + +// =========================================================================== +// load_card desde disco (e2e fino) +// =========================================================================== + +#[test] +fn load_card_from_disk_round_trip_ui_module() { + let tmp = tempfile_path("ui_module.json"); + let input = json!({ + "id": "demo", + "label": "Demo", + "entities": [], + "menu": [], + "views": {} + }); + std::fs::write(&tmp, serde_json::to_vec_pretty(&input).unwrap()).unwrap(); + + let card = load_card_with(&tmp, &default_readers()).expect("load ok"); + assert_eq!(card.body.kind_name(), "ui_module"); + assert_eq!(card.id, "demo"); + + let _ = std::fs::remove_file(&tmp); +} + +#[test] +fn load_card_rejects_unsupported_extension() { + let tmp = tempfile_path("foo.toml"); + std::fs::write(&tmp, b"[anything]\nx = 1").unwrap(); + let err = load_card_with(&tmp, &default_readers()).unwrap_err(); + match err { + CardLoadError::UnsupportedExtension { ext, supported } => { + assert_eq!(ext, "toml"); + assert!(supported.contains(&"json")); + } + other => panic!("expected UnsupportedExtension, got {other:?}"), + } + let _ = std::fs::remove_file(&tmp); +} + +// =========================================================================== +// Custom reader sets +// =========================================================================== + +#[test] +fn custom_reader_set_can_restrict_supported_formats() { + // Sólo Ente: un input Monad debería rechazarse. + let only_ente: Vec> = vec![Box::new(EnteJsonReader)]; + let monad_input = json!({"members": [], "cardinality": 0}); + let err = dispatch(monad_input, &only_ente).unwrap_err(); + assert!(matches!(err, CardLoadError::NoMatchingReader)); +} + +// =========================================================================== +// Wrapper field invariants +// =========================================================================== + +#[test] +fn extensions_field_starts_empty_in_v1() { + // Documented: V1 no mueve el "extras" del crate origen al + // wrapper.extensions. Si esto cambia, este test se rompe como + // signal para actualizar el doc de readers.rs. + let input = json!({ + "id": "demo", + "label": "Demo", + "entities": [], + "menu": [], + "views": {} + }); + let card = dispatch(input, &default_readers()).unwrap(); + assert_eq!(card.extensions, BTreeMap::new()); +} + +// =========================================================================== +// Helpers de tests +// =========================================================================== + +fn tempfile_path(name: &str) -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "brahman-cards-test-{}-{}", + std::process::id(), + name + )); + p +}