From f6361bbdcab3209adc2d90f004c6a7746e204f67 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 9 May 2026 23:32:47 +0000 Subject: [PATCH] feat(nakui-ui): migrar consumer al brazo brahman_cards::load_cards_from_dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primera consumer migration del brazo. nakui-ui ya no llama a nakui_ui_schema::load_modules_from_dir directamente; pasa por brahman_cards::load_cards_from_dir y extrae UiModule del CardBody. Beneficios concretos: - Soporta .ncl además de .json (templates Nickel + merge funcionan en cualquier subdir de modules). - Cards de otros body kinds (Ente/Monad) se skipean limpio con toast informativo, no rompen la carga. Cambios en brahman-cards: - Nuevo load_cards_from_dir(dir) + variante con readers/filenames custom. DEFAULT_CARD_FILENAMES = [card.ncl, card.json, module.ncl, module.json] (orden de prioridad). - 4 tests nuevos del helper. Cambios en nakui-ui: - Nueva dep brahman-cards. - Helper load_ui_modules(dir) -> (Vec, Vec) envuelve el brazo, filtra a UiModule, aplica Module::validate(), detecta duplicate ids. - MetaUi::new usa el helper, emite toast con cards skipped si las hay. - 3 tests e2e nuevos. 26/26 brahman-cards verdes (+4). 48/48 nakui-ui verdes (+3). Workspace build verde. nakui_ui_schema::load_modules_from_dir queda intacto (sus tests lo usan + otros consumers futuros pueden preferirlo). Migración opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 68 +++++++ Cargo.lock | 1 + crates/apps/nakui-ui/Cargo.toml | 1 + crates/apps/nakui-ui/src/main.rs | 180 +++++++++++++++++- crates/core/brahman-cards/src/lib.rs | 58 ++++++ .../core/brahman-cards/tests/integration.rs | 151 +++++++++++++++ 6 files changed, 449 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef0d8b8..af9ddca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,74 @@ ratio/diff ver `git show `. ## 2026-05-09 +### feat(nakui-ui): migrar consumer al brazo unificado `brahman_cards::load_cards_from_dir` +Primera consumer migration del brazo. `nakui-ui` ya no llama a +`nakui_ui_schema::load_modules_from_dir` directamente — pasa por +`brahman_cards::load_cards_from_dir` y extrae el variant `UiModule` +del `CardBody` de cada Card. Beneficios concretos: + +- **Soporta `.ncl` además de `.json`**: el usuario puede dropear un + `card.ncl` (con templates Nickel + merge) en cualquier subdir y + el runtime lo levanta automáticamente. El layout legacy + `examples/nakui-modules//module.json` sigue funcionando vía + los filenames default `[card.ncl, card.json, module.ncl, module.json]`. +- **Cards de otros body kinds (Ente/Monad) se skipean limpio**: + si el dir contiene Cards no-UiModule, se reportan en un toast + informativo en lugar de fallar la carga. + +Cambios en `brahman-cards`: +- **Nuevo `load_cards_from_dir(dir)`** + variante con readers/filenames + custom. Walkea subdirs (orden lexicográfico), busca el primero de + `DEFAULT_CARD_FILENAMES`, dispatcha al reader. Subdirs sin ningún + filename matching se skipean silenciosamente (permite assets/fixtures + sueltos al lado de los cards). Errores per-file se propagan loud + (sin ocultar corrupción). +- **`pub const DEFAULT_CARD_FILENAMES`**: lista canónica probada en + orden. `card.ncl` tiene prioridad sobre `card.json` y sobre los + legacy `module.*`. +- **4 tests nuevos del helper**: walk + skip de subdirs sin + card, prioridad ncl > json, propagación loud de errores per-file, + custom filenames. + +Cambios en `nakui-ui`: +- **Nueva dep** `brahman-cards` en `Cargo.toml`. +- **Nuevo helper `load_ui_modules(dir) -> (Vec, Vec)`** + que envuelve `brahman_cards::load_cards_from_dir`, filtra a + UiModule body, valida cada Module con su `validate()`, ordena + por id, y detecta duplicados. El callsite en `MetaUi::new` pasa + a usarlo y al ver Cards skipped emite un toast informativo. +- **3 tests nuevos**: + - `load_ui_modules_via_brahman_cards_returns_ui_modules_and_skips_others` + — verifica que un dir con UiModule + Ente carga el primero y + reporta el segundo en `skipped`. + - `load_ui_modules_via_brahman_cards_rejects_invalid_module` — + `Module::validate()` se sigue aplicando (menu apuntando a view + inexistente rebota). + - `load_ui_modules_detects_duplicate_id` — dos UiModule con + mismo id rebotan con mensaje claro. + +Tests totales: +- `brahman-cards`: 22 → 26 (+4 helper directorio). +- `nakui-ui`: 45 → 48 (+3 e2e migración). +- Workspace build verde. + +Lo que NO cambió: +- `nakui_ui_schema::load_modules_from_dir` se mantiene intacto (sus + propios tests lo siguen usando, y otros consumers futuros podrían + preferir su error-typing más específico). La migración es opt-in: + `nakui-ui` usa el brazo, ui-schema sigue siendo una API válida. +- Layout actual de `examples/nakui-modules//module.json` no + requiere cambio. Un usuario puede convertir cualquier módulo a + `card.ncl` sin tocar el dir layout. + +**Pendientes para próximos commits** (orden): +1. **Yahweh refactor**: lift del MetaUi runtime a + `crates/modules/ui_engine/` para reuso. El brazo + canónico ya + estables, ahora puede extraerse el meta-form widget genérico. +2. **KCL → Nickel**: kcl_wrapper reemplazado por Nickel contracts; + los 3 schemas .k de nakui modules pasan a .ncl. +3. **card.k eliminado** (es REFERENCE ONLY documentado). + ### 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 diff --git a/Cargo.lock b/Cargo.lock index d9be7b6..d322400 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6393,6 +6393,7 @@ dependencies = [ name = "nakui-ui" version = "0.1.0" dependencies = [ + "brahman-cards", "gpui", "nakui-core", "nakui-ui-schema", diff --git a/crates/apps/nakui-ui/Cargo.toml b/crates/apps/nakui-ui/Cargo.toml index 9f6c82f..a0922d4 100644 --- a/crates/apps/nakui-ui/Cargo.toml +++ b/crates/apps/nakui-ui/Cargo.toml @@ -8,6 +8,7 @@ description = "Nakui — runtime GPUI de la metainterfaz: carga module.json desd [dependencies] nakui-core = { path = "../../modules/nakui/core" } nakui-ui-schema = { path = "../../modules/nakui/ui-schema" } +brahman-cards = { path = "../../core/brahman-cards" } yahweh-widget-text-input = { path = "../../modules/ui_engine/widgets/text_input" } yahweh-theme = { path = "../../modules/ui_engine/libs/theme" } gpui = { workspace = true } diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs index 0df095a..5ab9d27 100644 --- a/crates/apps/nakui-ui/src/main.rs +++ b/crates/apps/nakui-ui/src/main.rs @@ -34,6 +34,7 @@ use nakui_core::delta::{FieldOp, FieldPath}; use nakui_core::event_log::{ execute_and_log_with_recovery, replay_with_snapshot_into, EventLog, LogEntry, Snapshot, }; +use brahman_cards::CardBody; use nakui_core::executor::Executor; use nakui_core::store::{MemoryStore, Store}; use nakui_ui_schema::{ @@ -125,17 +126,36 @@ impl MetaUi { .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("nakui-modules")); - let (modules, mut load_error) = - match nakui_ui_schema::load_modules_from_dir(&modules_dir) { - Ok(m) => (m, None), - Err(e) => ( - Vec::new(), + // Carga via el brazo unificado: brahman_cards::load_cards_from_dir + // walkea subdirs buscando card.ncl/card.json/module.ncl/module.json, + // dispatcha al reader apropiado, devuelve Vec. Acá filtramos + // a los UiModule body variants y aplicamos las validaciones + // específicas de la UI (validate de cada Module + dedup de id). + // Cards de otros kinds (Ente, Monad) que aparezcan en el dir se + // skipean con un msg al banner — no son fatales pero el usuario + // sabe que estaban ahí. + let (modules, mut load_error) = match load_ui_modules(&modules_dir) { + Ok((mods, skipped)) => { + let toast = if skipped.is_empty() { + None + } else { Some(SharedString::from(format!( - "no pude cargar módulos de {}: {e}", - modules_dir.display() - ))), - ), - }; + "skipeé {} card(s) no-UiModule en {}: {:?}", + skipped.len(), + modules_dir.display(), + skipped + ))) + }; + (mods, toast) + } + Err(e) => ( + Vec::new(), + Some(SharedString::from(format!( + "no pude cargar módulos de {}: {e}", + modules_dir.display() + ))), + ), + }; // Persistencia: abrir/crear el event log + opcionalmente un // snapshot sibling para acortar el replay. Path del log por @@ -895,6 +915,50 @@ impl CommitOutcome { /// Concatena un mensaje de compact opcional al toast del op original. /// Devuelve el toast resultante listo para ir a `SharedString`. /// Sin `compact_msg` devuelve `base` tal cual. +/// Carga UiModules desde un directorio via el brazo unificado +/// `brahman_cards::load_cards_from_dir`. Aplica las reglas +/// específicas de la UI: +/// - Sólo `CardBody::UiModule` cuenta; otros body kinds +/// (Ente, Monad, ...) se reportan en el `skipped` para que el +/// runtime los muestre como banner informativo. +/// - Cada `Module` se valida via `Module::validate()`. +/// - Detecta `id` duplicados entre módulos UiModule (el runtime +/// los direcciona por id; duplicados serían ambiguos). +/// +/// Devuelve `(modules, skipped_ids)` ordenados por id. +fn load_ui_modules( + dir: &std::path::Path, +) -> Result<(Vec, Vec), String> { + let cards = brahman_cards::load_cards_from_dir(dir) + .map_err(|e| e.to_string())?; + let mut modules: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); + for c in cards { + match c.body { + CardBody::UiModule(m) => modules.push(m), + other => skipped.push(format!("{}({})", c.id, other.kind_name())), + } + } + for m in &modules { + m.validate() + .map_err(|e| format!("módulo '{}' inválido: {e}", m.id))?; + } + modules.sort_by(|a, b| a.id.cmp(&b.id)); + let mut prev: Option<&Module> = None; + for cur in &modules { + if let Some(p) = prev { + if p.id == cur.id { + return Err(format!( + "id de módulo duplicado: '{}' aparece más de una vez", + cur.id + )); + } + } + prev = Some(cur); + } + Ok((modules, skipped)) +} + fn append_compact_msg(base: String, compact_msg: Option) -> SharedString { match compact_msg { Some(m) => SharedString::from(format!("{base}; {m}")), @@ -2175,6 +2239,102 @@ mod tests { assert!(validate_entity_refs(&store, &refs).is_err()); } + /// E2E del nuevo `load_ui_modules` que pasa por + /// `brahman_cards::load_cards_from_dir`. Verifica: + /// 1. UiModules cargados ordenados por id. + /// 2. Validación per-module se aplica (un module.json con + /// menu apuntando a una view inexistente debería fallar). + /// 3. Cards de otros body kinds (Ente fixture) se reportan + /// en el `skipped` sin romper la carga. + #[test] + fn load_ui_modules_via_brahman_cards_returns_ui_modules_and_skips_others() { + let root = tempfile::tempdir().unwrap(); + + // Subdir A: UiModule válido. + let a = root.path().join("alpha"); + std::fs::create_dir(&a).unwrap(); + std::fs::write( + a.join("module.json"), + serde_json::to_vec(&json!({ + "id": "alpha", + "label": "Alpha", + "entities": [], + "menu": [], + "views": {} + })) + .unwrap(), + ) + .unwrap(); + + // Subdir B: Ente card (no UiModule). Debe skipearse, + // no romper la carga. + let b = root.path().join("bravo"); + std::fs::create_dir(&b).unwrap(); + std::fs::write( + b.join("card.json"), + serde_json::to_vec(&json!({ + "schema_version": 1, + "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "label": "ente-bravo", + "payload": "Virtual", + "supervision": "OneShot" + })) + .unwrap(), + ) + .unwrap(); + + let (modules, skipped) = + load_ui_modules(root.path()).expect("load ok"); + assert_eq!(modules.len(), 1); + assert_eq!(modules[0].id, "alpha"); + assert_eq!(skipped.len(), 1); + assert!(skipped[0].contains("ente")); + } + + #[test] + fn load_ui_modules_via_brahman_cards_rejects_invalid_module() { + let root = tempfile::tempdir().unwrap(); + let sub = root.path().join("broken"); + std::fs::create_dir(&sub).unwrap(); + // menu apunta a una view que no existe en `views`. + std::fs::write( + sub.join("module.json"), + serde_json::to_vec(&json!({ + "id": "broken", + "label": "Broken", + "entities": [], + "menu": [{ "label": "Phantom", "view": "ghost" }], + "views": {} + })) + .unwrap(), + ) + .unwrap(); + let err = load_ui_modules(root.path()).unwrap_err(); + assert!(err.contains("broken"), "msg debe nombrar el módulo: {err}"); + } + + #[test] + fn load_ui_modules_detects_duplicate_id() { + let root = tempfile::tempdir().unwrap(); + for name in ["dir_a", "dir_b"] { + let sub = root.path().join(name); + std::fs::create_dir(&sub).unwrap(); + std::fs::write( + sub.join("module.json"), + serde_json::to_vec(&json!({ + "id": "dup", + "label": "Dup", + "entities": [], "menu": [], "views": {} + })) + .unwrap(), + ) + .unwrap(); + } + let err = load_ui_modules(root.path()).unwrap_err(); + assert!(err.contains("duplicado"), "msg debe decir duplicado: {err}"); + assert!(err.contains("dup"), "msg debe nombrar el id: {err}"); + } + #[test] fn clear_fields_skips_absent_and_null() { let current = json!({ diff --git a/crates/core/brahman-cards/src/lib.rs b/crates/core/brahman-cards/src/lib.rs index 1270b8e..bf65906 100644 --- a/crates/core/brahman-cards/src/lib.rs +++ b/crates/core/brahman-cards/src/lib.rs @@ -256,3 +256,61 @@ fn dispatch_to_reader( } Err(CardLoadError::NoMatchingReader) } + +/// Filenames convencionales que [`load_cards_from_dir`] busca dentro +/// de cada subdir, en orden de preferencia. Si `card.ncl` existe se +/// usa ese; sino `card.json`; sino los aliases legacy `module.*`. Los +/// últimos dos son por compat con el layout actual de +/// `examples/nakui-modules//module.json`. +pub const DEFAULT_CARD_FILENAMES: &[&str] = + &["card.ncl", "card.json", "module.ncl", "module.json"]; + +/// Carga todas las Cards encontradas como subdirs inmediatos de +/// `dir`. Por cada subdir, busca los filenames convencionales (ver +/// [`DEFAULT_CARD_FILENAMES`]) y carga el primero que existe. Subdirs +/// sin ningún filename matching se skipean silenciosamente — permite +/// que un dir contenga subdirs auxiliares (assets, fixtures, etc.). +/// +/// Devuelve las Cards en orden lexicográfico por subdir name (estable +/// across runs). NO ordena por `Card.id` — el caller decide si quiere +/// re-ordenar y/o dedupar. +/// +/// Errores: cualquier I/O al leer el `dir` mismo, o cualquier +/// `CardLoadError` de un archivo encontrado (NO continúa tras el +/// primer fallo — fallo loud > corrupción silenciosa). +pub fn load_cards_from_dir(dir: impl AsRef) -> Result, CardLoadError> { + load_cards_from_dir_with(dir, DEFAULT_CARD_FILENAMES, &default_readers()) +} + +/// Variante de [`load_cards_from_dir`] con filenames y readers +/// custom. Útil para apps que quieren restringir formatos o usar un +/// nombre canónico distinto. +pub fn load_cards_from_dir_with( + dir: impl AsRef, + filenames: &[&str], + readers: &[Box], +) -> Result, CardLoadError> { + let dir = dir.as_ref(); + let mut subdir_paths: Vec = std::fs::read_dir(dir)? + .flatten() + .filter_map(|e| { + let p = e.path(); + if p.is_dir() { Some(p) } else { None } + }) + .collect(); + // Orden estable por subdir name — el output del brazo no debería + // depender del orden de read_dir (que varía por filesystem). + subdir_paths.sort(); + + let mut out: Vec = Vec::new(); + for sub in subdir_paths { + for fname in filenames { + let candidate = sub.join(fname); + if candidate.exists() { + out.push(load_card_with(&candidate, readers)?); + break; + } + } + } + Ok(out) +} diff --git a/crates/core/brahman-cards/tests/integration.rs b/crates/core/brahman-cards/tests/integration.rs index 4d105c2..7cbb463 100644 --- a/crates/core/brahman-cards/tests/integration.rs +++ b/crates/core/brahman-cards/tests/integration.rs @@ -268,6 +268,142 @@ fn extensions_field_starts_empty_in_v1() { assert_eq!(card.extensions, BTreeMap::new()); } +// =========================================================================== +// load_cards_from_dir (subdir walking) +// =========================================================================== + +#[test] +fn load_cards_from_dir_walks_subdirs_and_finds_module_json() { + let root = unique_dir("dir-walk"); + // Subdir A: tiene module.json (UiModule). + let a = root.join("alpha"); + std::fs::create_dir(&a).unwrap(); + std::fs::write( + a.join("module.json"), + serde_json::to_vec_pretty(&json!({ + "id": "alpha", + "label": "Alpha", + "entities": [], + "menu": [], + "views": {} + })) + .unwrap(), + ) + .unwrap(); + // Subdir B: tiene module.json (UiModule). + let b = root.join("bravo"); + std::fs::create_dir(&b).unwrap(); + std::fs::write( + b.join("module.json"), + serde_json::to_vec_pretty(&json!({ + "id": "bravo", + "label": "Bravo", + "entities": [], + "menu": [], + "views": {} + })) + .unwrap(), + ) + .unwrap(); + // Subdir C: NO tiene ninguno de los filenames convencionales — + // se debe skipear sin error. + let c = root.join("charlie"); + std::fs::create_dir(&c).unwrap(); + std::fs::write(c.join("readme.txt"), b"sin card aca").unwrap(); + + let cards = brahman_cards::load_cards_from_dir(&root).expect("ok"); + let ids: Vec<&str> = cards.iter().map(|c| c.id.as_str()).collect(); + assert_eq!( + ids, + vec!["alpha", "bravo"], + "orden lexicográfico por subdir name" + ); + for c in &cards { + assert_eq!(c.body.kind_name(), "ui_module"); + } + + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn load_cards_from_dir_prefers_ncl_over_json_when_both_present() { + let root = unique_dir("dir-prefer"); + let sub = root.join("only"); + std::fs::create_dir(&sub).unwrap(); + // Ambos archivos existen; el .ncl debería ganar. + std::fs::write( + sub.join("card.ncl"), + r#"{ id = "from_ncl", label = "Ncl", entities = [], menu = [], views = {} }"#, + ) + .unwrap(); + std::fs::write( + sub.join("card.json"), + serde_json::to_vec(&json!({ + "id": "from_json", + "label": "Json", + "entities": [], "menu": [], "views": {} + })) + .unwrap(), + ) + .unwrap(); + + let cards = brahman_cards::load_cards_from_dir(&root).expect("ok"); + assert_eq!(cards.len(), 1); + assert_eq!(cards[0].id, "from_ncl", "card.ncl tiene prioridad"); + + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn load_cards_from_dir_propagates_per_file_errors_loud() { + let root = unique_dir("dir-error-loud"); + let sub = root.join("broken"); + std::fs::create_dir(&sub).unwrap(); + std::fs::write(sub.join("card.json"), b"{ this is not valid json").unwrap(); + + let err = brahman_cards::load_cards_from_dir(&root).unwrap_err(); + assert!( + matches!(err, CardLoadError::JsonParse(_)), + "el error de un file roto debe propagar fail-loud, got {err:?}" + ); + + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn load_cards_from_dir_with_custom_filenames() { + let root = unique_dir("dir-custom-fname"); + let sub = root.join("only"); + std::fs::create_dir(&sub).unwrap(); + // Filename custom que NO está en el default. + std::fs::write( + sub.join("manifest.json"), + serde_json::to_vec(&json!({ + "id": "x", + "label": "X", + "entities": [], "menu": [], "views": {} + })) + .unwrap(), + ) + .unwrap(); + + // Default no encuentra nada (skipea): + let with_default = brahman_cards::load_cards_from_dir(&root).unwrap(); + assert_eq!(with_default.len(), 0, "default filenames no incluye manifest.json"); + + // Custom filename encuentra: + let with_custom = brahman_cards::load_cards_from_dir_with( + &root, + &["manifest.json"], + &brahman_cards::default_readers(), + ) + .unwrap(); + assert_eq!(with_custom.len(), 1); + assert_eq!(with_custom[0].id, "x"); + + std::fs::remove_dir_all(&root).ok(); +} + // =========================================================================== // Helpers de tests // =========================================================================== @@ -281,3 +417,18 @@ fn tempfile_path(name: &str) -> std::path::PathBuf { )); p } + +fn unique_dir(name: &str) -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "brahman-cards-test-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0), + name + )); + std::fs::create_dir_all(&p).unwrap(); + p +}