feat(brahman-cards): Nickel reader + templates con merge nativo (V2)

El brazo ahora acepta `.ncl`: evalúa via nickel-lang 2.0, exporta a
JSON, dispatcha por los readers JSON estándar. Templates funcionan
con import + & merge nativos de Nickel — el brazo no inventa
mecánica paralela.

- Dep nickel-lang = "2.0.0" (interfaz estable).
- Nuevo módulo nickel_eval con eval_nickel_file(path) -> Value y
  errores tipados (Io/Eval/Export/JsonReparse). Mensaje de Nickel
  como texto plano sin ANSI.
- load_card_with añade arm "ncl" simétrico al "json".
- CardLoadError::Nickel propaga el error limpio.
- Imports resueltos: parent dir del input + env
  BRAHMAN_CARDS_TEMPLATES_DIR (registry global, opcional).
- Convención obligatoria documentada: fields override-ables del
  template usan `| default` (sin eso Nickel rechaza el merge).

9 tests nuevos: eval directo, dispatch a UiModule/Ente, template
merge con id+label override, registry via env, error wrapping,
contract violation en eval-time (`id | String = 42`), shape
desconocida.

22 tests totales en brahman-cards (13 JSON V1 + 9 Nickel V2).
Workspace build verde.

NO hace: migrar consumers, set canonical de templates, KCL→Nickel
— todos para commits siguientes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-09 23:25:57 +00:00
parent 09501911b7
commit 2a4443790c
6 changed files with 1185 additions and 27 deletions
+1
View File
@@ -16,6 +16,7 @@ ulid = { workspace = true }
brahman-card = { path = "../brahman-card" }
nouser-card = { path = "../../modules/nouser/card" }
nakui-ui-schema = { path = "../../modules/nakui/ui-schema" }
nickel-lang = "2.0.0"
[dev-dependencies]
serde_json = { workspace = true }
+15 -1
View File
@@ -157,6 +157,9 @@ pub enum CardLoadError {
ext: String,
supported: Vec<&'static str>,
},
#[error("evaluación Nickel: {0}")]
Nickel(#[from] NickelEvalError),
}
/// Trait de reader. Cada formato implementa una instancia.
@@ -180,7 +183,9 @@ pub trait CardReader: Send + Sync {
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};
/// Construye el set default de readers para inputs JSON. El orden
@@ -222,9 +227,18 @@ pub fn load_card_with(
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"],
supported: vec!["json", "ncl"],
}),
}
}
@@ -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:?}"))
}
+371
View File
@@ -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(
&registry,
"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, &registry);
}
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(&registry).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();
}