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 `. ## 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`. 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; } +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 ".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 { + 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 = 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 = 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( + ®istry, + "ui_module_minimal.ncl", + r#" + { + id | String | default = "X", + label | String | default = "X", + entities = [], + menu = [], + views = {}, + } + "#, + ); + + let p = write_file( + &inputs, + "from_registry.ncl", + r#" + let base = import "ui_module_minimal.ncl" in + base & { id = "registry_user", label = "Usado del Registry" } + "#, + ); + + // Set env, evaluar, restaurar. + let prev = std::env::var(BRAHMAN_CARDS_TEMPLATES_ENV).ok(); + // SAFETY: nickel-lang tests modifican un env ad-hoc que no es + // referenciado por nada externo y se restaura al salir. Ningún + // otro test del crate lee este env. + unsafe { + std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, ®istry); + } + + let result = load_card(&p); + + unsafe { + if let Some(v) = prev { + std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, v); + } else { + std::env::remove_var(BRAHMAN_CARDS_TEMPLATES_ENV); + } + } + + let card = result.expect("template via registry ok"); + assert_eq!(card.id, "registry_user"); + assert_eq!(card.body.kind_name(), "ui_module"); + + fs::remove_dir_all(®istry).ok(); + fs::remove_dir_all(&inputs).ok(); +} + +// =========================================================================== +// 4. Errores propagan limpios al CardLoadError +// =========================================================================== + +#[test] +fn load_card_wraps_nickel_error_in_card_load_error() { + let dir = unique_dir("wrap-err"); + let p = write_file(&dir, "bad.ncl", "let x = unknown in x"); + let err = load_card(&p).unwrap_err(); + match err { + CardLoadError::Nickel(NickelEvalError::Eval { .. }) => {} // expected + other => panic!("expected Nickel(Eval), got {other:?}"), + } + fs::remove_dir_all(&dir).ok(); +} + +/// El value-add concreto de Nickel sobre JSON: un contract +/// violation se captura en evaluación, ANTES de que el reader +/// JSON tenga oportunidad de aceptar un shape mal-tipado. Acá un +/// `id | String` con un value que no es String falla en eval-time +/// con un mensaje legible. JSON puro lo aceptaría y rompería más +/// tarde aguas abajo. +#[test] +fn nickel_contract_violation_caught_at_eval_time() { + let dir = unique_dir("contract-violation"); + let p = write_file( + &dir, + "bad_id.ncl", + r#" + { + id | String = 42, + label = "X", + entities = [], + menu = [], + views = {}, + } + "#, + ); + let err = load_card(&p).unwrap_err(); + match err { + CardLoadError::Nickel(NickelEvalError::Eval { message, .. }) => { + // Mensaje de contract violation legible. + assert!( + message.contains("contract") || message.contains("String"), + "msg debe mencionar contract o String: {message}" + ); + } + other => panic!("expected Nickel(Eval), got {other:?}"), + } + fs::remove_dir_all(&dir).ok(); +} + +/// Sanity: un Nickel que evalúa a un shape NO-reconocible (no +/// matchea ningún reader) cae en `NoMatchingReader` — la cadena +/// Nickel + dispatcher se mantiene coherente. +#[test] +fn ncl_evaluating_to_unknown_shape_returns_no_matching_reader() { + let dir = unique_dir("unknown-shape"); + let p = write_file( + &dir, + "weird.ncl", + r#"{ random = "shape", without = "fingerprint" }"#, + ); + let err = load_card(&p).unwrap_err(); + assert!( + matches!(err, CardLoadError::NoMatchingReader), + "expected NoMatchingReader, got {err:?}" + ); + fs::remove_dir_all(&dir).ok(); +}