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>
This commit is contained in:
Sergio
2026-05-09 23:32:47 +00:00
parent 2a4443790c
commit f6361bbdca
6 changed files with 449 additions and 10 deletions
@@ -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
}