tawasuyu

tawasuyu/brahman

Rust 467TOML 184Markdown 49JSON 176411 Sutichaykuna

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>

2a4443790c09b64e310ca3a3d6f25e4b7f058e4f · ruwaq Sergio · (5 killa ñawpaq) · tayta 09501911b · kay commitpi sach'ata qhaway

Semántico sayk'uy · 19

crates/core/brahman-cards/src/lib.rs

crates/core/brahman-cards/src/nickel_eval.rs

6 qillqakuna · +1185 −27

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8995cb..ef0d8b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,101 @@ ratio/diff ver `git show <sha>`.
 
 ## 2026-05-09
 
+### feat(brahman-cards): Nickel reader + templates con merge nativo (V2)
+Sigue al V1 (readers JSON). Ahora el brazo acepta inputs `.ncl`:
+los evalúa via `nickel-lang` 2.0, exporta a JSON, y dispatcha por
+los mismos readers JSON estándar. Un `.ncl` puede producir
+cualquier `CardBody` siempre que su shape sea reconocida. Los
+templates funcionan con los `import` + `&` merge nativos de
+Nickel — el brazo no inventa una mecánica paralela.
+
+Cambios:
+- **Dep `nickel-lang = "2.0.0"`** (interfaz estable, no
+  `nickel-lang-core` que es internal/inestable). Compila clean
+  pero suma ~1 min al build cold del crate.
+- **Nuevo módulo `nickel_eval.rs`** con `eval_nickel_file(path) ->
+  Result<Value, NickelEvalError>`. Errores tipados:
+  `Io`, `Eval`, `Export`, `JsonReparse` — el mensaje de Nickel se
+  formatea como texto plano (sin ANSI) para que sea legible en
+  logs y toasts.
+- **`load_card_with` añade `"ncl"`**: lee archivo → eval Nickel →
+  exporta a JSON → parsea de vuelta a Value → dispatch a los
+  readers JSON. Pipeline simétrico a `"json"`.
+- **`CardLoadError::Nickel(NickelEvalError)`**: el error de
+  Nickel se propaga limpio al error público del brazo.
+- **Resolución de imports**:
+  - El parent dir del input se agrega como import path → `import
+    "./template.ncl"` resuelve sin config.
+  - El env `BRAHMAN_CARDS_TEMPLATES_DIR` (constante exportada
+    `BRAHMAN_CARDS_TEMPLATES_ENV`) agrega un registry global →
+    `import "ui_module_minimal.ncl"` desde cualquier ubicación.
+  - No hay magic resolución por kind. El autor del Card decide
+    qué template importa.
+
+**Convención obligatoria de templates** (documentada en
+`nickel_eval.rs`): las fields que el usuario va a sobrescribir
+deben marcarse `| default` (o `| optional`). Sin ese marker
+Nickel rechaza el merge de strings/numbers no-iguales con la
+misma prioridad. Patrón canónico:
+
+```nickel
+# template ui_module_basic.ncl
+{
+  id | String | default = "TEMPLATE_ID",
+  label | String | default = "TEMPLATE_LABEL",
+  ...
+}
+
+# uso concreto
+let base = import "ui_module_basic.ncl" in
+base & { id = "my_id", label = "Mi Label" }
+```
+
+9 tests nuevos en `tests/nickel.rs`:
+- `eval_nickel_file_returns_value_for_valid_input` — happy path.
+- `eval_nickel_file_surfaces_evaluation_error` — variant `Eval`
+  con path + message.
+- `load_card_dispatches_ncl_to_ui_module_variant` — pipeline
+  e2e a UiModule.
+- `load_card_dispatches_ncl_to_ente_variant` — pipeline e2e a
+  Ente.
+- `template_merge_overrides_id_and_label_only` — el caso del
+  user: template + override de id+label, resto del template
+  intacto.
+- `template_resolves_via_env_registry` — uso del env como
+  registry global.
+- `load_card_wraps_nickel_error_in_card_load_error` — wrap
+  limpio del error.
+- `nickel_contract_violation_caught_at_eval_time` — value-add
+  concreto: `id | String = 42` falla en eval, no en deserialize
+  ni aguas abajo.
+- `ncl_evaluating_to_unknown_shape_returns_no_matching_reader`
+  — sanity de coherencia con dispatcher JSON.
+
+22 tests en total en `brahman-cards` (13 JSON V1 + 9 Nickel V2).
+Workspace build verde tras la dep nueva.
+
+**Lo que NO hace V2** (sigue pendiente):
+- No migra consumers — `nakui-ui` sigue cargando con
+  `nakui_ui_schema::load_modules_from_dir`. La migración a
+  `brahman_cards::load_card` queda para después.
+- No define un set canonical de templates en el repo (algo
+  como `templates/ente_basic.ncl`, `templates/ui_module_minimal.ncl`).
+  Eso emerge cuando aparezcan los primeros casos de uso reales
+  donde dos cards comparten estructura.
+- No hace cross-validation entre template + override (ej:
+  detectar que un override saca un campo required del template).
+  Nickel ya lo hace via contracts si el template tiene un schema.
+- No expone una API streaming (load N cards en paralelo). El
+  use case actual es one-shot al boot.
+
+**Pendientes para próximos commits** (orden):
+1. Migrar consumers (`nakui-ui` consume `brahman_cards::load_card`).
+2. Yahweh refactor: lift del MetaUi runtime a `crates/modules/ui_engine/`.
+3. KCL → Nickel: kcl_wrapper reemplazado por evaluación de Nickel
+   contracts; los 3 schemas .k de nakui modules pasan a .ncl.
+4. card.k eliminado (es REFERENCE ONLY documentado).
+
 ### feat(brahman-cards): brazo unificado V1 — readers JSON + estructura canónica
 **Pivote arquitectónico** decidido en charla: Brahman maneja varios
 formatos legítimos de "Card" (cada formato vive en su crate origen y
diff --git a/Cargo.lock b/Cargo.lock
index bd4c96a..d9be7b6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -106,6 +106,12 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "aliasable"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
+
 [[package]]
 name = "aligned"
 version = "0.4.3"
@@ -270,12 +276,24 @@ dependencies = [
  "password-hash",
 ]
 
+[[package]]
+name = "arraydeque"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
+
 [[package]]
 name = "arrayref"
 version = "0.3.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
 
+[[package]]
+name = "arrayvec"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
+
 [[package]]
 name = "arrayvec"
 version = "0.7.6"
@@ -315,7 +333,16 @@ version = "3.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6"
 dependencies = [
- "term",
+ "term 0.7.0",
+]
+
+[[package]]
+name = "ascii-canvas"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891"
+dependencies = [
+ "term 1.2.1",
 ]
 
 [[package]]
@@ -800,7 +827,7 @@ dependencies = [
  "aligned",
  "anyhow",
  "arg_enum_proc_macro",
- "arrayvec",
+ "arrayvec 0.7.6",
  "log",
  "num-rational",
  "num-traits",
@@ -818,7 +845,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
 dependencies = [
  "anyhow",
- "arrayvec",
+ "arrayvec 0.7.6",
  "log",
  "nom 8.0.0",
  "num-rational",
@@ -831,7 +858,7 @@ version = "0.8.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38"
 dependencies = [
- "arrayvec",
+ "arrayvec 0.7.6",
 ]
 
 [[package]]
@@ -887,6 +914,12 @@ dependencies = [
  "zeroize",
 ]
 
+[[package]]
+name = "beef"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
+
 [[package]]
 name = "bincode"
 version = "1.3.3"
@@ -964,6 +997,12 @@ version = "2.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
 
+[[package]]
+name = "bitmaps"
+version = "3.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6"
+
 [[package]]
 name = "bitstream-io"
 version = "4.10.0"
@@ -995,7 +1034,7 @@ dependencies = [
  "ash-window",
  "bitflags 2.11.1",
  "bytemuck",
- "codespan-reporting",
+ "codespan-reporting 0.12.0",
  "glow",
  "gpu-alloc",
  "gpu-alloc-ash",
@@ -1059,7 +1098,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
 dependencies = [
  "arrayref",
- "arrayvec",
+ "arrayvec 0.7.6",
  "cc",
  "cfg-if",
  "constant_time_eq",
@@ -1200,6 +1239,7 @@ version = "0.1.0"
 dependencies = [
  "brahman-card",
  "nakui-ui-schema",
+ "nickel-lang",
  "nouser-card",
  "serde",
  "serde_json",
@@ -1432,7 +1472,7 @@ dependencies = [
  "cedar-policy-core",
  "cedar-policy-validator",
  "itertools 0.10.5",
- "lalrpop-util",
+ "lalrpop-util 0.20.2",
  "ref-cast",
  "serde",
  "serde_json",
@@ -1449,8 +1489,8 @@ dependencies = [
  "either",
  "ipnet",
  "itertools 0.10.5",
- "lalrpop",
- "lalrpop-util",
+ "lalrpop 0.20.2",
+ "lalrpop-util 0.20.2",
  "lazy_static",
  "miette",
  "regex",
@@ -1706,6 +1746,16 @@ dependencies = [
  "objc",
 ]
 
+[[package]]
+name = "codespan"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "583f52b0658b321b25fd6b209b6c76cf058f433071297de64e5980c3d9aad937"
+dependencies = [
+ "codespan-reporting 0.13.1",
+ "serde",
+]
+
 [[package]]
 name = "codespan-reporting"
 version = "0.12.0"
@@ -1717,6 +1767,17 @@ dependencies = [
  "unicode-width 0.2.2",
 ]
 
+[[package]]
+name = "codespan-reporting"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
+dependencies = [
+ "serde",
+ "termcolor",
+ "unicode-width 0.2.2",
+]
+
 [[package]]
 name = "color_quant"
 version = "1.1.0"
@@ -3264,6 +3325,12 @@ version = "0.4.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
 
+[[package]]
+name = "fixedbitset"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
+
 [[package]]
 name = "flate2"
 version = "1.1.9"
@@ -4053,7 +4120,7 @@ version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e4f3bedd573fafafa13d1200b356c588cf094fb2786e3684bb3f5ea59b549fa9"
 dependencies = [
- "arrayvec",
+ "arrayvec 0.7.6",
  "log",
  "rayon",
 ]
@@ -4773,6 +4840,15 @@ version = "0.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
 
+[[package]]
+name = "imbl-sized-chunks"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d"
+dependencies = [
+ "bitmaps",
+]
+
 [[package]]
 name = "imgref"
 version = "1.12.1"
@@ -4815,6 +4891,15 @@ dependencies = [
  "web-time",
 ]
 
+[[package]]
+name = "indoc"
+version = "2.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
+dependencies = [
+ "rustversion",
+]
+
 [[package]]
 name = "inotify"
 version = "0.9.6"
@@ -4994,6 +5079,15 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "json_scanner"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe0a2dc336065c75719cffd3c6c929e0ec4ed85b92b8248a7bbd999acb0e419c"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "jsonwebtoken"
 version = "9.3.1"
@@ -5009,6 +5103,15 @@ dependencies = [
  "simple_asn1",
 ]
 
+[[package]]
+name = "keccak"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
+dependencies = [
+ "cpufeatures 0.2.17",
+]
+
 [[package]]
 name = "khronos-egl"
 version = "6.0.0"
@@ -5045,7 +5148,7 @@ version = "0.11.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
 dependencies = [
- "arrayvec",
+ "arrayvec 0.7.6",
  "euclid",
  "smallvec",
 ]
@@ -5065,22 +5168,44 @@ version = "0.20.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca"
 dependencies = [
- "ascii-canvas",
+ "ascii-canvas 3.0.0",
  "bit-set 0.5.3",
  "ena",
  "itertools 0.11.0",
- "lalrpop-util",
- "petgraph",
+ "lalrpop-util 0.20.2",
+ "petgraph 0.6.5",
  "pico-args",
  "regex",
  "regex-syntax",
  "string_cache",
- "term",
+ "term 0.7.0",
  "tiny-keccak",
  "unicode-xid",
  "walkdir",
 ]
 
+[[package]]
+name = "lalrpop"
+version = "0.22.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501"
+dependencies = [
+ "ascii-canvas 4.0.0",
+ "bit-set 0.8.0",
+ "ena",
+ "itertools 0.14.0",
+ "lalrpop-util 0.22.2",
+ "petgraph 0.7.1",
+ "pico-args",
+ "regex",
+ "regex-syntax",
+ "sha3",
+ "string_cache",
+ "term 1.2.1",
+ "unicode-xid",
+ "walkdir",
+]
+
 [[package]]
 name = "lalrpop-util"
 version = "0.20.2"
@@ -5090,6 +5215,16 @@ dependencies = [
  "regex-automata",
 ]
 
+[[package]]
+name = "lalrpop-util"
+version = "0.22.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733"
+dependencies = [
+ "regex-automata",
+ "rustversion",
+]
+
 [[package]]
 name = "lazy_static"
 version = "1.5.0"
@@ -5593,6 +5728,40 @@ dependencies = [
  "value-bag",
 ]
 
+[[package]]
+name = "logos"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154"
+dependencies = [
+ "logos-derive",
+]
+
+[[package]]
+name = "logos-codegen"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c"
+dependencies = [
+ "beef",
+ "fnv",
+ "lazy_static",
+ "proc-macro2",
+ "quote",
+ "regex-syntax",
+ "rustc_version",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "logos-derive"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470"
+dependencies = [
+ "logos-codegen",
+]
+
 [[package]]
 name = "loop9"
 version = "0.1.5"
@@ -5643,7 +5812,7 @@ version = "1.0.19"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92"
 dependencies = [
- "arrayvec",
+ "arrayvec 0.7.6",
  "euclid",
  "num-traits",
 ]
@@ -5691,6 +5860,68 @@ version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30"
 
+[[package]]
+name = "malachite"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec410515e231332b14cd986a475d1c3323bcfa4c7efc038bfa1d5b410b1c57e4"
+dependencies = [
+ "malachite-base",
+ "malachite-float",
+ "malachite-nz",
+ "malachite-q",
+]
+
+[[package]]
+name = "malachite-base"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c738d3789301e957a8f7519318fcbb1b92bb95863b28f6938ae5a05be6259f34"
+dependencies = [
+ "hashbrown 0.15.5",
+ "itertools 0.14.0",
+ "libm",
+ "ryu",
+]
+
+[[package]]
+name = "malachite-float"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9446a966be7f1708c10badd6690d3094b5ad62d3accdcf2154740656d7650cfa"
+dependencies = [
+ "itertools 0.14.0",
+ "malachite-base",
+ "malachite-nz",
+ "malachite-q",
+ "serde",
+]
+
+[[package]]
+name = "malachite-nz"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1707c9a1fa36ce21749b35972bfad17bbf34cf5a7c96897c0491da321e387d3b"
+dependencies = [
+ "itertools 0.14.0",
+ "libm",
+ "malachite-base",
+ "serde",
+ "wide",
+]
+
+[[package]]
+name = "malachite-q"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d764801aa4e96bbb69b389dcd03b50075345131cd63ca2e380bca71cc37a3675"
+dependencies = [
+ "itertools 0.14.0",
+ "malachite-base",
+ "malachite-nz",
+ "serde",
+]
+
 [[package]]
 name = "malloc_buf"
 version = "0.0.6"
@@ -6110,11 +6341,11 @@ version = "25.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632"
 dependencies = [
- "arrayvec",
+ "arrayvec 0.7.6",
  "bit-set 0.8.0",
  "bitflags 2.11.1",
  "cfg_aliases",
- "codespan-reporting",
+ "codespan-reporting 0.12.0",
  "half",
  "hashbrown 0.15.5",
  "hexf-parse",
@@ -6135,7 +6366,7 @@ version = "0.1.0"
 dependencies = [
  "brahman-card",
  "brahman-sidecar",
- "petgraph",
+ "petgraph 0.6.5",
  "rhai",
  "serde",
  "serde_json",
@@ -6315,6 +6546,102 @@ dependencies = [
  "smallvec",
 ]
 
+[[package]]
+name = "nickel-lang"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec629a17e21b7e192dfc715a9567fb4f773a6d0c92424f0ec659c6633f31b087"
+dependencies = [
+ "codespan-reporting 0.13.1",
+ "indexmap 2.14.0",
+ "malachite",
+ "nickel-lang-core",
+ "nickel-lang-vector",
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "toml 0.9.12+spec-1.1.0",
+]
+
+[[package]]
+name = "nickel-lang-core"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51647f09e6e385c140226867c62292f23c31241ee2b4986f3f71a40d48e88a60"
+dependencies = [
+ "base64 0.22.1",
+ "bumpalo",
+ "codespan",
+ "codespan-reporting 0.13.1",
+ "colorchoice",
+ "indexmap 2.14.0",
+ "indoc",
+ "json_scanner",
+ "lalrpop 0.22.2",
+ "lalrpop-util 0.22.2",
+ "logos",
+ "malachite",
+ "malachite-q",
+ "md-5",
+ "nickel-lang-parser",
+ "nickel-lang-vector",
+ "once_cell",
+ "ouroboros",
+ "paste",
+ "pretty",
+ "regex",
+ "saphyr-parser",
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "sha-1",
+ "sha2",
+ "simple-counter",
+ "smallvec",
+ "strip-ansi-escapes",
+ "strsim",
+ "toml 0.9.12+spec-1.1.0",
+ "toml_edit 0.23.10+spec-1.0.0",
+ "typed-arena",
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "nickel-lang-parser"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20d8810705f1a243b1996c83fe9f173d8fb33f1cc97ea5ff7486cc6ef8981f81"
+dependencies = [
+ "bumpalo",
+ "codespan",
+ "codespan-reporting 0.13.1",
+ "indexmap 2.14.0",
+ "lalrpop 0.22.2",
+ "lalrpop-util 0.22.2",
+ "logos",
+ "malachite",
+ "nickel-lang-vector",
+ "ouroboros",
+ "pretty",
+ "regex",
+ "saphyr-parser",
+ "serde",
+ "serde_json",
+ "simple-counter",
+ "toml_edit 0.23.10+spec-1.0.0",
+ "typed-arena",
+]
+
+[[package]]
+name = "nickel-lang-vector"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "870c323d81061fc47db4aa7346f6f3492f3e4bb8bd5dd8b7c85ac9d0c9709f0c"
+dependencies = [
+ "imbl-sized-chunks",
+ "serde",
+]
+
 [[package]]
 name = "nix"
 version = "0.29.0"
@@ -6989,6 +7316,30 @@ dependencies = [
  "ureq",
 ]
 
+[[package]]
+name = "ouroboros"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59"
+dependencies = [
+ "aliasable",
+ "ouroboros_macro",
+ "static_assertions",
+]
+
+[[package]]
+name = "ouroboros_macro"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0"
+dependencies = [
+ "heck 0.4.1",
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn 2.0.117",
+]
+
 [[package]]
 name = "parking"
 version = "2.2.1"
@@ -7147,7 +7498,17 @@ version = "0.6.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
 dependencies = [
- "fixedbitset",
+ "fixedbitset 0.4.2",
+ "indexmap 2.14.0",
+]
+
+[[package]]
+name = "petgraph"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
+dependencies = [
+ "fixedbitset 0.5.7",
  "indexmap 2.14.0",
 ]
 
@@ -7430,6 +7791,17 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
 
+[[package]]
+name = "pretty"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156"
+dependencies = [
+ "arrayvec 0.5.2",
+ "typed-arena",
+ "unicode-width 0.2.2",
+]
+
 [[package]]
 name = "prettyplease"
 version = "0.2.37"
@@ -7480,6 +7852,19 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "proc-macro2-diagnostics"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "version_check",
+ "yansi",
+]
+
 [[package]]
 name = "profiling"
 version = "1.0.18"
@@ -7811,7 +8196,7 @@ dependencies = [
  "aligned-vec",
  "arbitrary",
  "arg_enum_proc_macro",
- "arrayvec",
+ "arrayvec 0.7.6",
  "av-scenechange",
  "av1-grain",
  "bitstream-io",
@@ -8449,7 +8834,7 @@ version = "1.42.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995"
 dependencies = [
- "arrayvec",
+ "arrayvec 0.7.6",
  "borsh",
  "bytes",
  "num-traits",
@@ -8640,6 +9025,15 @@ version = "1.0.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
 
+[[package]]
+name = "safe_arch"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
+dependencies = [
+ "bytemuck",
+]
+
 [[package]]
 name = "salsa20"
 version = "0.10.2"
@@ -8658,6 +9052,16 @@ dependencies = [
  "winapi-util",
 ]
 
+[[package]]
+name = "saphyr-parser"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fb771b59f6b1985d1406325ec28f97cfb14256abcec4fdfb37b36a1766d6af7"
+dependencies = [
+ "arraydeque",
+ "hashlink 0.10.0",
+]
+
 [[package]]
 name = "schannel"
 version = "0.1.29"
@@ -8970,6 +9374,30 @@ dependencies = [
  "syn 2.0.117",
 ]
 
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap 2.14.0",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
+[[package]]
+name = "sha-1"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
 [[package]]
 name = "sha1"
 version = "0.10.6"
@@ -8998,6 +9426,16 @@ dependencies = [
  "digest",
 ]
 
+[[package]]
+name = "sha3"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
+dependencies = [
+ "digest",
+ "keccak",
+]
+
 [[package]]
 name = "sharded-slab"
 version = "0.1.7"
@@ -9053,6 +9491,12 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
 
+[[package]]
+name = "simple-counter"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bb57743b52ea059937169c0061d70298fe2df1d2c988b44caae79dd979d9b49"
+
 [[package]]
 name = "simple_asn1"
 version = "0.6.4"
@@ -9388,6 +9832,15 @@ dependencies = [
  "quote",
 ]
 
+[[package]]
+name = "strip-ansi-escapes"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
+dependencies = [
+ "vte",
+]
+
 [[package]]
 name = "strsim"
 version = "0.11.1"
@@ -9449,7 +9902,7 @@ version = "2.6.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3429154a8b5a98ca39100ba45ef49ae046fb1d0869dff78d78a2670b1b278982"
 dependencies = [
- "arrayvec",
+ "arrayvec 0.7.6",
  "async-channel 2.5.0",
  "bincode",
  "chrono",
@@ -9814,7 +10267,7 @@ version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a13e5d13f79d558b5d353a98072ca8ca0e99da429467804de959aa8c83c9a004"
 dependencies = [
- "arrayvec",
+ "arrayvec 0.7.6",
  "grid",
  "serde",
  "slotmap",
@@ -9896,6 +10349,15 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "term"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "termcolor"
 version = "1.4.1"
@@ -10024,7 +10486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
 dependencies = [
  "arrayref",
- "arrayvec",
+ "arrayvec 0.7.6",
  "bytemuck",
  "cfg-if",
  "log",
@@ -10188,6 +10650,21 @@ dependencies = [
  "toml_edit 0.22.27",
 ]
 
+[[package]]
+name = "toml"
+version = "0.9.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde_core",
+ "serde_spanned 1.1.1",
+ "toml_datetime 0.7.5+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 0.7.15",
+]
+
 [[package]]
 name = "toml"
 version = "1.1.2+spec-1.1.0"
@@ -10212,6 +10689,15 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "toml_datetime"
+version = "0.7.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
+dependencies = [
+ "serde_core",
+]
+
 [[package]]
 name = "toml_datetime"
 version = "1.1.1+spec-1.1.0"
@@ -10235,6 +10721,19 @@ dependencies = [
  "winnow 0.7.15",
 ]
 
+[[package]]
+name = "toml_edit"
+version = "0.23.10+spec-1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
+dependencies = [
+ "indexmap 2.14.0",
+ "toml_datetime 0.7.5+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 0.7.15",
+]
+
 [[package]]
 name = "toml_edit"
 version = "0.25.11+spec-1.1.0"
@@ -10481,6 +10980,12 @@ dependencies = [
  "core_maths",
 ]
 
+[[package]]
+name = "typed-arena"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
+
 [[package]]
 name = "typeid"
 version = "1.0.3"
@@ -10667,6 +11172,12 @@ dependencies = [
  "subtle",
 ]
 
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
 [[package]]
 name = "unsigned-varint"
 version = "0.7.2"
@@ -10879,6 +11390,15 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "vte"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "waker-fn"
 version = "1.2.0"
@@ -11035,7 +11555,7 @@ version = "0.40.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a19af97fcb96045dd1d6b4d23e2b4abdbbe81723dbc5c9f016eb52145b320063"
 dependencies = [
- "arrayvec",
+ "arrayvec 0.7.6",
  "multi-stash",
  "smallvec",
  "spin",
@@ -11317,6 +11837,16 @@ dependencies = [
  "winsafe",
 ]
 
+[[package]]
+name = "wide"
+version = "0.7.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
+dependencies = [
+ "bytemuck",
+ "safe_arch",
+]
+
 [[package]]
 name = "widestring"
 version = "1.2.1"
@@ -12461,6 +12991,12 @@ dependencies = [
  "web-time",
 ]
 
+[[package]]
+name = "yansi"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
+
 [[package]]
 name = "yasna"
 version = "0.5.2"
diff --git a/crates/core/brahman-cards/Cargo.toml b/crates/core/brahman-cards/Cargo.toml
index 2ee3a6c..bc47950 100644
--- a/crates/core/brahman-cards/Cargo.toml
+++ b/crates/core/brahman-cards/Cargo.toml
@@ -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 }
diff --git a/crates/core/brahman-cards/src/lib.rs b/crates/core/brahman-cards/src/lib.rs
index 72cd18e..1270b8e 100644
--- a/crates/core/brahman-cards/src/lib.rs
+++ b/crates/core/brahman-cards/src/lib.rs
@@ -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"],
         }),
     }
 }
diff --git a/crates/core/brahman-cards/src/nickel_eval.rs b/crates/core/brahman-cards/src/nickel_eval.rs
new file mode 100644
index 0000000..2f58557
--- /dev/null
+++ b/crates/core/brahman-cards/src/nickel_eval.rs
@@ -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:?}"))
+}
diff --git a/crates/core/brahman-cards/tests/nickel.rs b/crates/core/brahman-cards/tests/nickel.rs
new file mode 100644
index 0000000..d2bd61b
--- /dev/null
+++ b/crates/core/brahman-cards/tests/nickel.rs
@@ -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();
+}