refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG
Reorganización física de crates/: - core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/ - shared/ (3 crates) se redistribuye en protocol/ e init/ - lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/ Renames de proyectos: - shipote → shuma (runtime de sandboxes) - nouser → akasha (explorador de Mónadas) - yahweh → nahual (motor GPUI, antes ui_engine/) - lapaloma → pineal (data-viz agnóstica) Fraccionamiento UI → core agnóstico: - vista-core (DeckState + snap, 175 LOC, 5 tests verdes) - barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes) - vista-web y barra-web ahora son thin DOM bindings Documentación nueva: - 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat + 10 módulos + apps/ - docs/STATUS.md con cifras reales por proyecto - docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas) - CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets) Automatización: - scripts/reorg.py — script idempotente que: git mv directorios, renombra package names, recomputa path = refs, reescribe imports rust, actualiza workspace Cargo.toml. Soporta --dry-run. - scripts/split-changelog.py — particiona CHANGELOG por componente. Validación: - cargo check --workspace pasa (124 crates + 2 nuevos cores). - 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
[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" }
|
||||
akasha-card = { path = "../../modules/akasha/card" }
|
||||
nahual-meta-schema = { path = "../../modules/nahual/libs/meta-schema" }
|
||||
nickel-lang = "2.0.0"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,334 @@
|
||||
//! `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- │ │ (akasha- │ │ (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 akasha_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<String>,
|
||||
|
||||
/// Etiqueta humana legible. Cada reader la deriva del campo
|
||||
/// equivalente del input (label/title/etc.).
|
||||
pub label: String,
|
||||
|
||||
/// Campos no reconocidos del input se preservan acá. Permite
|
||||
/// forward-compat: leer un input con campos nuevos no rompe la
|
||||
/// carga, y volver a serializar conserva el extra.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub extensions: BTreeMap<String, Value>,
|
||||
|
||||
/// Cuerpo tipado por dominio. La elección del variant es
|
||||
/// responsabilidad del reader (basada en el input shape).
|
||||
pub body: CardBody,
|
||||
}
|
||||
|
||||
/// Versión actual del schema canónico de [`Card`]. Bump cuando cambie
|
||||
/// la shape del wrapper o las invariantes que comparten todos los
|
||||
/// variants.
|
||||
pub const CARD_SCHEMA_VERSION: u16 = 1;
|
||||
|
||||
/// Variantes tipadas del body de [`Card`]. Una por dominio.
|
||||
///
|
||||
/// **Convención de extensión**: agregar un variant nuevo aquí + un
|
||||
/// reader que produzca ese variant. Los consumers que sólo manejen
|
||||
/// algunos variants pueden hacer `match { Ente(..) => ..., _ => skip }`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum CardBody {
|
||||
/// Entidad runtime con proceso/payload/supervision (lo que era
|
||||
/// `brahman_card::Card` directo).
|
||||
Ente(EnteCard),
|
||||
|
||||
/// Agrupación semántica de archivos (Mónada de Nouser). No tiene
|
||||
/// proceso; describe membership + signals semánticas (centroid,
|
||||
/// keywords, lens).
|
||||
Monad(MonadManifest),
|
||||
|
||||
/// Descriptor de módulo de UI: entities + views + menu + actions.
|
||||
/// Lo que hoy lee la metainterface de Nakui desde
|
||||
/// `examples/nakui-modules/<id>/module.json`.
|
||||
UiModule(UiModuleSpec),
|
||||
}
|
||||
|
||||
impl CardBody {
|
||||
/// Etiqueta corta del variant — útil para mensajes de error y
|
||||
/// dispatch en la UI sin necesitar match exhaustivo.
|
||||
pub fn kind_name(&self) -> &'static str {
|
||||
match self {
|
||||
CardBody::Ente(_) => "ente",
|
||||
CardBody::Monad(_) => "monad",
|
||||
CardBody::UiModule(_) => "ui_module",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errores de carga del brazo.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CardLoadError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("parse JSON: {0}")]
|
||||
JsonParse(#[from] serde_json::Error),
|
||||
|
||||
#[error("ningún reader registrado matcheó el input (shape no reconocido)")]
|
||||
NoMatchingReader,
|
||||
|
||||
#[error("reader '{reader}' falló: {message}")]
|
||||
ReaderFailed { reader: &'static str, message: String },
|
||||
|
||||
#[error("formato no soportado: extensión '{ext}'. Soportadas: {supported:?}")]
|
||||
UnsupportedExtension {
|
||||
ext: String,
|
||||
supported: Vec<&'static str>,
|
||||
},
|
||||
|
||||
#[error("evaluación Nickel: {0}")]
|
||||
Nickel(#[from] NickelEvalError),
|
||||
}
|
||||
|
||||
/// Trait de reader. Cada formato implementa una instancia.
|
||||
///
|
||||
/// El dispatcher del brazo (`load_card`) prueba los readers en el
|
||||
/// orden registrado y se queda con el primero cuyo `can_read`
|
||||
/// devuelve `true`. Por eso el orden importa: poner los más
|
||||
/// específicos antes que los más laxos.
|
||||
pub trait CardReader: Send + Sync {
|
||||
/// Nombre del reader, para mensajes de error.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Dado un JSON Value (el input ya parseado a serde Value),
|
||||
/// decide si este reader puede manejarlo. Heurística estructural
|
||||
/// — el shape del input identifica el formato, no flags
|
||||
/// explícitos (los inputs legacy no los tienen).
|
||||
fn can_read(&self, input: &Value) -> bool;
|
||||
|
||||
/// Produce el [`Card`] canónico. Sólo se llama si `can_read`
|
||||
/// devolvió `true`.
|
||||
fn read(&self, input: Value) -> Result<Card, CardLoadError>;
|
||||
}
|
||||
|
||||
mod nickel_eval;
|
||||
mod readers;
|
||||
pub use nickel_eval::{eval_nickel_file, NickelEvalError, BRAHMAN_CARDS_TEMPLATES_ENV};
|
||||
pub use readers::{EnteJsonReader, MonadJsonReader, UiModuleJsonReader};
|
||||
|
||||
/// 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<Box<dyn CardReader>> {
|
||||
vec![
|
||||
Box::new(UiModuleJsonReader),
|
||||
Box::new(MonadJsonReader),
|
||||
Box::new(EnteJsonReader),
|
||||
]
|
||||
}
|
||||
|
||||
/// Carga un Card desde una ruta. Detecta formato por extensión, y
|
||||
/// dentro de JSON detecta el shape probando los readers default en
|
||||
/// orden.
|
||||
///
|
||||
/// Para custom reader sets, usar [`load_card_with`].
|
||||
pub fn load_card(path: impl AsRef<Path>) -> Result<Card, CardLoadError> {
|
||||
load_card_with(path, &default_readers())
|
||||
}
|
||||
|
||||
/// Variante de [`load_card`] con readers custom. Útil para tests o
|
||||
/// para apps que quieren restringir formatos soportados.
|
||||
pub fn load_card_with(
|
||||
path: impl AsRef<Path>,
|
||||
readers: &[Box<dyn CardReader>],
|
||||
) -> Result<Card, CardLoadError> {
|
||||
let path = path.as_ref();
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"json" => {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let value: Value = serde_json::from_slice(&bytes)?;
|
||||
dispatch_to_reader(value, readers)
|
||||
}
|
||||
"ncl" => {
|
||||
// Nickel pipeline: leer archivo → evaluar deeply → exportar
|
||||
// a JSON → parsear como Value → dispatch a los readers JSON
|
||||
// estándar. Templates funcionan via los `import` nativos de
|
||||
// Nickel; el evaluator resuelve relativo al input y al
|
||||
// `BRAHMAN_CARDS_TEMPLATES_DIR` env (si está set).
|
||||
let value = eval_nickel_file(path)?;
|
||||
dispatch_to_reader(value, readers)
|
||||
}
|
||||
other => Err(CardLoadError::UnsupportedExtension {
|
||||
ext: other.to_string(),
|
||||
supported: vec!["json", "ncl"],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recorre los readers en orden, se queda con el primero que matchea
|
||||
/// y delega la conversión.
|
||||
fn dispatch_to_reader(
|
||||
input: Value,
|
||||
readers: &[Box<dyn CardReader>],
|
||||
) -> Result<Card, CardLoadError> {
|
||||
for r in readers {
|
||||
if r.can_read(&input) {
|
||||
return r.read(input);
|
||||
}
|
||||
}
|
||||
Err(CardLoadError::NoMatchingReader)
|
||||
}
|
||||
|
||||
/// Filenames convencionales que [`load_cards_from_dir`] busca dentro
|
||||
/// de cada subdir, en orden de preferencia. Si `card.ncl` existe se
|
||||
/// usa ese; sino `card.json`; sino los aliases legacy `module.*`. Los
|
||||
/// últimos dos son por compat con el layout actual de
|
||||
/// `examples/nakui-modules/<id>/module.json`.
|
||||
pub const DEFAULT_CARD_FILENAMES: &[&str] =
|
||||
&["card.ncl", "card.json", "module.ncl", "module.json"];
|
||||
|
||||
/// Carga todas las Cards encontradas como subdirs inmediatos de
|
||||
/// `dir`. Por cada subdir, busca los filenames convencionales (ver
|
||||
/// [`DEFAULT_CARD_FILENAMES`]) y carga el primero que existe. Subdirs
|
||||
/// sin ningún filename matching se skipean silenciosamente — permite
|
||||
/// que un dir contenga subdirs auxiliares (assets, fixtures, etc.).
|
||||
///
|
||||
/// Devuelve las Cards en orden lexicográfico por subdir name (estable
|
||||
/// across runs). NO ordena por `Card.id` — el caller decide si quiere
|
||||
/// re-ordenar y/o dedupar.
|
||||
///
|
||||
/// Errores: cualquier I/O al leer el `dir` mismo, o cualquier
|
||||
/// `CardLoadError` de un archivo encontrado (NO continúa tras el
|
||||
/// primer fallo — fallo loud > corrupción silenciosa).
|
||||
pub fn load_cards_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Card>, CardLoadError> {
|
||||
load_cards_from_dir_with(dir, DEFAULT_CARD_FILENAMES, &default_readers())
|
||||
}
|
||||
|
||||
/// Variante de [`load_cards_from_dir`] con filenames y readers
|
||||
/// custom. Útil para apps que quieren restringir formatos o usar un
|
||||
/// nombre canónico distinto.
|
||||
pub fn load_cards_from_dir_with(
|
||||
dir: impl AsRef<Path>,
|
||||
filenames: &[&str],
|
||||
readers: &[Box<dyn CardReader>],
|
||||
) -> Result<Vec<Card>, CardLoadError> {
|
||||
let dir = dir.as_ref();
|
||||
let mut subdir_paths: Vec<std::path::PathBuf> = std::fs::read_dir(dir)?
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let p = e.path();
|
||||
if p.is_dir() { Some(p) } else { None }
|
||||
})
|
||||
.collect();
|
||||
// Orden estable por subdir name — el output del brazo no debería
|
||||
// depender del orden de read_dir (que varía por filesystem).
|
||||
subdir_paths.sort();
|
||||
|
||||
let mut out: Vec<Card> = Vec::new();
|
||||
for sub in subdir_paths {
|
||||
for fname in filenames {
|
||||
let candidate = sub.join(fname);
|
||||
if candidate.exists() {
|
||||
out.push(load_card_with(&candidate, readers)?);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Evaluador Nickel para inputs `.ncl`.
|
||||
//!
|
||||
//! El brazo de Cards lee Nickel como **fuente** y produce JSON como
|
||||
//! **representación intermedia** que después dispatcha por los readers
|
||||
//! estándar. Esto significa que un `.ncl` puede producir cualquier
|
||||
//! variant del [`super::CardBody`] siempre que evalúe a una shape JSON
|
||||
//! que alguno de los readers reconozca.
|
||||
//!
|
||||
//! # Templates
|
||||
//!
|
||||
//! Nickel soporta `import "..."` y el operador `&` de merge nativo. Un
|
||||
//! Card "concreto" puede ser un template + override:
|
||||
//!
|
||||
//! ```nickel
|
||||
//! let base = import "ente_basic.ncl" in
|
||||
//! base & { id = "01ARZ...", label = "mi-ente" }
|
||||
//! ```
|
||||
//!
|
||||
//! **Convención obligatoria del template**: las fields que el usuario
|
||||
//! va a sobrescribir tienen que estar marcadas `| default` (o
|
||||
//! `| optional`). Nickel rechaza el merge de dos strings/numbers
|
||||
//! distintos con la misma prioridad — el `| default` baja la prioridad
|
||||
//! del template y deja que el override del user gane:
|
||||
//!
|
||||
//! ```nickel
|
||||
//! # template ui_module_basic.ncl
|
||||
//! {
|
||||
//! id | String | default = "TEMPLATE_ID",
|
||||
//! label | String | default = "TEMPLATE_LABEL",
|
||||
//! # ...
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Resolución de imports (en orden):
|
||||
//! 1. Relativo al directorio del archivo input (default de Nickel).
|
||||
//! 2. `BRAHMAN_CARDS_TEMPLATES_DIR` (env). Permite tener un
|
||||
//! registry global de templates accesible por nombre desnudo:
|
||||
//! `import "ui_module_basic.ncl"`.
|
||||
//!
|
||||
//! No agregamos magic resolución por kind — el autor decide qué
|
||||
//! template importa explícitamente.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Variable de entorno opcional. Si está set, su path se agrega al
|
||||
/// search path de imports de Nickel después del parent dir del input,
|
||||
/// permitiendo `import "<nombre>.ncl"` desde cualquier ubicación.
|
||||
pub const BRAHMAN_CARDS_TEMPLATES_ENV: &str = "BRAHMAN_CARDS_TEMPLATES_DIR";
|
||||
|
||||
/// Errores específicos del pipeline Nickel. Wrap del error de Nickel
|
||||
/// formateado como texto plano (sin ANSI) + el path del input para
|
||||
/// contexto.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum NickelEvalError {
|
||||
#[error("io leyendo {path}: {source}")]
|
||||
Io {
|
||||
path: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("evaluación de '{path}' falló:\n{message}")]
|
||||
Eval { path: String, message: String },
|
||||
|
||||
#[error("export a JSON de '{path}' falló:\n{message}")]
|
||||
Export { path: String, message: String },
|
||||
|
||||
#[error("JSON exportado por Nickel no parsea de vuelta: {source}")]
|
||||
JsonReparse {
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Lee `path` (debe ser un `.ncl` válido), lo evalúa profundamente vía
|
||||
/// `nickel-lang` y devuelve el resultado como `serde_json::Value`
|
||||
/// listo para dispatch a un reader JSON.
|
||||
///
|
||||
/// El parent dir del input se agrega como import path para que
|
||||
/// imports relativos tipo `import "./template.ncl"` funcionen sin
|
||||
/// configuración extra. Si `BRAHMAN_CARDS_TEMPLATES_DIR` está set,
|
||||
/// también se agrega.
|
||||
pub fn eval_nickel_file(path: &Path) -> Result<Value, NickelEvalError> {
|
||||
let path_display = path.display().to_string();
|
||||
let source = std::fs::read_to_string(path).map_err(|e| NickelEvalError::Io {
|
||||
path: path_display.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let mut import_paths: Vec<OsString> = Vec::new();
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
import_paths.push(parent.into());
|
||||
}
|
||||
}
|
||||
if let Ok(reg) = std::env::var(BRAHMAN_CARDS_TEMPLATES_ENV) {
|
||||
if !reg.is_empty() {
|
||||
import_paths.push(reg.into());
|
||||
}
|
||||
}
|
||||
|
||||
let mut ctx = nickel_lang::Context::new()
|
||||
.with_added_import_paths(import_paths)
|
||||
.with_source_name(path_display.clone());
|
||||
|
||||
let expr = ctx
|
||||
.eval_deep_for_export(&source)
|
||||
.map_err(|e| NickelEvalError::Eval {
|
||||
path: path_display.clone(),
|
||||
message: format_nickel_error(&e),
|
||||
})?;
|
||||
|
||||
let json_str = ctx
|
||||
.expr_to_json(&expr)
|
||||
.map_err(|e| NickelEvalError::Export {
|
||||
path: path_display.clone(),
|
||||
message: format_nickel_error(&e),
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&json_str).map_err(|e| NickelEvalError::JsonReparse { source: e })
|
||||
}
|
||||
|
||||
/// Formatea un error de Nickel como texto plano. Usa `ErrorFormat::Text`
|
||||
/// (sin ANSI) para que sea legible en logs y mensajes de UI sin
|
||||
/// escape sequences.
|
||||
fn format_nickel_error(err: &nickel_lang::Error) -> String {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
if err
|
||||
.format(&mut buf, nickel_lang::ErrorFormat::Text)
|
||||
.is_err()
|
||||
{
|
||||
// Si la propia formateación falla, devolvemos el Debug —
|
||||
// peor mensaje que el normal pero no perdemos info.
|
||||
return format!("{err:?}");
|
||||
}
|
||||
String::from_utf8(buf).unwrap_or_else(|_| format!("{err:?}"))
|
||||
}
|
||||
@@ -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 (akasha-card)
|
||||
// ============================================================================
|
||||
|
||||
/// Reader para el shape JSON de [`akasha_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 (nahual-meta-schema)
|
||||
// ============================================================================
|
||||
|
||||
/// Reader para el shape JSON de los `module.json` de la metainterfaz
|
||||
/// ([`nahual_meta_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,31 @@
|
||||
# `ente_basic.ncl` — template canónico para Cards de tipo Ente.
|
||||
#
|
||||
# Use case típico: declarar una entity runtime mínima (Virtual
|
||||
# payload, OneShot supervision) sobrescribiendo sólo `id` y `label`:
|
||||
#
|
||||
# let base = import "ente_basic.ncl" in
|
||||
# base & {
|
||||
# id = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
# label = "mi-ente",
|
||||
# }
|
||||
#
|
||||
# El brazo `brahman-cards::load_card` lo dispatcha al
|
||||
# `EnteJsonReader` porque el shape resultante tiene `payload` Y
|
||||
# `supervision` (los campos detect-key del reader Ente).
|
||||
#
|
||||
# **Convención obligatoria**: cada field que el usuario va a
|
||||
# sobrescribir está marcada `| default`. Sin eso Nickel rebota el
|
||||
# merge de strings/numbers no-iguales con misma prioridad.
|
||||
|
||||
{
|
||||
schema_version | Number | default = 1,
|
||||
|
||||
# Identidad: el usuario casi siempre las sobrescribe.
|
||||
id | String | default = "TEMPLATE_ID",
|
||||
label | String | default = "TEMPLATE_LABEL",
|
||||
|
||||
# Runtime defaults razonables: nodo lógico sin proceso, sin
|
||||
# restart. Override si querés un ente con payload Wasm/Native.
|
||||
payload | default = "Virtual",
|
||||
supervision | default = "OneShot",
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# `monad_basic.ncl` — template canónico para Cards de tipo Monad.
|
||||
#
|
||||
# Use case típico: declarar una agrupación semántica de archivos
|
||||
# (Mónada de Nouser) con metadata mínima:
|
||||
#
|
||||
# let base = import "monad_basic.ncl" in
|
||||
# base & {
|
||||
# id = "01ARZ3NDEKTSV4RRFFQ69G5FAW",
|
||||
# label = "fotos-2026",
|
||||
# members = ["01ARZ3FILE1", "01ARZ3FILE2"],
|
||||
# cardinality = 2,
|
||||
# }
|
||||
#
|
||||
# El brazo lo dispatcha al `MonadJsonReader` por la presencia
|
||||
# simultánea de `members` Y `cardinality`.
|
||||
|
||||
{
|
||||
schema_version | Number | default = 1,
|
||||
|
||||
# Identidad: override siempre.
|
||||
id | String | default = "TEMPLATE_ID",
|
||||
label | String | default = "TEMPLATE_LABEL",
|
||||
|
||||
# Metadata semántica: defaults vacíos. El usuario typically
|
||||
# override `members` + `cardinality`, opcionalmente `summary`
|
||||
# / `keywords` / `dominant_lens`.
|
||||
summary | String | default = "",
|
||||
keywords | default = [],
|
||||
centroid | default = [],
|
||||
cardinality | Number | default = 0,
|
||||
entropy | Number | default = 0.0,
|
||||
# Lens variants serialize lowercase (serde rename_all): grid /
|
||||
# code / gallery / database / markdown / tree.
|
||||
dominant_lens | default = "grid",
|
||||
|
||||
# Membership: vacío por default. El usuario llena con los IDs
|
||||
# de archivo cuando los conoce.
|
||||
members | default = [],
|
||||
pins | default = [],
|
||||
|
||||
# Timestamps Unix ms — default 0 = "no timestamp registrado".
|
||||
# Override con el momento real cuando importa.
|
||||
created_at_ms | Number | default = 0,
|
||||
updated_at_ms | Number | default = 0,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# `ui_module_basic.ncl` — template canónico para Cards de tipo
|
||||
# UiModule (descriptores de módulos para metainterfaz yahweh).
|
||||
#
|
||||
# Use case típico: declarar un módulo nuevo sobrescribiendo `id`,
|
||||
# `label`, y aportando los `entities`/`menu`/`views` propios:
|
||||
#
|
||||
# let base = import "ui_module_basic.ncl" in
|
||||
# base & {
|
||||
# id = "customers",
|
||||
# label = "Clientes",
|
||||
# entities = [
|
||||
# { name = "Customer", label = "Cliente", fields = [...] },
|
||||
# ],
|
||||
# menu = [{ label = "Listar", view = "list" }],
|
||||
# views = { list = { kind = "list", ... } },
|
||||
# }
|
||||
#
|
||||
# El brazo lo dispatcha al `UiModuleJsonReader` por la presencia
|
||||
# simultánea de `entities` Y `views` Y `menu`.
|
||||
|
||||
{
|
||||
# Identidad: override siempre.
|
||||
id | String | default = "TEMPLATE_ID",
|
||||
label | String | default = "TEMPLATE_LABEL",
|
||||
|
||||
# Subtítulo opcional (tooltip en el sidebar). null por default.
|
||||
description | default = null,
|
||||
|
||||
# Las 3 listas/maps son el **payload** real del módulo. El
|
||||
# template las deja vacías para que el usuario las defina sin
|
||||
# heredar nada útil-pero-equivocado de un default.
|
||||
entities | default = [],
|
||||
menu | default = [],
|
||||
views | default = {},
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
//! 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());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// load_cards_from_dir (subdir walking)
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn load_cards_from_dir_walks_subdirs_and_finds_module_json() {
|
||||
let root = unique_dir("dir-walk");
|
||||
// Subdir A: tiene module.json (UiModule).
|
||||
let a = root.join("alpha");
|
||||
std::fs::create_dir(&a).unwrap();
|
||||
std::fs::write(
|
||||
a.join("module.json"),
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"id": "alpha",
|
||||
"label": "Alpha",
|
||||
"entities": [],
|
||||
"menu": [],
|
||||
"views": {}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
// Subdir B: tiene module.json (UiModule).
|
||||
let b = root.join("bravo");
|
||||
std::fs::create_dir(&b).unwrap();
|
||||
std::fs::write(
|
||||
b.join("module.json"),
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"id": "bravo",
|
||||
"label": "Bravo",
|
||||
"entities": [],
|
||||
"menu": [],
|
||||
"views": {}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
// Subdir C: NO tiene ninguno de los filenames convencionales —
|
||||
// se debe skipear sin error.
|
||||
let c = root.join("charlie");
|
||||
std::fs::create_dir(&c).unwrap();
|
||||
std::fs::write(c.join("readme.txt"), b"sin card aca").unwrap();
|
||||
|
||||
let cards = brahman_cards::load_cards_from_dir(&root).expect("ok");
|
||||
let ids: Vec<&str> = cards.iter().map(|c| c.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["alpha", "bravo"],
|
||||
"orden lexicográfico por subdir name"
|
||||
);
|
||||
for c in &cards {
|
||||
assert_eq!(c.body.kind_name(), "ui_module");
|
||||
}
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cards_from_dir_prefers_ncl_over_json_when_both_present() {
|
||||
let root = unique_dir("dir-prefer");
|
||||
let sub = root.join("only");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
// Ambos archivos existen; el .ncl debería ganar.
|
||||
std::fs::write(
|
||||
sub.join("card.ncl"),
|
||||
r#"{ id = "from_ncl", label = "Ncl", entities = [], menu = [], views = {} }"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
sub.join("card.json"),
|
||||
serde_json::to_vec(&json!({
|
||||
"id": "from_json",
|
||||
"label": "Json",
|
||||
"entities": [], "menu": [], "views": {}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cards = brahman_cards::load_cards_from_dir(&root).expect("ok");
|
||||
assert_eq!(cards.len(), 1);
|
||||
assert_eq!(cards[0].id, "from_ncl", "card.ncl tiene prioridad");
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cards_from_dir_propagates_per_file_errors_loud() {
|
||||
let root = unique_dir("dir-error-loud");
|
||||
let sub = root.join("broken");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
std::fs::write(sub.join("card.json"), b"{ this is not valid json").unwrap();
|
||||
|
||||
let err = brahman_cards::load_cards_from_dir(&root).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, CardLoadError::JsonParse(_)),
|
||||
"el error de un file roto debe propagar fail-loud, got {err:?}"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cards_from_dir_with_custom_filenames() {
|
||||
let root = unique_dir("dir-custom-fname");
|
||||
let sub = root.join("only");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
// Filename custom que NO está en el default.
|
||||
std::fs::write(
|
||||
sub.join("manifest.json"),
|
||||
serde_json::to_vec(&json!({
|
||||
"id": "x",
|
||||
"label": "X",
|
||||
"entities": [], "menu": [], "views": {}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Default no encuentra nada (skipea):
|
||||
let with_default = brahman_cards::load_cards_from_dir(&root).unwrap();
|
||||
assert_eq!(with_default.len(), 0, "default filenames no incluye manifest.json");
|
||||
|
||||
// Custom filename encuentra:
|
||||
let with_custom = brahman_cards::load_cards_from_dir_with(
|
||||
&root,
|
||||
&["manifest.json"],
|
||||
&brahman_cards::default_readers(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(with_custom.len(), 1);
|
||||
assert_eq!(with_custom[0].id, "x");
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 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
|
||||
}
|
||||
|
||||
fn unique_dir(name: &str) -> std::path::PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"brahman-cards-test-{}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0),
|
||||
name
|
||||
));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Nickel reader + templates.
|
||||
//!
|
||||
//! V2 del brazo: la dispatcher acepta archivos `.ncl`. La evaluación
|
||||
//! produce JSON intermedio que va a los readers estándar, así que un
|
||||
//! `.ncl` puede generar cualquier `CardBody` siempre que su shape sea
|
||||
//! reconocida.
|
||||
//!
|
||||
//! Templates: Nickel `import` + `&` merge nativos. El brazo no
|
||||
//! inventa nada — sólo agrega el parent dir + el env
|
||||
//! `BRAHMAN_CARDS_TEMPLATES_DIR` al import path.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use brahman_cards::{
|
||||
eval_nickel_file, load_card, CardBody, CardLoadError, NickelEvalError,
|
||||
BRAHMAN_CARDS_TEMPLATES_ENV,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
// ===========================================================================
|
||||
// Helpers
|
||||
// ===========================================================================
|
||||
|
||||
fn unique_dir(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"brahman-cards-nickel-{}-{}-{}",
|
||||
std::process::id(),
|
||||
nanos(),
|
||||
name
|
||||
));
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
fn nanos() -> u128 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn write_file(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
|
||||
let p = dir.join(name);
|
||||
fs::write(&p, content).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 1. Evaluación directa: Nickel → Value
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn eval_nickel_file_returns_value_for_valid_input() {
|
||||
let dir = unique_dir("eval-basic");
|
||||
let p = write_file(
|
||||
&dir,
|
||||
"card.ncl",
|
||||
r#"
|
||||
{
|
||||
id = "demo",
|
||||
label = "Demo",
|
||||
entities = [],
|
||||
menu = [],
|
||||
views = {},
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let v = eval_nickel_file(&p).expect("eval ok");
|
||||
assert_eq!(v.get("id"), Some(&json!("demo")));
|
||||
assert_eq!(v.get("label"), Some(&json!("Demo")));
|
||||
assert!(v.get("entities").is_some());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_nickel_file_surfaces_evaluation_error() {
|
||||
let dir = unique_dir("eval-err");
|
||||
let p = write_file(
|
||||
&dir,
|
||||
"broken.ncl",
|
||||
r#"
|
||||
{
|
||||
id = "x",
|
||||
label = doesnotexist,
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let err = eval_nickel_file(&p).unwrap_err();
|
||||
match err {
|
||||
NickelEvalError::Eval { path, message } => {
|
||||
assert!(path.contains("broken.ncl"));
|
||||
assert!(!message.is_empty(), "el msg debe traer info de Nickel");
|
||||
}
|
||||
other => panic!("expected Eval error, got {other:?}"),
|
||||
}
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 2. load_card pipeline: .ncl → Card
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn load_card_dispatches_ncl_to_ui_module_variant() {
|
||||
let dir = unique_dir("dispatch-ui");
|
||||
let p = write_file(
|
||||
&dir,
|
||||
"module.ncl",
|
||||
r#"
|
||||
{
|
||||
id = "demo",
|
||||
label = "Demo",
|
||||
entities = [],
|
||||
menu = [{ label = "Stock", view = "stock_list" }],
|
||||
views = {
|
||||
stock_list = {
|
||||
kind = "list",
|
||||
title = "Stock",
|
||||
entity = "Stock",
|
||||
columns = [],
|
||||
},
|
||||
},
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let card = load_card(&p).expect("load ok");
|
||||
assert_eq!(card.body.kind_name(), "ui_module");
|
||||
assert_eq!(card.id, "demo");
|
||||
assert_eq!(card.label, "Demo");
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_card_dispatches_ncl_to_ente_variant() {
|
||||
let dir = unique_dir("dispatch-ente");
|
||||
let p = write_file(
|
||||
&dir,
|
||||
"ente.ncl",
|
||||
r#"
|
||||
{
|
||||
schema_version = 1,
|
||||
id = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
label = "test-ente",
|
||||
payload = "Virtual",
|
||||
supervision = "OneShot",
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let card = load_card(&p).expect("load ok");
|
||||
assert_eq!(card.body.kind_name(), "ente");
|
||||
assert_eq!(card.id, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 3. Templates: import + merge native de Nickel
|
||||
// ===========================================================================
|
||||
|
||||
/// El caso de uso que el usuario describió: "un Card simple usa un
|
||||
/// Card ya hecho cambiando sólo nombre y id". Template define la
|
||||
/// shape full; el archivo concreto importa + override.
|
||||
#[test]
|
||||
fn template_merge_overrides_id_and_label_only() {
|
||||
let dir = unique_dir("template-merge");
|
||||
|
||||
// Template con la shape full de un UiModule. Los campos
|
||||
// sobrescribibles se marcan `| default` — Nickel sólo permite
|
||||
// override en merge cuando hay diferencia de prioridad. Sin
|
||||
// `| default` los strings no-iguales fallan con "non mergeable".
|
||||
write_file(
|
||||
&dir,
|
||||
"ui_module_basic.ncl",
|
||||
r#"
|
||||
{
|
||||
id | String | default = "TEMPLATE_ID",
|
||||
label | String | default = "TEMPLATE_LABEL",
|
||||
description = "stock + form básico",
|
||||
entities = [
|
||||
{ name = "Item", label = "Item", fields = [] },
|
||||
],
|
||||
menu = [
|
||||
{ label = "Items", view = "items_list" },
|
||||
{ label = "+ Item", view = "items_form" },
|
||||
],
|
||||
views = {
|
||||
items_list = {
|
||||
kind = "list",
|
||||
title = "Items",
|
||||
entity = "Item",
|
||||
columns = [],
|
||||
},
|
||||
items_form = {
|
||||
kind = "form",
|
||||
title = "Nuevo item",
|
||||
entity = "Item",
|
||||
fields = [],
|
||||
on_submit = {
|
||||
kind = "seed_entity",
|
||||
entity = "Item",
|
||||
next_view = "items_list",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
"#,
|
||||
);
|
||||
|
||||
// Card concreto: import + merge override.
|
||||
let p = write_file(
|
||||
&dir,
|
||||
"my_module.ncl",
|
||||
r#"
|
||||
let base = import "ui_module_basic.ncl" in
|
||||
base & {
|
||||
id = "my_module",
|
||||
label = "Mi Módulo",
|
||||
}
|
||||
"#,
|
||||
);
|
||||
|
||||
let card = load_card(&p).expect("template merge ok");
|
||||
assert_eq!(card.id, "my_module", "el override del id se aplicó");
|
||||
assert_eq!(card.label, "Mi Módulo", "el override del label se aplicó");
|
||||
assert_eq!(card.body.kind_name(), "ui_module");
|
||||
match card.body {
|
||||
CardBody::UiModule(m) => {
|
||||
// El resto viene del template intacto.
|
||||
assert_eq!(m.menu.len(), 2);
|
||||
assert_eq!(m.entities.len(), 1);
|
||||
assert_eq!(m.entities[0].name, "Item");
|
||||
}
|
||||
other => panic!("variant inesperado: {:?}", other.kind_name()),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// El env `BRAHMAN_CARDS_TEMPLATES_DIR` permite tener un registry
|
||||
/// global: el usuario importa por nombre desnudo desde cualquier
|
||||
/// ubicación.
|
||||
///
|
||||
/// Este test setea/unset el env de forma local (no thread-safe en
|
||||
/// tests paralelos contra el mismo env, pero usamos una key dedicada
|
||||
/// y borramos después). Si se vuelve flaky, agregar mutex.
|
||||
#[test]
|
||||
fn template_resolves_via_env_registry() {
|
||||
let registry = unique_dir("template-registry");
|
||||
let inputs = unique_dir("template-input");
|
||||
|
||||
write_file(
|
||||
®istry,
|
||||
"ui_module_minimal.ncl",
|
||||
r#"
|
||||
{
|
||||
id | String | default = "X",
|
||||
label | String | default = "X",
|
||||
entities = [],
|
||||
menu = [],
|
||||
views = {},
|
||||
}
|
||||
"#,
|
||||
);
|
||||
|
||||
let p = write_file(
|
||||
&inputs,
|
||||
"from_registry.ncl",
|
||||
r#"
|
||||
let base = import "ui_module_minimal.ncl" in
|
||||
base & { id = "registry_user", label = "Usado del Registry" }
|
||||
"#,
|
||||
);
|
||||
|
||||
// Set env, evaluar, restaurar.
|
||||
let prev = std::env::var(BRAHMAN_CARDS_TEMPLATES_ENV).ok();
|
||||
// SAFETY: nickel-lang tests modifican un env ad-hoc que no es
|
||||
// referenciado por nada externo y se restaura al salir. Ningún
|
||||
// otro test del crate lee este env.
|
||||
unsafe {
|
||||
std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, ®istry);
|
||||
}
|
||||
|
||||
let result = load_card(&p);
|
||||
|
||||
unsafe {
|
||||
if let Some(v) = prev {
|
||||
std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, v);
|
||||
} else {
|
||||
std::env::remove_var(BRAHMAN_CARDS_TEMPLATES_ENV);
|
||||
}
|
||||
}
|
||||
|
||||
let card = result.expect("template via registry ok");
|
||||
assert_eq!(card.id, "registry_user");
|
||||
assert_eq!(card.body.kind_name(), "ui_module");
|
||||
|
||||
fs::remove_dir_all(®istry).ok();
|
||||
fs::remove_dir_all(&inputs).ok();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 4. Errores propagan limpios al CardLoadError
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn load_card_wraps_nickel_error_in_card_load_error() {
|
||||
let dir = unique_dir("wrap-err");
|
||||
let p = write_file(&dir, "bad.ncl", "let x = unknown in x");
|
||||
let err = load_card(&p).unwrap_err();
|
||||
match err {
|
||||
CardLoadError::Nickel(NickelEvalError::Eval { .. }) => {} // expected
|
||||
other => panic!("expected Nickel(Eval), got {other:?}"),
|
||||
}
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// El value-add concreto de Nickel sobre JSON: un contract
|
||||
/// violation se captura en evaluación, ANTES de que el reader
|
||||
/// JSON tenga oportunidad de aceptar un shape mal-tipado. Acá un
|
||||
/// `id | String` con un value que no es String falla en eval-time
|
||||
/// con un mensaje legible. JSON puro lo aceptaría y rompería más
|
||||
/// tarde aguas abajo.
|
||||
#[test]
|
||||
fn nickel_contract_violation_caught_at_eval_time() {
|
||||
let dir = unique_dir("contract-violation");
|
||||
let p = write_file(
|
||||
&dir,
|
||||
"bad_id.ncl",
|
||||
r#"
|
||||
{
|
||||
id | String = 42,
|
||||
label = "X",
|
||||
entities = [],
|
||||
menu = [],
|
||||
views = {},
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let err = load_card(&p).unwrap_err();
|
||||
match err {
|
||||
CardLoadError::Nickel(NickelEvalError::Eval { message, .. }) => {
|
||||
// Mensaje de contract violation legible.
|
||||
assert!(
|
||||
message.contains("contract") || message.contains("String"),
|
||||
"msg debe mencionar contract o String: {message}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Nickel(Eval), got {other:?}"),
|
||||
}
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Sanity: un Nickel que evalúa a un shape NO-reconocible (no
|
||||
/// matchea ningún reader) cae en `NoMatchingReader` — la cadena
|
||||
/// Nickel + dispatcher se mantiene coherente.
|
||||
#[test]
|
||||
fn ncl_evaluating_to_unknown_shape_returns_no_matching_reader() {
|
||||
let dir = unique_dir("unknown-shape");
|
||||
let p = write_file(
|
||||
&dir,
|
||||
"weird.ncl",
|
||||
r#"{ random = "shape", without = "fingerprint" }"#,
|
||||
);
|
||||
let err = load_card(&p).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, CardLoadError::NoMatchingReader),
|
||||
"expected NoMatchingReader, got {err:?}"
|
||||
);
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Tests E2E de los templates canónicos shipped con el crate.
|
||||
//!
|
||||
//! Cada test escribe un Card user-side en un tempdir, importa el
|
||||
//! template canónico, override id/label/etc., y verifica que el
|
||||
//! brazo lo dispatcha al variant correcto del CardBody con los
|
||||
//! valores merged.
|
||||
//!
|
||||
//! `BRAHMAN_CARDS_TEMPLATES_DIR` se setea localmente en cada test.
|
||||
//! Como Nickel también busca relativo al input file, usamos el env
|
||||
//! para que `import "ente_basic.ncl"` (sin path) resuelva desde
|
||||
//! cualquier ubicación del input.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use brahman_cards::{
|
||||
canonical_templates_dir, load_card, CardBody, BRAHMAN_CARDS_TEMPLATES_ENV,
|
||||
};
|
||||
|
||||
/// Helper: corre `f()` con `BRAHMAN_CARDS_TEMPLATES_ENV` set al
|
||||
/// directorio de templates canónicos, restaurando el env al salir.
|
||||
///
|
||||
/// Tests no son thread-safe entre sí cuando comparten env. Por eso
|
||||
/// quedan en serial via `nextest --test-threads=1` o `cargo test`
|
||||
/// que paralelizara sólo entre `tests/*.rs` distintos. Como este
|
||||
/// archivo encapsula todo el setup de env, aún en paralelo entre
|
||||
/// archivos de tests no chocan (cada thread setea/restaura).
|
||||
fn with_canonical_templates<F: FnOnce()>(f: F) {
|
||||
let prev = std::env::var(BRAHMAN_CARDS_TEMPLATES_ENV).ok();
|
||||
let dir = canonical_templates_dir();
|
||||
// SAFETY: env mutation single-threaded en este test.
|
||||
unsafe {
|
||||
std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, &dir);
|
||||
}
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
|
||||
unsafe {
|
||||
match prev {
|
||||
Some(v) => std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, v),
|
||||
None => std::env::remove_var(BRAHMAN_CARDS_TEMPLATES_ENV),
|
||||
}
|
||||
}
|
||||
if let Err(panic) = result {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_dir(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"brahman-cards-templates-{}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0),
|
||||
name
|
||||
));
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ente_basic_template_overridden_loads_as_ente_card() {
|
||||
with_canonical_templates(|| {
|
||||
let dir = unique_dir("ente");
|
||||
let card_path = dir.join("my_ente.ncl");
|
||||
fs::write(
|
||||
&card_path,
|
||||
r#"
|
||||
let base = import "ente_basic.ncl" in
|
||||
base & {
|
||||
id = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
label = "mi-ente",
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let card = load_card(&card_path).expect("load ente");
|
||||
assert_eq!(card.id, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
|
||||
assert_eq!(card.label, "mi-ente");
|
||||
assert_eq!(card.body.kind_name(), "ente");
|
||||
match card.body {
|
||||
CardBody::Ente(e) => {
|
||||
assert_eq!(e.label, "mi-ente");
|
||||
// Defaults del template intactos.
|
||||
assert_eq!(e.schema_version, 1);
|
||||
// Payload es el "Virtual" del template default.
|
||||
assert!(
|
||||
matches!(e.payload, brahman_card::Payload::Virtual),
|
||||
"payload debería ser Virtual, got {:?}",
|
||||
e.payload
|
||||
);
|
||||
}
|
||||
other => panic!("variant inesperado: {:?}", other.kind_name()),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monad_basic_template_overridden_loads_as_monad_card() {
|
||||
with_canonical_templates(|| {
|
||||
let dir = unique_dir("monad");
|
||||
let card_path = dir.join("my_monad.ncl");
|
||||
fs::write(
|
||||
&card_path,
|
||||
r#"
|
||||
let base = import "monad_basic.ncl" in
|
||||
base & {
|
||||
id = "01ARZ3NDEKTSV4RRFFQ69G5FAW",
|
||||
label = "fotos-2026",
|
||||
cardinality = 5,
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let card = load_card(&card_path).expect("load monad");
|
||||
assert_eq!(card.id, "01ARZ3NDEKTSV4RRFFQ69G5FAW");
|
||||
assert_eq!(card.label, "fotos-2026");
|
||||
assert_eq!(card.body.kind_name(), "monad");
|
||||
match card.body {
|
||||
CardBody::Monad(m) => {
|
||||
assert_eq!(m.label, "fotos-2026");
|
||||
assert_eq!(m.cardinality, 5);
|
||||
// Defaults del template intactos.
|
||||
assert_eq!(m.schema_version, 1);
|
||||
assert!(m.members.is_empty());
|
||||
assert!(m.summary.is_empty());
|
||||
}
|
||||
other => panic!("variant inesperado: {:?}", other.kind_name()),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_module_basic_template_overridden_loads_as_ui_module_card() {
|
||||
with_canonical_templates(|| {
|
||||
let dir = unique_dir("ui");
|
||||
let card_path = dir.join("my_module.ncl");
|
||||
fs::write(
|
||||
&card_path,
|
||||
r#"
|
||||
let base = import "ui_module_basic.ncl" in
|
||||
base & {
|
||||
id = "customers",
|
||||
label = "Clientes",
|
||||
menu = [{ label = "Lista", view = "list" }],
|
||||
views = {
|
||||
list = {
|
||||
kind = "list",
|
||||
title = "Customers",
|
||||
entity = "Customer",
|
||||
columns = [],
|
||||
},
|
||||
},
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let card = load_card(&card_path).expect("load ui_module");
|
||||
assert_eq!(card.id, "customers");
|
||||
assert_eq!(card.label, "Clientes");
|
||||
assert_eq!(card.body.kind_name(), "ui_module");
|
||||
match card.body {
|
||||
CardBody::UiModule(m) => {
|
||||
assert_eq!(m.id, "customers");
|
||||
assert_eq!(m.menu.len(), 1);
|
||||
assert!(m.views.contains_key("list"));
|
||||
// Defaults del template: entities vacío.
|
||||
assert!(m.entities.is_empty());
|
||||
}
|
||||
other => panic!("variant inesperado: {:?}", other.kind_name()),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_default_id_and_label_pass_through_when_not_overridden() {
|
||||
// Sanity: si el usuario importa el template SIN override de
|
||||
// id/label, los defaults `"TEMPLATE_ID"` y `"TEMPLATE_LABEL"`
|
||||
// pasan al wrapper Card.id/label. El brazo no falla — sólo
|
||||
// los muestra como están. Validar este flow garantiza que un
|
||||
// user "vacío" (importa y no override) carga sin error.
|
||||
with_canonical_templates(|| {
|
||||
let dir = unique_dir("defaults");
|
||||
let card_path = dir.join("noop.ncl");
|
||||
fs::write(&card_path, r#"import "ui_module_basic.ncl""#).unwrap();
|
||||
|
||||
let card = load_card(&card_path).expect("load defaults");
|
||||
assert_eq!(card.id, "TEMPLATE_ID");
|
||||
assert_eq!(card.label, "TEMPLATE_LABEL");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_templates_dir_actually_exists() {
|
||||
// Sanity: el path expuesto por canonical_templates_dir tiene
|
||||
// que apuntar a un directorio que existe físicamente, sino los
|
||||
// tests anteriores fallarían silenciosamente (Nickel reporta
|
||||
// import-not-found pero el test ya estaría roto).
|
||||
let d = canonical_templates_dir();
|
||||
assert!(d.is_dir(), "templates dir no existe: {}", d.display());
|
||||
for fname in ["ente_basic.ncl", "monad_basic.ncl", "ui_module_basic.ncl"] {
|
||||
let p = d.join(fname);
|
||||
assert!(p.is_file(), "template missing: {}", p.display());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user