550c98f275
Reorganización física de crates/: - core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/ - shared/ (3 crates) se redistribuye en protocol/ e init/ - lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/ Renames de proyectos: - shipote → shuma (runtime de sandboxes) - nouser → akasha (explorador de Mónadas) - yahweh → nahual (motor GPUI, antes ui_engine/) - lapaloma → pineal (data-viz agnóstica) Fraccionamiento UI → core agnóstico: - vista-core (DeckState + snap, 175 LOC, 5 tests verdes) - barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes) - vista-web y barra-web ahora son thin DOM bindings Documentación nueva: - 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat + 10 módulos + apps/ - docs/STATUS.md con cifras reales por proyecto - docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas) - CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets) Automatización: - scripts/reorg.py — script idempotente que: git mv directorios, renombra package names, recomputa path = refs, reescribe imports rust, actualiza workspace Cargo.toml. Soporta --dry-run. - scripts/split-changelog.py — particiona CHANGELOG por componente. Validación: - cargo check --workspace pasa (124 crates + 2 nuevos cores). - 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
372 lines
11 KiB
Rust
372 lines
11 KiB
Rust
//! 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();
|
|
}
|