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 `. ## 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: 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()); + } +}