Files
brahman/crates/apps/nakui-ui/src/main.rs
T
sergio c56ef25546 feat(nakui): Fase 7 del ERP — pulido (cierra el plan maestro)
Validación inline: al fallar un submit por campos required vacíos, el
form los marca (label destructivo + mensaje debajo), no sólo un toast.
MetaApp.form_errors + validate_required_fields. Secciones de formulario:
FieldSpec.section agrupa campos bajo encabezados; abrir_form del CRM las
usa. Campos condicionales y pulido puramente visual: scope-out conciente.

El plan docs/nakui-erp-masterplan.md queda completo (7/7 fases). Tests
verdes (meta-schema 16, meta-runtime 70, meta-form 8, nakui-ui 14);
clippy limpio en las libs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:43:44 +00:00

551 lines
19 KiB
Rust

//! `nakui-ui` — binario shell de la metainterfaz Nakui.
//!
//! Compone:
//! - **Yahweh widget** [`nahual_widget_meta_form::MetaApp`] genérico
//! sobre cualquier `MetaBackend` — toda la lógica de
//! render/edit/delete/morphism vive ahí.
//! - **Backend** [`backend::NakuiBackend`] — implementa el trait
//! wireado al stack nakui-core (event log + MemoryStore + Rhai
//! executors).
//! - **Loader** [`load_ui_modules`] — usa `brahman_cards` para leer
//! `card.{ncl,json}` / `module.{ncl,json}` desde
//! `NAKUI_MODULES_DIR`, filtra a UiModule body, valida.
//!
//! ## Uso
//!
//! ```sh
//! NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
//! # default sin env: ./nakui-modules en pwd.
//! ```
mod backend;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use gpui::{
prelude::*, px, App, Application, Bounds, SharedString, TitlebarOptions, WindowBounds,
WindowOptions,
};
use brahman_cards::CardBody;
use nahual_meta_schema::Module;
use nahual_theme::Theme;
use nahual_widget_meta_form::MetaApp;
use nakui_core::executor::Executor;
use crate::backend::NakuiBackend;
fn main() {
Application::new().run(|cx: &mut App| {
// El text input pide Theme::global; instalarlo antes de
// crear el window evita que panicee.
Theme::install_default(cx);
// 1. Cargar módulos (Cards UiModule via brahman_cards).
let modules_dir = std::env::var("NAKUI_MODULES_DIR")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("nakui-modules"));
let (modules, mut load_error) = match load_ui_modules(&modules_dir) {
Ok((mods, skipped)) => {
let toast = if skipped.is_empty() {
None
} else {
Some(format!(
"skipeé {} card(s) no-UiModule en {}: {:?}",
skipped.len(),
modules_dir.display(),
skipped
))
};
(mods, toast)
}
Err(e) => (
Vec::new(),
Some(format!(
"no pude cargar módulos de {}: {e}",
modules_dir.display()
)),
),
};
// 2. Cargar Executors para módulos con `nakui_module_dir`.
// Path resuelve relativo al subdir del módulo.
let mut executors: BTreeMap<String, Arc<Executor>> = BTreeMap::new();
for m in &modules {
let Some(rel) = &m.nakui_module_dir else {
continue;
};
let module_root = modules_dir.join(&m.id);
let nakui_dir = if std::path::Path::new(rel).is_absolute() {
PathBuf::from(rel)
} else {
module_root.join(rel)
};
match Executor::load_module(&nakui_dir) {
Ok(exec) => {
executors.insert(m.id.clone(), Arc::new(exec));
}
Err(e) => {
let msg = format!(
"módulo {}: no pude cargar executor nakui en {}: {e}",
m.id,
nakui_dir.display()
);
load_error = Some(match load_error {
Some(prev) => format!("{prev}; {msg}"),
None => msg,
});
}
}
}
// 3. Construir el backend Nakui (abre log, replay, compact).
let log_path = std::env::var("NAKUI_EVENT_LOG")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("nakui-ui-state.jsonl"));
let snapshot_threshold: usize = std::env::var("NAKUI_SNAPSHOT_THRESHOLD")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(50);
let (backend, status) = NakuiBackend::open(log_path, snapshot_threshold, executors);
let initial_toast = status.init_toast;
if let Some(msg) = status.load_error {
load_error = Some(match load_error {
Some(prev) => format!("{prev}; {msg}"),
None => msg,
});
}
// 4. Abrir window con MetaApp<NakuiBackend> como root view.
let bounds = Bounds::centered(None, gpui::size(px(1100.), px(720.)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(TitlebarOptions {
title: Some(SharedString::from("Nakui")),
..Default::default()
}),
..Default::default()
},
|_w, cx| cx.new(|cx| MetaApp::new(modules, backend, initial_toast, load_error, cx)),
)
.expect("open window");
cx.activate(true);
});
}
/// 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))
}
#[cfg(test)]
mod tests {
//! Tests del shell. Los tests del backend impl viven en
//! `backend.rs`. Los tests del widget viven en
//! `nahual-widget-meta-form`. Los helpers puros en
//! `nahual-meta-runtime`.
use super::*;
use serde_json::json;
/// E2E mínimo del WAL: armamos un log a mano con dos seeds,
/// abrimos con `EventLog::open` + `replay_into`, y verificamos
/// que el `MemoryStore` queda con esos records aplicados.
/// Reproduce el flujo del startup de NakuiBackend sin GPUI.
#[test]
fn event_log_replay_restores_memory_store() {
use nakui_core::event_log::{replay_into, EventLog, LogEntry};
use nakui_core::store::{MemoryStore, Store};
use uuid::Uuid;
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
drop(tmp);
let id_a = Uuid::new_v4();
let id_b = Uuid::new_v4();
{
let mut log = EventLog::open(&path).unwrap();
log.append(LogEntry::Seed {
seq: 0,
entity: "customer".into(),
id: id_a,
data: json!({"name": "Acme"}),
schema_hash: None,
})
.unwrap();
log.append(LogEntry::Seed {
seq: 1,
entity: "customer".into(),
id: id_b,
data: json!({"name": "Globex"}),
schema_hash: None,
})
.unwrap();
}
let log = EventLog::open(&path).unwrap();
assert_eq!(log.next_seq(), 2);
let mut store = MemoryStore::new();
replay_into(&log, &mut store).unwrap();
assert_eq!(store.load("customer", id_a), Some(json!({"name": "Acme"})));
assert_eq!(
store.load("customer", id_b),
Some(json!({"name": "Globex"}))
);
let _ = std::fs::remove_file(&path);
}
/// E2E del Action::Morphism: carga el módulo nakui-core real
/// `sales`, arma store + log, y ejecuta el morphism `vender` vía
/// `execute_and_log_with_recovery` (la función que usa
/// `NakuiBackend::morphism` internamente). Verifica las
/// post-condiciones esperadas del manifest sales.
#[test]
fn morphism_pipeline_executes_real_sales_vender() {
use nakui_core::event_log::{execute_and_log_with_recovery, EventLog};
use nakui_core::executor::Executor;
use nakui_core::store::{MemoryStore, Store};
use uuid::Uuid;
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let sales_dir = here
.join("../../..")
.join("crates/modules/nakui/modules/sales");
if !sales_dir.join("nsmc.json").exists() {
eprintln!(
"skip: sales module no encontrado en {}",
sales_dir.display()
);
return;
}
let executor = Executor::load_module(&sales_dir).expect("cargar sales executor");
let mut store = MemoryStore::new();
let stock_id = Uuid::new_v4();
let caja_id = Uuid::new_v4();
store.seed(
"Stock",
stock_id,
json!({
"id": stock_id.to_string(),
"sku_id": "test-sku",
"ubicacion": "loc-1",
"cantidad": 100_i64,
}),
);
store.seed(
"Caja",
caja_id,
json!({
"id": caja_id.to_string(),
"name": "Caja Test",
"currency": "USD",
"saldo": 1_000_000_i64,
}),
);
let tmp = tempfile::NamedTempFile::new().unwrap();
let log_path = tmp.path().to_path_buf();
drop(tmp);
let mut log = EventLog::open(&log_path).unwrap();
let venta_id = Uuid::new_v4();
let inputs = vec![("stock", stock_id), ("caja", caja_id)];
let params = json!({
"venta_id": venta_id.to_string(),
"cantidad": 5_i64,
"precio_unitario": 200_i64,
"timestamp": "2026-05-04T10:00:00Z",
});
let ops = execute_and_log_with_recovery(
&executor, &mut store, &mut log, "vender", &inputs, params,
)
.expect("morphism vender debe ejecutar limpio");
assert!(!ops.is_empty());
let stock_after = store
.load("Stock", stock_id)
.and_then(|v| v.get("cantidad").and_then(|c| c.as_i64()))
.expect("stock con cantidad");
assert_eq!(stock_after, 95);
let caja_after = store
.load("Caja", caja_id)
.and_then(|v| v.get("saldo").and_then(|s| s.as_i64()))
.expect("caja con saldo");
assert_eq!(caja_after, 1_001_000);
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn load_ui_modules_via_brahman_cards_returns_ui_modules_and_skips_others() {
let root = tempfile::tempdir().unwrap();
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();
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();
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"));
assert!(err.contains("dup"));
}
/// El UiModule del CRM (`examples/nakui-modules/crm`) debe parsear
/// como `Module` y pasar `validate()` — sino `nakui-ui` lo rechaza
/// al arrancar. Cubre que las 7 vistas del ERP existan y que
/// enganche el módulo-kernel.
#[test]
fn crm_example_module_parses_and_validates() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../examples/nakui-modules/crm/module.json");
let m = Module::from_path(&path).expect("crm/module.json debe parsear");
m.validate().expect("el módulo crm debe validar");
assert_eq!(m.id, "crm");
assert!(
m.nakui_module_dir.is_some(),
"el CRM debe enganchar el módulo-kernel"
);
for view in [
"cliente_list",
"cliente_form",
"oportunidad_list",
"abrir_form",
"mover_form",
"interaccion_list",
"interaccion_form",
"cliente_detail",
"oportunidad_detail",
"panorama",
] {
assert!(m.views.contains_key(view), "falta la vista «{view}»");
}
// Fase 5: el tablero «panorama» con sus tarjetas de KPI.
let nahual_meta_schema::View::Dashboard(dash) = &m.views["panorama"] else {
panic!("panorama debe ser un tablero (dashboard)");
};
assert!(
dash.cards.len() >= 5,
"el panorama debe tener varias tarjetas"
);
let ganadas = dash
.cards
.iter()
.find(|c| c.label.contains("ganadas"))
.expect("tarjeta de oportunidades ganadas");
assert!(
ganadas.filter.is_some(),
"la tarjeta «ganadas» debe filtrar por etapa",
);
assert!(
dash.cards
.iter()
.any(|c| matches!(c.metric, nahual_meta_schema::Metric::GroupBy { .. })),
"el panorama debe tener al menos un breakdown",
);
// Fase 7: el formulario «abrir_form» agrupa sus campos en
// secciones.
let nahual_meta_schema::View::Form(abrir) = &m.views["abrir_form"] else {
panic!("abrir_form debe ser un formulario");
};
assert!(
abrir.fields.iter().all(|f| f.section.is_some()),
"todos los campos de abrir_form deben tener sección",
);
let secciones: std::collections::BTreeSet<&str> = abrir
.fields
.iter()
.filter_map(|f| f.section.as_deref())
.collect();
assert!(
secciones.len() >= 2,
"abrir_form debe tener varias secciones"
);
// Fase 2: la lista de oportunidades resuelve `cliente_id` al
// label del cliente y formatea `monto` como moneda.
let nahual_meta_schema::View::List(lv) = &m.views["oportunidad_list"] else {
panic!("oportunidad_list debe ser una lista");
};
let cliente_col = lv
.columns
.iter()
.find(|c| c.field == "cliente_id")
.expect("columna cliente_id");
assert_eq!(cliente_col.ref_entity.as_deref(), Some("Cliente"));
let monto_col = lv
.columns
.iter()
.find(|c| c.field == "monto")
.expect("columna monto");
assert!(
matches!(
monto_col.format,
nahual_meta_schema::ValueFormat::Currency { .. }
),
"monto debe formatearse como moneda",
);
assert_eq!(
lv.row_detail.as_deref(),
Some("oportunidad_detail"),
"la fila de oportunidad debe abrir su ficha",
);
// Fase 3: la ficha del cliente lista sus oportunidades e
// interacciones (back-references).
let nahual_meta_schema::View::Detail(dv) = &m.views["cliente_detail"] else {
panic!("cliente_detail debe ser una ficha (detail)");
};
assert_eq!(dv.entity, "Cliente");
let related: Vec<&str> = dv.related.iter().map(|r| r.entity.as_str()).collect();
assert!(
related.contains(&"Oportunidad"),
"ficha cliente: falta Oportunidad"
);
assert!(
related.contains(&"Interaccion"),
"ficha cliente: falta Interaccion"
);
for r in &dv.related {
assert_eq!(r.via_field, "cliente_id", "back-ref por cliente_id");
}
}
/// Carga el módulo crm por el mismo camino que usa `nakui-ui`
/// (`load_ui_modules` → `brahman_cards::load_cards_from_dir`). Se
/// aísla en un tempdir para no acoplar el test a los otros módulos
/// de ejemplo.
#[test]
fn crm_module_loads_via_card_pipeline() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../examples/nakui-modules/crm/module.json");
let root = tempfile::tempdir().unwrap();
let crm_dir = root.path().join("crm");
std::fs::create_dir(&crm_dir).unwrap();
std::fs::copy(&src, crm_dir.join("module.json")).unwrap();
let (modules, skipped) = load_ui_modules(root.path()).expect("el módulo crm debe cargar");
assert!(skipped.is_empty(), "ninguna card debe saltarse");
assert_eq!(modules.len(), 1);
assert_eq!(modules[0].id, "crm");
}
}