feat(brahman-cards): templates Nickel canónicos para cada body kind
Materializa el patrón "import + override" del brazo. Hasta ahora
BRAHMAN_CARDS_TEMPLATES_DIR existía como mecanismo pero el repo no
shippeaba ningún template.
3 templates basic bajo crates/core/brahman-cards/templates/:
- ente_basic.ncl: Card runtime mínima (Virtual + OneShot).
- monad_basic.ncl: Mónada con metadata vacía.
- ui_module_basic.ncl: descriptor UI con entities/menu/views vacíos.
Cada field override-able marcada `| default` (sin eso Nickel rebota
merge de strings/numbers no-iguales).
Nuevo `pub fn canonical_templates_dir() -> PathBuf` resuelve el dir
via CARGO_MANIFEST_DIR. Para distribución del binary standalone
queda como pending (include_dir! o convención de install path).
5 tests E2E que cubren los 3 templates con import+override, sanity
del default sin override, y existencia física del dir.
Tests brahman-cards: 26 → 31 (+5). Workspace intacto.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 81d1b3f..cb44952 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,79 @@ ratio/diff ver `git show <sha>`.
## 2026-05-10
+### feat(brahman-cards): templates Nickel canónicos para cada body kind
+Materializa el patrón "import + override" del brazo: hasta ahora
+`BRAHMAN_CARDS_TEMPLATES_DIR` existía como mecanismo pero el repo
+no shippeaba ningún template. Ahora hay 3 templates basic (uno
+por body kind del CardBody) bajo
+`crates/core/brahman-cards/templates/`:
+
+- **`ente_basic.ncl`** — Card runtime mínima: `payload="Virtual"`,
+ `supervision="OneShot"`, `schema_version=1`. Override típico:
+ `id` + `label`.
+- **`monad_basic.ncl`** — agrupación semántica de archivos
+ (Mónada Nouser): metadata vacía, `dominant_lens="grid"` (lowercase
+ por convención serde rename_all). Override típico: `id`, `label`,
+ `members`, `cardinality`.
+- **`ui_module_basic.ncl`** — descriptor UI con `entities=[]`,
+ `menu=[]`, `views={}`. Override típico: `id`, `label` y los
+ 3 payloads.
+
+Cada field override-able marcada `| default` (sin eso Nickel
+rebota merge de strings/numbers no-iguales).
+
+API nueva en `lib.rs`:
+- **`pub fn canonical_templates_dir() -> PathBuf`**: devuelve el
+ directorio de templates del crate (resuelto via
+ `CARGO_MANIFEST_DIR`). Útil para apuntar el env
+ `BRAHMAN_CARDS_TEMPLATES_DIR` en runtime/tests sin hardcoding
+ del path.
+- Doc explica que para distribución del binary standalone (cuando
+ emerja), incluir templates como recursos via `include_dir!` o
+ instalar el directorio junto al ejecutable.
+
+5 tests E2E (`tests/templates.rs`) que cubren:
+- `ente_basic` import + override `id`+`label` → Card body Ente
+ con `payload=Virtual` (default preserved).
+- `monad_basic` import + override `id`+`label`+`cardinality` →
+ Card body Monad con members=[] y summary="" (defaults).
+- `ui_module_basic` import + override de `id`+`label`+menu+views
+ → Card body UiModule con entities=[] (default).
+- Sanity: import sin override → defaults `"TEMPLATE_ID"` /
+ `"TEMPLATE_LABEL"` pasan al wrapper sin error.
+- Sanity: el path de `canonical_templates_dir()` apunta a un
+ directorio existente con los 3 archivos esperados.
+
+Helper de test `with_canonical_templates(F)` setea/restaura el
+env localmente; cada test single-thread-safe.
+
+Tests suite brahman-cards: 26 → **31** verdes (+5). El resto del
+workspace intacto.
+
+Beneficio operativo:
+- Un usuario que quiera declarar un Card nuevo puede empezar con
+ un override de 2 líneas (`id` + `label`) en lugar de copiar el
+ shape full desde cero.
+- Templates auto-documentan la convención `| default` para que
+ copiar uno y agregar fields propios "just works" en merge.
+- El brazo sigue siendo agnostic — los templates son sólo
+ archivos `.ncl` resueltos via el import resolver Nickel; nada
+ hardcoded en código Rust.
+
+Limitaciones:
+- No hay templates "ricos" tipo `crud_basic.ncl` que parametricen
+ por entity name. Nickel no expone funciones-templates de la
+ forma típica de templating engines; lo más cercano sería un
+ template con un field `entity_name | String` y references
+ internas via `me.entity_name`. Cuando aparezca el caso de uso
+ real (e.g., un módulo donde el patrón list+form es repetitivo),
+ se diseña el template paramétrico.
+- `canonical_templates_dir()` resuelve via `CARGO_MANIFEST_DIR` —
+ funciona en `cargo` (test/run/build) pero no para un binary
+ instalado fuera del workspace. Para release distribution la API
+ necesitará un fallback (resources embedded o convención de
+ install path).
+
### refactor(nakui-core): KCL → Nickel — `kcl_wrapper` reemplazado por evaluación in-process
Cierra el ciclo: el motor de validación de entities deja de
shellear el binario externo `kcl` y pasa a evaluar **Nickel
diff --git a/crates/core/brahman-cards/src/lib.rs b/crates/core/brahman-cards/src/lib.rs
index 1a6a529..3ebf20f 100644
--- a/crates/core/brahman-cards/src/lib.rs
+++ b/crates/core/brahman-cards/src/lib.rs
@@ -41,7 +41,7 @@
#![forbid(unsafe_code)]
use std::collections::BTreeMap;
-use std::path::Path;
+use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -188,6 +188,24 @@ 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
diff --git a/crates/core/brahman-cards/templates/ente_basic.ncl b/crates/core/brahman-cards/templates/ente_basic.ncl
new file mode 100644
index 0000000..03aecd3
--- /dev/null
+++ b/crates/core/brahman-cards/templates/ente_basic.ncl
@@ -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",
+}
diff --git a/crates/core/brahman-cards/templates/monad_basic.ncl b/crates/core/brahman-cards/templates/monad_basic.ncl
new file mode 100644
index 0000000..0061d87
--- /dev/null
+++ b/crates/core/brahman-cards/templates/monad_basic.ncl
@@ -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,
+}
diff --git a/crates/core/brahman-cards/templates/ui_module_basic.ncl b/crates/core/brahman-cards/templates/ui_module_basic.ncl
new file mode 100644
index 0000000..219f899
--- /dev/null
+++ b/crates/core/brahman-cards/templates/ui_module_basic.ncl
@@ -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 = {},
+}
diff --git a/crates/core/brahman-cards/tests/templates.rs b/crates/core/brahman-cards/tests/templates.rs
new file mode 100644
index 0000000..0ea855d
--- /dev/null
+++ b/crates/core/brahman-cards/tests/templates.rs
@@ -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());
+ }
+}