tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198629 Definitions

feat(nakui-ui): migrar consumer al brazo 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 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<Module>, Vec<String>) 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) <noreply@anthropic.com>

f6361bbdcab3209adc2d90f004c6a7746e204f67 · by Sergio · (5 months ago) · parent 2a4443790 · browse the tree at this commit

Semantic delta · 13

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

6 files · +449 −10

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 <sha>`.
 
 ## 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/<id>/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<Module>, Vec<String>)`**
+  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/<id>/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<Card>. 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<Module>, Vec<String>), String> {
+    let cards = brahman_cards::load_cards_from_dir(dir)
+        .map_err(|e| e.to_string())?;
+    let mut modules: Vec<Module> = Vec::new();
+    let mut skipped: Vec<String> = 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<String>) -> 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/<id>/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<Path>) -> Result<Vec<Card>, 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<Path>,
+    filenames: &[&str],
+    readers: &[Box<dyn CardReader>],
+) -> Result<Vec<Card>, CardLoadError> {
+    let dir = dir.as_ref();
+    let mut subdir_paths: Vec<std::path::PathBuf> = 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<Card> = 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
+}