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:
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user