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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user