feat(brahman-cards): brazo unificado V1 — readers JSON + estructura canónica
Pivote arquitectónico: Brahman maneja varios formatos legítimos de
"Card" (cada uno en su crate origen, shape preservado), y un único
brazo los lee y proyecta a UNA estructura interna canónica que
consumen UI runtime / storage / DHT / wire. Agregar formato nuevo
= agregar reader, sin tocar consumers.
Crate nuevo `crates/core/brahman-cards/`:
- Card { id, schema_version, lineage, label, extensions, body }:
wrapper común con identidad legible. PartialEq omitido porque
MonadManifest y nakui_ui_schema::Module no lo implementan.
- CardBody enum tagged: Ente(brahman_card::Card), Monad(MonadManifest),
UiModule(nakui_ui_schema::Module). Convención: agregar variant +
reader; consumers hacen `match { Ente(..) => ..., _ => skip }`.
- trait CardReader { name, can_read(&Value), read(Value) }.
- 3 readers: EnteJsonReader (payload+supervision), MonadJsonReader
(members+cardinality), UiModuleJsonReader (entities+views+menu).
- Entry points load_card / load_card_with. Errores tipados.
13 tests integration: detection x3, dispatch+projection x3,
negative cases x2, sanity de orden, e2e desde disco, unsupported
extension, custom reader set, documented invariant.
13/13 verdes. Workspace build verde.
V1 NO hace (explícito): Nickel reader, templates, migración de
consumers, yahweh refactor, KCL→Nickel — todos en commits siguientes
para mantener este aislado.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,93 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-09
|
## 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<Card>`. 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)
|
### feat(nakui-ui): validación cross-field del EntityRef (existence en store)
|
||||||
Cierra otro pendiente. Hasta ahora `parse_field_value(EntityRef, raw)`
|
Cierra otro pendiente. Hasta ahora `parse_field_value(EntityRef, raw)`
|
||||||
sólo validaba **forma** (UUID parseable + trim de whitespace) — un
|
sólo validaba **forma** (UUID parseable + trim de whitespace) — un
|
||||||
|
|||||||
Generated
+13
@@ -1194,6 +1194,19 @@ dependencies = [
|
|||||||
"wit-parser 0.230.0",
|
"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]]
|
[[package]]
|
||||||
name = "brahman-handshake"
|
name = "brahman-handshake"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ members = [
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
"crates/core/brahman-card",
|
"crates/core/brahman-card",
|
||||||
"crates/core/brahman-card-wit",
|
"crates/core/brahman-card-wit",
|
||||||
|
"crates/core/brahman-cards",
|
||||||
"crates/core/brahman-handshake",
|
"crates/core/brahman-handshake",
|
||||||
"crates/core/brahman-broker",
|
"crates/core/brahman-broker",
|
||||||
"crates/core/brahman-admin",
|
"crates/core/brahman-admin",
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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<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>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 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<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)
|
||||||
|
}
|
||||||
|
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<dyn CardReader>],
|
||||||
|
) -> Result<Card, CardLoadError> {
|
||||||
|
for r in readers {
|
||||||
|
if r.can_read(&input) {
|
||||||
|
return r.read(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(CardLoadError::NoMatchingReader)
|
||||||
|
}
|
||||||
@@ -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<Card, CardLoadError> {
|
||||||
|
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<FileId>) 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<Card, CardLoadError> {
|
||||||
|
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<Card, CardLoadError> {
|
||||||
|
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<String> {
|
||||||
|
v.get(key)?.as_str().map(|s| s.to_string())
|
||||||
|
}
|
||||||
@@ -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<dyn CardReader>]) -> Result<Card, CardLoadError> {
|
||||||
|
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<Box<dyn CardReader>> = 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user