refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG

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>
This commit is contained in:
sergio
2026-05-19 14:48:34 +00:00
parent 86fb6ae20b
commit 550c98f275
375 changed files with 8512 additions and 7155 deletions
@@ -0,0 +1,9 @@
[package]
name = "nahual-bus"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "AppBus + AppEvent — comunicación cross-widget app-level."
[dependencies]
gpui = { workspace = true }
+44
View File
@@ -0,0 +1,44 @@
//! `nahual_bus` — `AppBus` + `AppEvent` para comunicación cross-widget.
//!
//! Es un `Entity<AppBus>` que emite [`AppEvent`]. Cualquier widget se
//! subscribe con `cx.subscribe(&bus, |this, _, ev, cx| { ... })`. La
//! Shell crea exactamente un AppBus al boot y lo distribuye:
//!
//! - **Productores** (FileExplorer, DatabaseExplorer): el LayoutHost los
//! subscribe individualmente y reenvía sus eventos tipados al bus,
//! normalizando al formato `{provider, id, …}` agnóstico.
//! - **Consumidores** (TextViewer, ImageViewer, …): reciben el handle del
//! bus en su constructor y se subscriben directo.
//!
//! Por qué un bus y no `cx.subscribe` directo entre productor y consumidor:
//! los viewers no saben qué explorers existen (ni viceversa). El bus
//! desacopla — puede haber 0, 1 o N explorers de distintos providers, y
//! varios viewers en paralelo viendo el mismo evento.
use gpui::EventEmitter;
/// Eventos cross-widget. Diseñados para ser agnósticos del dominio:
/// `provider` es el id (string) del DataProvider que sabe interpretar el
/// `id`. `provider_path` es el contexto opcional (ej. el .sqlite del
/// DatabaseExplorer) que el viewer necesita para construir su provider.
#[derive(Clone, Debug)]
pub enum AppEvent {
/// Una entidad fue seleccionada (single click). Suele triggerear un
/// preview en el viewer activo.
EntitySelected {
provider: String,
provider_path: Option<String>,
id: String,
},
/// Una entidad fue ejecutada (doble click u "Open" del menú).
EntityOpened {
provider: String,
provider_path: Option<String>,
id: String,
},
}
#[derive(Default)]
pub struct AppBus;
impl EventEmitter<AppEvent> for AppBus {}
@@ -0,0 +1,12 @@
[package]
name = "nahual-core"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tipos data compartidos: providers, layout JSON, taxonomía de módulos."
[dependencies]
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
+266
View File
@@ -0,0 +1,266 @@
//! `nahual_core` — tipos compartidos por toda la app, sin dependencias de UI.
//!
//! Contiene tres bloques:
//! 1. **Providers** (`DataProvider`, `EntityNode`, `DisplayType`) — fuente de
//! datos jerárquicos para los exploradores. Portado intacto de `gioser_core`.
//! 2. **Layout** (`LayerConfig`, `LayerParam`, `ModuloTipo`, `LayoutDirection`,
//! `LayoutMode`) — el JSON describe el árbol de widgets. Portado de
//! `gioser_core` quitando los helpers acoplados a Makepad (`LiveId`).
//! 3. **Identidad** (`NodeId`) — id estable de un nodo del layout, derivado
//! del `id` JSON o del path estructural.
//!
//! NO contiene tipos de comunicación entre widgets. Esos viven en la `shell`
//! y se construyen sobre el sistema de eventos de GPUI (`EventEmitter`,
//! `cx.subscribe`).
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::pin::Pin;
use tokio::io::{AsyncRead, AsyncWrite};
// =====================================================================
// Providers
// =====================================================================
#[derive(Clone, Debug)]
pub enum DisplayType {
Folder,
File,
Stream,
}
#[derive(Clone, Debug)]
pub struct EntityNode {
pub id: String,
pub name: String,
pub display_type: DisplayType,
pub mime_type: Option<String>,
}
#[async_trait]
pub trait DataProvider: Send + Sync {
fn provider_id(&self) -> String;
async fn list_children(&self, parent_id: Option<&str>) -> Result<Vec<EntityNode>, String>;
async fn get_read_stream(
&self,
entity_id: &str,
) -> Result<Pin<Box<dyn AsyncRead + Send>>, String>;
async fn get_write_stream(
&self,
entity_id: &str,
) -> Result<Pin<Box<dyn AsyncWrite + Send>>, String>;
/// Default convenience: vacía un read stream a `Vec<u8>`. Los providers
/// pueden override si tienen un fast-path.
async fn get_data(&self, entity_id: &str) -> Result<Vec<u8>, String> {
use tokio::io::AsyncReadExt;
let mut stream = self.get_read_stream(entity_id).await?;
let mut buffer = Vec::new();
stream
.read_to_end(&mut buffer)
.await
.map_err(|e| e.to_string())?;
Ok(buffer)
}
}
// =====================================================================
// Identidad estable de nodos del layout
// =====================================================================
/// Identificador estable de un nodo del árbol de layout. Construido con
/// `NodeId::from_layer(&LayerConfig, path)` durante el DFS del LayoutHost:
/// si el `LayerConfig` trae `id` propio, se usa ese; si no, se sintetiza a
/// partir del path estructural (`root/main/0`).
///
/// Internamente es una `String` para no atarse al sistema de hashing de
/// ningún framework. La igualdad lexicográfica garantiza estabilidad: el
/// mismo `id` o el mismo path producen el mismo `NodeId`.
#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeId(pub String);
impl NodeId {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn from_layer(cfg: &LayerConfig, path: &str) -> Self {
match &cfg.id {
Some(id) if !id.is_empty() => Self(id.clone()),
_ => Self(path.to_string()),
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for NodeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
// =====================================================================
// Taxonomía y layout JSON
// =====================================================================
/// Tipo de módulo que la Shell sabe instanciar. Cualquier `kind` del JSON se
/// resuelve a uno de estos via `ModuloTipo::from_kind`.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ModuloTipo {
Texto,
Arbol,
Imagen,
/// Marco contenedor — define un sub-layout y aloja hijos.
Contenedor,
/// Tile manager autónomo (Tiled / Floating / Stacked + shortcuts).
TileManager,
}
impl ModuloTipo {
pub fn from_kind(kind: &str) -> Self {
match kind {
"TextViewer" | "SectionEditor" | "Texto" => Self::Texto,
"FileExplorer" | "DatabaseExplorer" | "Arbol" => Self::Arbol,
"ImageViewer" | "Imagen" => Self::Imagen,
"TileManager" | "Tiled" => Self::TileManager,
_ => Self::Contenedor,
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum LayoutDirection {
Horizontal,
Vertical,
Overlay,
}
impl Default for LayoutDirection {
fn default() -> Self {
Self::Vertical
}
}
impl LayoutDirection {
pub fn from_str(s: &str) -> Self {
match s {
"horizontal" | "Horizontal" | "row" => Self::Horizontal,
"overlay" | "Overlay" | "stack" => Self::Overlay,
_ => Self::Vertical,
}
}
}
/// Política global del root: cómo se presentan los hijos directos del
/// `LayerConfig` raíz entre sí (Tiled / Stacked / Floating). Distinta de
/// `LayoutDirection` que es por contenedor.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum LayoutMode {
Tiled,
Stacked,
Floating,
}
impl Default for LayoutMode {
fn default() -> Self {
Self::Tiled
}
}
impl LayoutMode {
pub fn from_str(s: &str) -> Self {
match s {
"stacked" | "Stacked" => Self::Stacked,
"floating" | "Floating" => Self::Floating,
_ => Self::Tiled,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LayerParam {
pub key: String,
pub value: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LayerConfig {
/// Identificador estable. Si falta, el LayoutHost sintetiza desde el path.
pub id: Option<String>,
/// Nombre de clase del módulo (e.g. "FileExplorer", "Split", "Tabs").
pub kind: String,
/// Peso flex relativo entre hermanos. `None` ⇒ 1.0.
pub flex: Option<f64>,
/// Solo válido para contenedores con orientación. `None` ⇒ Vertical.
pub direction: Option<String>,
pub params: Vec<LayerParam>,
pub children: Vec<LayerConfig>,
}
impl Default for LayerConfig {
fn default() -> Self {
Self {
id: Some("root".to_string()),
kind: "Split".to_string(),
flex: Some(1.0),
direction: Some("horizontal".to_string()),
params: vec![],
children: vec![
LayerConfig {
id: Some("nav".to_string()),
kind: "FileExplorer".to_string(),
flex: Some(0.3),
direction: None,
params: vec![],
children: vec![],
},
LayerConfig {
id: Some("main".to_string()),
kind: "TextViewer".to_string(),
flex: Some(0.7),
direction: None,
params: vec![],
children: vec![],
},
],
}
}
}
impl LayerConfig {
pub fn load_or_default(path: &str) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default()
}
pub fn serialize_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_default()
}
pub fn get_param(&self, key: &str) -> Option<&String> {
self.params.iter().find(|p| p.key == key).map(|p| &p.value)
}
pub fn flex_weight(&self) -> f64 {
self.flex.unwrap_or(1.0).max(0.0)
}
pub fn modulo_tipo(&self) -> ModuloTipo {
ModuloTipo::from_kind(&self.kind)
}
pub fn layout_direction(&self) -> LayoutDirection {
self.direction
.as_deref()
.map(LayoutDirection::from_str)
.unwrap_or(LayoutDirection::Vertical)
}
}
@@ -0,0 +1,13 @@
[package]
name = "nahual-launcher"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Launcher GPUI reusable: Application::new + Theme::install_default + cx.open_window + cx.activate. Las explorer apps lo invocan en una sola línea (`launch_app(title, size, root_factory)`)."
[dependencies]
gpui = { workspace = true }
nahual-theme = { path = "../theme" }
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
@@ -0,0 +1,123 @@
//! Yahweh shell — reduce el boot de un app GPUI temed a una línea.
//!
//! Las 4 (próximamente más) apps explorer del repo declaran el mismo
//! patrón: `Application::new + Theme::install_default + cx.open_window
//! + cx.activate(true)`. Sólo varían el título, el tamaño inicial y la
//! fábrica del root entity.
//!
//! Antes (~20 líneas):
//!
//! ```ignore
//! Application::new().run(|cx: &mut App| {
//! Theme::install_default(cx);
//! let bounds = Bounds::centered(None, gpui::size(px(900.), px(640.)), cx);
//! cx.open_window(
//! WindowOptions {
//! window_bounds: Some(WindowBounds::Windowed(bounds)),
//! titlebar: Some(gpui::TitlebarOptions {
//! title: Some(SharedString::from("Nakui — Event Log")),
//! ..Default::default()
//! }),
//! ..Default::default()
//! },
//! |_w, cx| cx.new(Explorer::new),
//! ).expect("open window");
//! cx.activate(true);
//! });
//! ```
//!
//! Ahora (1 línea):
//!
//! ```ignore
//! launch_app("Nakui — Event Log", (900., 640.), Explorer::new);
//! ```
use gpui::{
App, AppContext, Application, Bounds, Context, Render, SharedString, TitlebarOptions,
WindowBounds, WindowOptions, px,
};
use nahual_theme::Theme;
/// Configuración del primer (y normalmente único) ventana del app.
///
/// `size` es `(ancho, alto)` en píxeles lógicos. La ventana queda
/// centrada en el monitor primario.
pub struct AppLaunchConfig {
pub title: SharedString,
pub size: (f32, f32),
}
impl AppLaunchConfig {
pub fn new(title: impl Into<SharedString>, size: (f32, f32)) -> Self {
Self {
title: title.into(),
size,
}
}
}
/// Levanta un app GPUI con tema instalado y root entity construido.
///
/// El root debe implementar `Render`. La fábrica `root_factory` recibe
/// el `Context<T>` del nuevo entity para que pueda usar `cx.spawn`,
/// suscribirse a eventos, etc — lo mismo que en el patrón directo.
///
/// Bloquea el thread main hasta que se cierre la ventana
/// (`Application::run` no retorna).
pub fn launch_app<T, F>(title: impl Into<SharedString>, size: (f32, f32), root_factory: F)
where
T: Render + 'static,
F: FnOnce(&mut Context<T>) -> T + Send + 'static,
{
launch_app_with(AppLaunchConfig::new(title, size), root_factory);
}
/// Variante que acepta un `AppLaunchConfig` armado afuera. Útil cuando
/// el config se calcula condicionalmente (env var para tamaño, etc).
pub fn launch_app_with<T, F>(config: AppLaunchConfig, root_factory: F)
where
T: Render + 'static,
F: FnOnce(&mut Context<T>) -> T + Send + 'static,
{
Application::new().run(move |cx: &mut App| {
Theme::install_default(cx);
let bounds = Bounds::centered(None, gpui::size(px(config.size.0), px(config.size.1)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(TitlebarOptions {
title: Some(config.title.clone()),
..Default::default()
}),
..Default::default()
},
|_w, cx| cx.new(root_factory),
)
.expect("open window");
cx.activate(true);
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_new_normalizes_inputs() {
let c = AppLaunchConfig::new("My App", (800.0, 600.0));
assert_eq!(c.title.as_ref(), "My App");
assert_eq!(c.size, (800.0, 600.0));
}
#[test]
fn config_accepts_owned_string_title() {
let owned = String::from("Owned Title");
let c = AppLaunchConfig::new(owned, (400.0, 300.0));
assert_eq!(c.title.as_ref(), "Owned Title");
}
// No hay test de `launch_app` aquí: bloquea el thread main hasta
// que la ventana se cierre, y en sandbox no hay DISPLAY. La
// cobertura real es que cada explorer app lo invoque y arranque
// (smoke test manual o con DISPLAY).
}
@@ -0,0 +1,12 @@
[package]
name = "nahual-meta-runtime"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Yahweh — meta-runtime: helpers puros (parse, delta, validación, formato) que cualquier widget metainterfaz consume sobre `yahweh-meta-schema`. Sin GPUI, sin backend específico — toma cierres/closures para acceder al store."
[dependencies]
serde_json = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true, features = ["serde"] }
nahual-meta-schema = { path = "../meta-schema" }
@@ -0,0 +1,279 @@
//! `MetaBackend` trait — la frontera entre el widget metainterfaz
//! (nahual) y la implementación concreta de persistencia/ejecución
//! (nakui-core, Surreal, mocks para tests).
//!
//! El widget consume este trait; el binario lo implementa con su
//! stack particular. Esto es lo que hace que el widget sea reusable.
//!
//! Convenciones documentadas en el doc del trait abajo.
use std::collections::BTreeMap;
use serde_json::Value;
use uuid::Uuid;
/// Resultado uniforme de una operación de escritura del backend.
///
/// La UI lo usa para componer el toast: `id` para mostrar el
/// short_uuid, `changed` para diferenciar "actualizado X (3 campos)"
/// vs "sin cambios", `post_status` para concatenar mensajes
/// emitidos por hooks internos del backend (ej. "auto-compact:
/// snapshot @ seq 49") sin que la UI tenga que conocer el detalle.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WriteOutcome {
/// Id del record afectado. `Some` para seed/update/delete;
/// `None` para morphism cuando afecta múltiples records.
pub id: Option<Uuid>,
/// Cantidad de cambios efectivos. `0` = no-op (edit que no
/// modificó ningún campo, etc.).
pub changed: usize,
/// Mensaje de status opcional para concatenar al toast del op
/// original con el separator estándar.
pub post_status: Option<String>,
}
impl WriteOutcome {
/// Constructor para no-op writes (edits sin cambios).
pub fn no_change(id: Uuid) -> Self {
Self {
id: Some(id),
changed: 0,
post_status: None,
}
}
}
/// Backend que un widget de metainterfaz usa para leer y mutar
/// records. Decoupla el widget (nahual) de la implementación
/// concreta (nakui-core, Surreal, mock para tests).
///
/// # Convención sobre ids
///
/// `Uuid` canónico. Backends que internamente usan otros tipos
/// deben mapear via Uuid (hash determinista, wrapper, lo que sirva).
/// Esto evita generic associated types que complicarían el dispatch
/// en `cx.listener` de GPUI.
///
/// # Convención sobre validación
///
/// El backend ES la fuente de verdad sobre invariantes (KCL/Nickel
/// post-checks, conservación, etc.). El widget pre-valida shape
/// (nahual-meta-runtime: `parse_field_value`, `validate_entity_refs`)
/// pero el backend puede rebotar con `Err(...)` si su validación
/// adicional falla — el widget muestra el error al usuario.
///
/// # Convención sobre threading
///
/// `'static` (no `Send + Sync`): el widget vive en `Entity<MetaApp<B>>`
/// que requiere `'static`, pero los handlers son single-threaded en
/// el main UI thread de GPUI. Si en el futuro un backend necesita
/// `cx.spawn`, agregamos los marker traits.
///
/// # Convención sobre delta computation
///
/// El widget pre-computa `set` y `clear` con
/// [`crate::delta::compute_field_delta`] +
/// [`crate::delta::compute_clear_fields`] *antes* de llamar a
/// [`MetaBackend::update`]. El backend no recomputa: si recibe ambos
/// vacíos devuelve `changed = 0` sin escribir nada. Esto evita
/// double-roundtrip al store por el mismo dato.
pub trait MetaBackend: 'static {
/// Snapshot ordenado de records de una entity.
/// Orden estable (lexicográfico por id) para UI determinista.
/// Vacío si no hay records.
fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)>;
/// Lee un record por id. `None` si no existe.
fn load_record(&self, entity: &str, id: Uuid) -> Option<Value>;
/// Crea un record nuevo. El backend asigna el `Uuid`
/// (devuelve en `WriteOutcome.id`). `changed = 1` siempre.
fn seed(
&mut self,
entity: &str,
data: serde_json::Map<String, Value>,
) -> Result<WriteOutcome, String>;
/// Edita un record existente. Aplica `set` (overrides) y
/// `clear` (key removal). `changed = set.len() + clear.len()`.
/// Si ambos están vacíos (no-op edit), devuelve
/// `WriteOutcome::no_change(id)` sin error y sin escribir al log.
fn update(
&mut self,
entity: &str,
id: Uuid,
set: serde_json::Map<String, Value>,
clear: Vec<String>,
) -> Result<WriteOutcome, String>;
/// Borra un record. `changed = 1` si existía, error si no.
fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String>;
/// Ejecuta un morphism declarado por un módulo. El backend
/// resuelve la implementación, valida, computa ops, las aplica.
/// `changed = N ops aplicadas`.
///
/// `module_id` ubica al módulo (el trait no asume estructura del
/// manifest — el backend lo resuelve internamente).
fn morphism(
&mut self,
module_id: &str,
name: &str,
inputs: BTreeMap<String, Uuid>,
params: Value,
) -> Result<WriteOutcome, String>;
}
#[cfg(test)]
mod tests {
//! Tests del trait via [`crate::testing::MockBackend`]. Verifican
//! el contrato genérico (object-safety, semantica de seed/update/
//! delete) sin atar a un backend concreto. Los tests del mock en
//! sí (constructores, with_morphism, etc.) viven en
//! `crate::testing::tests`.
use super::*;
use crate::testing::MockBackend;
use serde_json::json;
fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}
#[test]
fn seed_then_load_round_trip() {
let mut b = MockBackend::new();
let out = b
.seed("Customer", map_of(&[("name", json!("Acme"))]))
.unwrap();
let id = out.id.expect("seed devuelve id");
assert_eq!(out.changed, 1);
assert!(out.post_status.is_none());
let rec = b.load_record("Customer", id).unwrap();
assert_eq!(rec.get("name"), Some(&json!("Acme")));
}
#[test]
fn list_records_filters_by_entity_and_orders_stably() {
let mut b = MockBackend::new();
let _ = b.seed("A", map_of(&[("k", json!(1))])).unwrap();
let _ = b.seed("B", map_of(&[("k", json!(2))])).unwrap();
let _ = b.seed("A", map_of(&[("k", json!(3))])).unwrap();
let a = b.list_records("A");
assert_eq!(a.len(), 2);
let b_only = b.list_records("B");
assert_eq!(b_only.len(), 1);
let none = b.list_records("Missing");
assert!(none.is_empty());
// Orden estable: re-llamadas devuelven mismo orden.
let a_again = b.list_records("A");
assert_eq!(
a.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
a_again.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
);
}
#[test]
fn update_with_set_changes_field() {
let mut b = MockBackend::new();
let id = b
.seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))]))
.unwrap()
.id
.unwrap();
let out = b
.update(
"Customer",
id,
map_of(&[("name", json!("Acme S.A."))]),
vec![],
)
.unwrap();
assert_eq!(out.changed, 1);
assert_eq!(out.id, Some(id));
let rec = b.load_record("Customer", id).unwrap();
assert_eq!(rec.get("name"), Some(&json!("Acme S.A.")));
assert_eq!(rec.get("notes"), Some(&json!("x")), "notes intacto");
}
#[test]
fn update_with_clear_removes_key() {
let mut b = MockBackend::new();
let id = b
.seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))]))
.unwrap()
.id
.unwrap();
let out = b
.update("Customer", id, serde_json::Map::new(), vec!["notes".into()])
.unwrap();
assert_eq!(out.changed, 1);
let rec = b.load_record("Customer", id).unwrap();
assert_eq!(rec.get("name"), Some(&json!("Acme")));
assert!(rec.get("notes").is_none(), "notes debería estar borrado");
}
#[test]
fn update_with_empty_set_and_clear_returns_no_change() {
let mut b = MockBackend::new();
let id = b
.seed("Customer", map_of(&[("name", json!("Acme"))]))
.unwrap()
.id
.unwrap();
let out = b
.update("Customer", id, serde_json::Map::new(), vec![])
.unwrap();
assert_eq!(out, WriteOutcome::no_change(id));
}
#[test]
fn update_on_missing_record_errors() {
let mut b = MockBackend::new();
let id = Uuid::new_v4();
let err = b
.update("Customer", id, map_of(&[("x", json!(1))]), vec![])
.unwrap_err();
assert!(err.contains("not found"));
}
#[test]
fn delete_removes_and_then_load_returns_none() {
let mut b = MockBackend::new();
let id = b
.seed("Customer", map_of(&[("name", json!("Acme"))]))
.unwrap()
.id
.unwrap();
let out = b.delete("Customer", id).unwrap();
assert_eq!(out.changed, 1);
assert_eq!(out.id, Some(id));
assert!(b.load_record("Customer", id).is_none());
}
#[test]
fn delete_on_missing_record_errors() {
let mut b = MockBackend::new();
let id = Uuid::new_v4();
assert!(b.delete("Customer", id).is_err());
}
/// Sanity: el trait acepta llamadas via `&mut dyn MetaBackend`
/// (object-safety). Esto permite que el widget tenga
/// `Box<dyn MetaBackend>` si el use case requiere borrado de
/// tipo (vs. el path normal con `MetaApp<B: MetaBackend>`).
#[test]
fn trait_is_object_safe() {
let mut b: Box<dyn MetaBackend> = Box::new(MockBackend::new());
let _ = b.seed("X", map_of(&[("k", json!(1))])).unwrap();
assert_eq!(b.list_records("X").len(), 1);
}
}
@@ -0,0 +1,134 @@
//! Cálculo del delta entre el record actual y la propuesta del form.
//!
//! Sirve a un runtime de edición para emitir SOLO los Set/Clear que
//! cambian algo: log + apply minimales, no-op edits = 0 entries.
use serde_json::Value;
/// Calcula el delta entre el record actual y los valores propuestos
/// del form. Devuelve un Map con sólo los campos cuyo valor difiere.
///
/// Comparación: igualdad estructural sobre `serde_json::Value`. Un
/// `current=Value::Null` (record no encontrado) hace que todos los
/// campos del `proposed` sean considerados nuevos. Un campo del
/// proposed que coincide con el del current se omite. Campos que
/// están en current pero NO en proposed se preservan tal cual (el
/// edit no los toca; ver [`compute_clear_fields`] para borrar
/// explícito desde un input vacío).
pub fn compute_field_delta(
current: &Value,
proposed: &serde_json::Map<String, Value>,
) -> serde_json::Map<String, Value> {
proposed
.iter()
.filter(|(field, value)| current.get(field.as_str()) != Some(*value))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
/// Decide cuáles fields del `to_clear` candidate list ameritan
/// realmente un `FieldOp::Clear`: sólo los que existen en el current
/// con un valor non-null. Para fields ausentes o ya null, Clear es
/// no-op semántico (el post-state es el mismo) y dropearlos
/// preserva la propiedad "1 op = 1 cambio efectivo" del log.
///
/// Preserva el orden del input para que el log entry sea estable.
pub fn compute_clear_fields(current: &Value, to_clear: &[String]) -> Vec<String> {
to_clear
.iter()
.filter(|f| match current.get(f.as_str()) {
None | Some(Value::Null) => false,
Some(_) => true,
})
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn map(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}
#[test]
fn delta_empty_when_all_fields_match() {
let current = json!({"name": "Acme", "saldo": 100_i64, "currency": "USD"});
let proposed = map(&[
("name", json!("Acme")),
("saldo", json!(100_i64)),
("currency", json!("USD")),
]);
assert!(compute_field_delta(&current, &proposed).is_empty());
}
#[test]
fn delta_includes_only_changed_field() {
let current = json!({"name": "Acme", "saldo": 100_i64});
let proposed = map(&[("name", json!("Acme")), ("saldo", json!(200_i64))]);
let d = compute_field_delta(&current, &proposed);
assert_eq!(d.len(), 1);
assert_eq!(d.get("saldo"), Some(&json!(200_i64)));
}
#[test]
fn delta_treats_missing_record_as_all_new() {
let current = Value::Null;
let proposed = map(&[("name", json!("Acme")), ("saldo", json!(0_i64))]);
assert_eq!(compute_field_delta(&current, &proposed).len(), 2);
}
#[test]
fn delta_distinguishes_int_from_string_repr() {
let current = json!({"qty": 100_i64});
let proposed = map(&[("qty", json!(100_i64))]);
assert!(compute_field_delta(&current, &proposed).is_empty());
let current_str = json!({"qty": "100"});
let proposed_int = map(&[("qty", json!(100_i64))]);
assert_eq!(compute_field_delta(&current_str, &proposed_int).len(), 1);
}
#[test]
fn delta_skips_fields_absent_from_proposed() {
let current = json!({"name": "Acme", "saldo": 100_i64, "extra": "x"});
let proposed = map(&[("name", json!("Acme")), ("saldo", json!(150_i64))]);
let d = compute_field_delta(&current, &proposed);
assert_eq!(d.len(), 1);
assert!(!d.contains_key("extra"));
}
#[test]
fn clear_fields_skips_absent_and_null() {
let current = json!({"name": "Acme", "notes": "lorem", "tag": null});
let to_clear = vec![
"name".into(),
"notes".into(),
"tag".into(),
"missing".into(),
];
assert_eq!(
compute_clear_fields(&current, &to_clear),
vec!["name".to_string(), "notes".to_string()]
);
}
#[test]
fn clear_fields_preserves_input_order() {
let current = json!({"a": 1, "b": 2, "c": 3});
let to_clear = vec!["c".into(), "a".into(), "b".into()];
assert_eq!(
compute_clear_fields(&current, &to_clear),
vec!["c", "a", "b"]
);
}
#[test]
fn clear_fields_empty_when_current_is_null() {
let current = Value::Null;
let to_clear = vec!["name".into()];
assert!(compute_clear_fields(&current, &to_clear).is_empty());
}
}
@@ -0,0 +1,185 @@
//! Helpers de presentación humana para records y values.
//!
//! Sin GPUI: devuelven `String`s. El widget renderer los wrap-ea
//! en `div().child(...)` o equivalente.
use serde_json::Value;
use uuid::Uuid;
/// Etiqueta humana para representar un record en el selector de
/// EntityRef. Heurística: prefiere campos comunes en este orden:
/// `name`, `label`, `title`, `sku`, `sku_id`. Fallback al UUID corto.
pub fn human_label_for_record(value: &Value, id: &Uuid) -> String {
for key in ["name", "label", "title", "sku", "sku_id"] {
if let Some(v) = value.get(key).and_then(Value::as_str) {
if !v.is_empty() {
return format!("{} ({})", v, short_uuid(id));
}
}
}
short_uuid(id)
}
/// Render legible de un `Value` arbitrario para mostrar en una celda
/// de lista. Strings van pelados; bools como ✓/✗; el resto via
/// `Display`.
pub fn render_value(v: Option<&Value>) -> String {
match v {
None | Some(Value::Null) => String::new(),
Some(Value::String(s)) => s.clone(),
Some(Value::Bool(b)) => if *b { "" } else { "" }.to_string(),
Some(Value::Number(n)) => n.to_string(),
Some(other) => other.to_string(),
}
}
/// Conversión inversa a `parse_field_value`: del JSON al texto raw
/// que un input puede tomar y volver a parsearse igual al submit.
/// Usado para pre-llenar inputs en modo edit.
pub fn value_to_input_text(v: &Value) -> String {
match v {
Value::Null => String::new(),
Value::String(s) => s.clone(),
Value::Bool(b) => if *b { "true" } else { "false" }.to_string(),
Value::Number(n) => n.to_string(),
other => other.to_string(),
}
}
/// Primeros 8 chars del UUID en forma canónica. Útil para logs y UI
/// donde el UUID full es ruido visual.
pub fn short_uuid(id: &Uuid) -> String {
id.to_string().chars().take(8).collect()
}
/// Hex string de los primeros 4 bytes de un hash SHA-256 (8
/// caracteres). Útil para mostrar bundle/schema hashes en UI sin
/// quemar pantalla con los 64 chars completos.
pub fn short_hash(h: &[u8; 32]) -> String {
use std::fmt::Write;
let mut s = String::with_capacity(8);
for b in h.iter().take(4) {
let _ = write!(s, "{:02x}", b);
}
s
}
/// Renderea un `serde_json::Value` en una sola línea, truncado a
/// `max` caracteres con `...` al final si excede. Para preview en
/// timelines/cards/listas — NO para edición.
///
/// `max` es un upper-bound aproximado: el resultado nunca excede
/// `max` chars, pero puede ser más corto si el value es chico.
pub fn preview_value(v: &Value, max: usize) -> String {
let s = v.to_string();
if s.chars().count() <= max {
s
} else if max < 3 {
s.chars().take(max).collect()
} else {
let truncated: String = s.chars().take(max - 3).collect();
format!("{truncated}...")
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn human_label_prefers_name_over_id() {
let id = Uuid::new_v4();
let v = json!({"name": "Acme S.A.", "email": "x@y.z"});
let label = human_label_for_record(&v, &id);
assert!(label.starts_with("Acme S.A."));
assert!(label.contains(&short_uuid(&id)));
}
#[test]
fn human_label_falls_back_through_label_title_sku() {
let id = Uuid::new_v4();
let only_label = json!({"label": "X"});
assert!(human_label_for_record(&only_label, &id).starts_with("X "));
let only_title = json!({"title": "Y"});
assert!(human_label_for_record(&only_title, &id).starts_with("Y "));
let only_sku = json!({"sku": "Z"});
assert!(human_label_for_record(&only_sku, &id).starts_with("Z "));
let only_sku_id = json!({"sku_id": "W"});
assert!(human_label_for_record(&only_sku_id, &id).starts_with("W "));
}
#[test]
fn human_label_falls_back_to_short_uuid_when_no_keys_match() {
let id = Uuid::new_v4();
let v = json!({"random": "field"});
assert_eq!(human_label_for_record(&v, &id), short_uuid(&id));
}
#[test]
fn render_value_handles_basic_kinds() {
assert_eq!(render_value(None), "");
assert_eq!(render_value(Some(&Value::Null)), "");
assert_eq!(render_value(Some(&json!("hola"))), "hola");
assert_eq!(render_value(Some(&json!(true))), "");
assert_eq!(render_value(Some(&json!(false))), "");
assert_eq!(render_value(Some(&json!(42))), "42");
}
#[test]
fn value_to_input_text_round_trip_with_strings_and_numbers() {
assert_eq!(value_to_input_text(&Value::Null), "");
assert_eq!(value_to_input_text(&json!("x")), "x");
assert_eq!(value_to_input_text(&json!(true)), "true");
assert_eq!(value_to_input_text(&json!(false)), "false");
assert_eq!(value_to_input_text(&json!(42)), "42");
}
#[test]
fn short_hash_takes_first_4_bytes_hex() {
let mut h = [0u8; 32];
h[0] = 0xaa;
h[1] = 0xbb;
h[2] = 0xcc;
h[3] = 0xdd;
assert_eq!(short_hash(&h), "aabbccdd");
}
#[test]
fn short_hash_zeros() {
let h = [0u8; 32];
assert_eq!(short_hash(&h), "00000000");
}
#[test]
fn preview_value_keeps_short_strings_intact() {
let v = json!({"a": 1});
assert_eq!(preview_value(&v, 30), "{\"a\":1}");
}
#[test]
fn preview_value_truncates_long_strings_with_ellipsis() {
let v = json!({"a": "x".repeat(200)});
let p = preview_value(&v, 30);
assert!(p.chars().count() <= 30);
assert!(p.ends_with("..."));
}
#[test]
fn preview_value_handles_max_smaller_than_ellipsis() {
// Edge case: max < 3 (no espacio para "..."). Devuelve
// los primeros `max` chars sin sufijo, sin panic.
let v = json!("xxxxxxxxxxxxxxxx");
let p = preview_value(&v, 2);
assert!(p.chars().count() <= 2);
}
#[test]
fn short_uuid_returns_first_8_chars() {
let id = Uuid::parse_str("01ARZ3ND-EKTS-V4RR-FFQ6-9G5FAV000000").ok();
// Si el parse falla, usamos uno fresco — el invariant es la
// longitud, no el contenido.
let id = id.unwrap_or_else(Uuid::new_v4);
assert_eq!(short_uuid(&id).len(), 8);
}
}
@@ -0,0 +1,39 @@
//! `nahual-meta-runtime` — helpers puros para runtimes metainterfaz.
//!
//! Consume [`nahual_meta_schema`] (los tipos `Module`/`View`/`FieldSpec`/
//! `FieldKind`/`Action`/etc.) y aporta funciones puras que cualquier
//! widget renderer o backend ejecutor necesita:
//!
//! - **Parse**: convertir el texto de un input a `serde_json::Value`
//! tipado según el `FieldKind` del spec.
//! - **Delta**: calcular qué cambió entre el estado actual y la
//! propuesta del form (Set + Clear).
//! - **Validation**: verificar que cada EntityRef apunte a un record
//! que existe (toma cierre `load`, no trait).
//! - **Format**: presentación humana de records (label heurístico,
//! render de values, UUID corto, round-trip a input text).
//!
//! Sin GPUI, sin acoplamiento a un backend específico. Cualquier
//! implementación de store/log puede consumirlos.
//!
//! El widget render (form/list/modal) vive en otro crate nahual
//! que esto consume; el runtime concreto (`nakui-ui`) implementa la
//! conexión a su event-log/executor y compone ambos.
#![forbid(unsafe_code)]
pub mod backend;
pub mod delta;
pub mod format;
pub mod parse;
pub mod refs;
pub mod testing;
pub use backend::{MetaBackend, WriteOutcome};
pub use delta::{compute_clear_fields, compute_field_delta};
pub use format::{
human_label_for_record, preview_value, render_value, short_hash, short_uuid,
value_to_input_text,
};
pub use parse::{infer_param_value, parse_field_value, resolve_param_value};
pub use refs::validate_entity_refs;
@@ -0,0 +1,231 @@
//! Parseo de inputs del form a `serde_json::Value` tipado.
use serde_json::{json, Value};
use uuid::Uuid;
use nahual_meta_schema::{FieldKind, FieldSpec};
/// Convierte el texto raw de un input al `Value` tipado según el
/// `kind` del spec.
///
/// - `Text` / `Multiline` / `Date` → string passthrough.
/// - `EntityRef` → string del UUID **trimmed**, validado como UUID
/// parseable. Falla con mensaje claro si no parsea.
/// - `Boolean` → variantes comunes (`true/yes/1/on/y` y `false/no/0/off/n`).
/// - `Number` → i64 si parsea, sino f64.
pub fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
match kind {
FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
// EntityRef se almacena como string del UUID seleccionado.
// El selector clickable garantiza UUIDs válidos en happy
// path; este check protege paste manual o garbage tipeado.
FieldKind::EntityRef => {
let trimmed = raw.trim();
Uuid::parse_str(trimmed).map_err(|_| {
format!("'{raw}' no es UUID válido (usá el selector de records)")
})?;
Ok(json!(trimmed))
}
FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
"true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
"" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
other => Err(format!("'{other}' no es booleano")),
},
FieldKind::Number => {
if let Ok(i) = raw.parse::<i64>() {
Ok(json!(i))
} else if let Ok(f) = raw.parse::<f64>() {
Ok(json!(f))
} else {
Err(format!("'{raw}' no es número"))
}
}
}
}
/// Resuelve un param de morphism a su `Value` según el `FieldSpec`
/// del form. **Strict path**: si hay spec, valida `required` y parsea
/// con el `kind` declarado (ej. Boolean rebota con "abc" antes de
/// llegar al morphism). **Fallback path**: si no hay spec (param
/// declarado en `Action::Morphism.params` que no aparece en
/// `form.fields`), usa la heurística [`infer_param_value`] para no
/// quedar atado a un schema mal-formado.
///
/// Errores tienen el label legible del spec, así el toast de la UI
/// es interpretable.
pub fn resolve_param_value(
field_name: &str,
raw: &str,
spec: Option<&FieldSpec>,
) -> Result<Value, String> {
let Some(s) = spec else {
return Ok(infer_param_value(raw));
};
let label = if s.label.is_empty() { field_name } else { &s.label };
if s.required && raw.trim().is_empty() {
return Err(format!("param '{label}' es obligatorio y está vacío"));
}
if raw.is_empty() && !s.required {
return Ok(Value::Null);
}
parse_field_value(s.kind, raw).map_err(|e| format!("param '{label}': {e}"))
}
/// Inferencia de tipo para values pasados como `params` a un
/// morphism. Usada como fallback en [`resolve_param_value`] cuando el
/// param declarado en `Action::Morphism.params` no aparece en los
/// `form.fields` (módulo mal-formado).
///
/// Heurística simple: int → i64, float → f64, "true"/"false" → bool,
/// resto → string.
pub fn infer_param_value(raw: &str) -> Value {
if raw.is_empty() {
return Value::Null;
}
if let Ok(i) = raw.parse::<i64>() {
return json!(i);
}
if let Ok(f) = raw.parse::<f64>() {
return json!(f);
}
match raw {
"true" => return json!(true),
"false" => return json!(false),
_ => {}
}
json!(raw)
}
#[cfg(test)]
mod tests {
use super::*;
use nahual_meta_schema::FieldSpec;
fn spec(name: &str, kind: FieldKind, required: bool) -> FieldSpec {
FieldSpec {
name: name.into(),
label: name.into(),
kind,
default: None,
required,
help: None,
ref_entity: None,
}
}
#[test]
fn infer_handles_basic_types() {
assert_eq!(infer_param_value(""), Value::Null);
assert_eq!(infer_param_value("42"), json!(42));
assert_eq!(infer_param_value("3.14"), json!(3.14));
assert_eq!(infer_param_value("true"), json!(true));
assert_eq!(infer_param_value("false"), json!(false));
assert_eq!(infer_param_value("hola"), json!("hola"));
}
#[test]
fn parse_text_passthrough() {
let v = parse_field_value(FieldKind::Text, "hola").unwrap();
assert_eq!(v, json!("hola"));
}
#[test]
fn parse_number_i64_or_f64() {
assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42));
assert_eq!(
parse_field_value(FieldKind::Number, "3.14").unwrap(),
json!(3.14)
);
assert!(parse_field_value(FieldKind::Number, "abc").is_err());
}
#[test]
fn parse_boolean_recognizes_variants() {
for s in ["true", "yes", "1", "on", "y"] {
assert_eq!(parse_field_value(FieldKind::Boolean, s).unwrap(), json!(true));
}
for s in ["false", "no", "0", "off", "n", ""] {
assert_eq!(
parse_field_value(FieldKind::Boolean, s).unwrap(),
json!(false)
);
}
assert!(parse_field_value(FieldKind::Boolean, "abc").is_err());
}
#[test]
fn parse_entity_ref_accepts_valid_uuid() {
let id = Uuid::new_v4();
let v = parse_field_value(FieldKind::EntityRef, &id.to_string()).unwrap();
assert_eq!(v, json!(id.to_string()));
}
#[test]
fn parse_entity_ref_trims_whitespace() {
let id = Uuid::new_v4();
let padded = format!(" {id}\n");
let v = parse_field_value(FieldKind::EntityRef, &padded).unwrap();
assert_eq!(v, json!(id.to_string()));
}
#[test]
fn parse_entity_ref_rejects_non_uuid() {
let err = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap_err();
assert!(err.contains("'abc-123'"));
assert!(err.contains("UUID") || err.contains("uuid"));
}
#[test]
fn parse_entity_ref_rejects_empty_string() {
let err = parse_field_value(FieldKind::EntityRef, "").unwrap_err();
assert!(err.contains("UUID"));
}
#[test]
fn resolve_param_strict_number_parses_i64() {
let s = spec("qty", FieldKind::Number, true);
let v = resolve_param_value("qty", "42", Some(&s)).unwrap();
assert_eq!(v, json!(42));
}
#[test]
fn resolve_param_strict_boolean_rejects_non_boolean() {
let s = spec("active", FieldKind::Boolean, true);
let err = resolve_param_value("active", "abc", Some(&s)).unwrap_err();
assert!(err.contains("active"));
}
#[test]
fn resolve_param_required_empty_rejected() {
let s = spec("name", FieldKind::Text, true);
let err = resolve_param_value("name", " ", Some(&s)).unwrap_err();
assert!(err.contains("obligatorio"));
}
#[test]
fn resolve_param_optional_empty_returns_null() {
let s = spec("notes", FieldKind::Text, false);
let v = resolve_param_value("notes", "", Some(&s)).unwrap();
assert_eq!(v, Value::Null);
}
#[test]
fn resolve_param_no_spec_falls_back_to_infer() {
let v = resolve_param_value("foo", "42", None).unwrap();
assert_eq!(v, json!(42));
let v = resolve_param_value("foo", "true", None).unwrap();
assert_eq!(v, json!(true));
let v = resolve_param_value("foo", "x", None).unwrap();
assert_eq!(v, json!("x"));
}
#[test]
fn resolve_param_strict_entity_ref_propagates_error() {
let s = spec("stock_ref", FieldKind::EntityRef, true);
let err = resolve_param_value("stock_ref", "not-a-uuid", Some(&s)).unwrap_err();
assert!(err.contains("stock_ref"));
assert!(err.contains("UUID"));
}
}
@@ -0,0 +1,108 @@
//! Validación cross-field de EntityRefs contra el store actual.
//!
//! Decoupling: en vez de un `trait Store` que ate este crate a un
//! backend específico, tomamos un cierre `load: Fn(&str, Uuid) ->
//! Option<Value>`. El caller (nakui-ui o cualquier otro runtime)
//! puede pasarlo trivialmente sobre cualquier store (MemoryStore,
//! SurrealStore, mock, ...).
use serde_json::Value;
use uuid::Uuid;
use crate::format::short_uuid;
/// Valida que cada UUID en `refs` apunte a un record que realmente
/// existe en el store bajo la entity esperada. Devuelve el primer
/// error encontrado (fail-fast).
///
/// `refs` es una lista de `(label, target_entity, uuid)`. El label
/// va al error message, así que conviene que sea legible (ej:
/// `FieldSpec.label` en lugar de `FieldSpec.name`).
///
/// `load` es el cierre que el caller usa para mirar el store —
/// típicamente `|e, id| store.load(e, id)`.
pub fn validate_entity_refs<F>(load: F, refs: &[(String, String, Uuid)]) -> Result<(), String>
where
F: Fn(&str, Uuid) -> Option<Value>,
{
for (label, target, id) in refs {
if load(target, *id).is_none() {
return Err(format!(
"campo '{label}': record {} de '{target}' no existe en el store",
short_uuid(id)
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
/// "Mock store" minimalista para tests: HashMap por (entity, uuid).
fn mk_load(records: HashMap<(String, Uuid), Value>) -> impl Fn(&str, Uuid) -> Option<Value> {
move |e, id| records.get(&(e.to_string(), id)).cloned()
}
#[test]
fn passes_when_all_records_exist() {
let stock = Uuid::new_v4();
let caja = Uuid::new_v4();
let mut records = HashMap::new();
records.insert(("Stock".into(), stock), json!({"sku_id": "abc"}));
records.insert(("Caja".into(), caja), json!({"name": "Principal"}));
let load = mk_load(records);
let refs = vec![
("Stock".into(), "Stock".into(), stock),
("Caja".into(), "Caja".into(), caja),
];
assert!(validate_entity_refs(load, &refs).is_ok());
}
#[test]
fn fails_on_first_missing() {
let stock = Uuid::new_v4();
let mut records = HashMap::new();
records.insert(("Stock".into(), stock), json!({"sku_id": "abc"}));
let load = mk_load(records);
let missing_caja = Uuid::new_v4();
let refs = vec![
("Stock".into(), "Stock".into(), stock),
("Caja".into(), "Caja".into(), missing_caja),
];
let err = validate_entity_refs(load, &refs).unwrap_err();
assert!(err.contains("Caja"));
assert!(err.contains(&short_uuid(&missing_caja)));
}
#[test]
fn uses_label_not_entity_in_msg() {
let load = |_: &str, _: Uuid| -> Option<Value> { None };
let id = Uuid::new_v4();
let refs = vec![("Stock origen".into(), "Stock".into(), id)];
let err = validate_entity_refs(load, &refs).unwrap_err();
assert!(err.contains("Stock origen"));
}
#[test]
fn empty_list_is_ok() {
let load = |_: &str, _: Uuid| -> Option<Value> { None };
assert!(validate_entity_refs(load, &[]).is_ok());
}
#[test]
fn distinguishes_target_from_other_entities() {
let id = Uuid::new_v4();
let mut records = HashMap::new();
// Mismo UUID bajo Customer pero NO bajo Stock.
records.insert(("Customer".into(), id), json!({"name": "Acme"}));
let load = mk_load(records);
let refs = vec![("Stock".into(), "Stock".into(), id)];
assert!(validate_entity_refs(load, &refs).is_err());
}
}
@@ -0,0 +1,339 @@
//! Utilidades de testing para code que consume [`MetaBackend`].
//!
//! Provee [`MockBackend`]: implementación in-memory minimalista
//! del trait, sin acoplamiento a stores reales (event log,
//! SurrealDB, etc.). Útil para:
//!
//! - Tests del widget [`nahual_widget_meta_form::MetaApp`] que
//! necesitan un backend funcional sin levantar nakui-core.
//! - Tests de cualquier consumer que tome `B: MetaBackend` y quiera
//! asserts sobre lecturas/escrituras sin tocar disco.
//! - Fixtures pre-pobladas para demos/screenshots/CI.
//!
//! Está bajo `pub mod testing` (no `#[cfg(test)]`) deliberadamente
//! para que crates downstream puedan importarlo en sus dev/integ
//! tests. No tiene overhead en producción si no se usa.
use std::collections::{BTreeMap, HashMap};
use serde_json::Value;
use uuid::Uuid;
use crate::backend::{MetaBackend, WriteOutcome};
/// Backend in-memory para tests. Implementa el contrato completo
/// del [`MetaBackend`] con semantica simple:
///
/// - `seed`: genera Uuid v4, inserta record. `changed = 1`.
/// - `update`: aplica `set` (overrides) y `clear` (key removal).
/// Si ambos vacíos → `changed = 0`. Falla si record no existe.
/// - `delete`: remueve record. Falla si no existe.
/// - `morphism`: por default rebota con error
/// `"MockBackend no soporta morphism '<name>'"`. Si querés
/// simular morphisms, registrá callbacks via
/// [`MockBackend::with_morphism`].
/// - `list_records`: orden lexicográfico por id (estable).
/// - Sin `post_status`: el mock no tiene tick/compact.
///
/// Métodos de inspección públicos ([`total_records`],
/// [`records_for`], etc.) facilitan asserts en tests sin necesidad
/// de re-leer el state via las APIs del trait.
pub struct MockBackend {
records: HashMap<(String, Uuid), Value>,
morphisms: HashMap<String, MorphismHandler>,
}
type MorphismHandler =
Box<dyn Fn(&BTreeMap<String, Uuid>, &Value) -> Result<usize, String> + Send + Sync>;
impl Default for MockBackend {
fn default() -> Self {
Self::new()
}
}
impl MockBackend {
/// Backend vacío.
pub fn new() -> Self {
Self {
records: HashMap::new(),
morphisms: HashMap::new(),
}
}
/// Pre-popula el backend con records `(entity, uuid, data)`.
/// Útil para fixtures: asserts sobre lecturas sin tener que
/// armar seeds via `seed()`.
pub fn with_records<I>(records: I) -> Self
where
I: IntoIterator<Item = (String, Uuid, Value)>,
{
let mut b = Self::new();
for (entity, id, data) in records {
b.records.insert((entity, id), data);
}
b
}
/// Registra un handler para un morphism de nombre `name`.
/// El handler recibe inputs + params y devuelve `changed` o
/// `Err` para simular fallo del morphism. Sobrescribe cualquier
/// handler previo del mismo nombre.
pub fn with_morphism<F>(mut self, name: impl Into<String>, handler: F) -> Self
where
F: Fn(&BTreeMap<String, Uuid>, &Value) -> Result<usize, String> + Send + Sync + 'static,
{
self.morphisms.insert(name.into(), Box::new(handler));
self
}
/// Cantidad total de records en el backend (todas las entities).
pub fn total_records(&self) -> usize {
self.records.len()
}
/// Records de una entity como `Vec<(Uuid, &Value)>` sin clones
/// (más liviano que `list_records` cuando el caller sólo quiere
/// inspeccionar).
pub fn records_for<'a>(&'a self, entity: &str) -> Vec<(Uuid, &'a Value)> {
self.records
.iter()
.filter(|((e, _), _)| e == entity)
.map(|((_, id), v)| (*id, v))
.collect()
}
}
impl MetaBackend for MockBackend {
fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)> {
let mut out: Vec<(Uuid, Value)> = self
.records
.iter()
.filter(|((e, _), _)| e == entity)
.map(|((_, id), v)| (*id, v.clone()))
.collect();
out.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
out
}
fn load_record(&self, entity: &str, id: Uuid) -> Option<Value> {
self.records.get(&(entity.to_string(), id)).cloned()
}
fn seed(
&mut self,
entity: &str,
data: serde_json::Map<String, Value>,
) -> Result<WriteOutcome, String> {
let id = Uuid::new_v4();
self.records
.insert((entity.to_string(), id), Value::Object(data));
Ok(WriteOutcome {
id: Some(id),
changed: 1,
post_status: None,
})
}
fn update(
&mut self,
entity: &str,
id: Uuid,
set: serde_json::Map<String, Value>,
clear: Vec<String>,
) -> Result<WriteOutcome, String> {
if set.is_empty() && clear.is_empty() {
return Ok(WriteOutcome::no_change(id));
}
let rec = self
.records
.get_mut(&(entity.to_string(), id))
.ok_or_else(|| format!("not found: {entity}/{id}"))?;
let map = rec
.as_object_mut()
.ok_or_else(|| format!("not an object: {entity}/{id}"))?;
let changed = set.len() + clear.len();
for (k, v) in set {
map.insert(k, v);
}
for k in clear {
map.remove(&k);
}
Ok(WriteOutcome {
id: Some(id),
changed,
post_status: None,
})
}
fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String> {
self.records
.remove(&(entity.to_string(), id))
.ok_or_else(|| format!("not found: {entity}/{id}"))?;
Ok(WriteOutcome {
id: Some(id),
changed: 1,
post_status: None,
})
}
fn morphism(
&mut self,
_module_id: &str,
name: &str,
inputs: BTreeMap<String, Uuid>,
params: Value,
) -> Result<WriteOutcome, String> {
match self.morphisms.get(name) {
Some(handler) => {
let changed = handler(&inputs, &params)?;
Ok(WriteOutcome {
id: None,
changed,
post_status: None,
})
}
None => Err(format!("MockBackend no soporta morphism '{name}'")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}
#[test]
fn with_records_populates_state() {
let id = Uuid::new_v4();
let b = MockBackend::with_records([(
"Customer".into(),
id,
json!({"name": "Acme"}),
)]);
assert_eq!(b.total_records(), 1);
assert_eq!(
b.load_record("Customer", id),
Some(json!({"name": "Acme"}))
);
}
#[test]
fn seed_then_load_round_trip_via_trait() {
let mut b = MockBackend::new();
let out = b
.seed("X", map_of(&[("k", json!(1))]))
.unwrap();
let id = out.id.unwrap();
assert_eq!(out.changed, 1);
assert_eq!(b.load_record("X", id), Some(json!({"k": 1})));
}
#[test]
fn update_no_op_returns_no_change() {
let id = Uuid::new_v4();
let mut b = MockBackend::with_records([(
"X".into(),
id,
json!({"k": 1}),
)]);
let out = b
.update("X", id, serde_json::Map::new(), vec![])
.unwrap();
assert_eq!(out, WriteOutcome::no_change(id));
}
#[test]
fn update_set_and_clear_aplica_ambos() {
let id = Uuid::new_v4();
let mut b = MockBackend::with_records([(
"X".into(),
id,
json!({"a": 1, "b": 2}),
)]);
let out = b
.update("X", id, map_of(&[("a", json!(10))]), vec!["b".into()])
.unwrap();
assert_eq!(out.changed, 2);
let rec = b.load_record("X", id).unwrap();
assert_eq!(rec.get("a"), Some(&json!(10)));
assert!(rec.get("b").is_none());
}
#[test]
fn delete_then_load_returns_none() {
let id = Uuid::new_v4();
let mut b = MockBackend::with_records([(
"X".into(),
id,
json!({"k": 1}),
)]);
b.delete("X", id).unwrap();
assert!(b.load_record("X", id).is_none());
}
#[test]
fn morphism_without_handler_errors_clearly() {
let mut b = MockBackend::new();
let err = b
.morphism("mod", "foo", BTreeMap::new(), json!({}))
.unwrap_err();
assert!(err.contains("foo"));
}
#[test]
fn with_morphism_lets_caller_simulate_logic() {
let mut b = MockBackend::new().with_morphism(
"double_qty",
|inputs, params| {
assert!(inputs.is_empty());
let qty = params.get("qty").and_then(|v| v.as_i64()).unwrap_or(0);
if qty <= 0 {
return Err("qty must be positive".into());
}
Ok(qty as usize)
},
);
let out = b
.morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 7}))
.unwrap();
assert_eq!(out.changed, 7);
assert!(out.id.is_none(), "morphism no devuelve id por convención");
let err = b
.morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 0}))
.unwrap_err();
assert!(err.contains("positive"));
}
#[test]
fn list_records_orders_lexicographically() {
let id_a = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
let id_b = Uuid::parse_str("ffffffff-0000-0000-0000-000000000000").unwrap();
let b = MockBackend::with_records([
("X".into(), id_b, json!({"n": 2})),
("X".into(), id_a, json!({"n": 1})),
]);
let rows = b.list_records("X");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].0, id_a, "menor uuid primero (orden lex)");
}
#[test]
fn records_for_returns_borrowed_view() {
let id = Uuid::new_v4();
let b = MockBackend::with_records([(
"X".into(),
id,
json!({"k": 1}),
)]);
let view = b.records_for("X");
assert_eq!(view.len(), 1);
assert_eq!(view[0].0, id);
assert_eq!(view[0].1.get("k"), Some(&json!(1)));
}
}
@@ -0,0 +1,14 @@
[package]
name = "nahual-meta-schema"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Yahweh — meta-schema: descriptores declarativos de UI (entities, menús, listas, formularios, acciones) consumidos por widgets metainterfaz reusables. Independiente del backend: cualquier app que monte una UI dirigida por datos puede usarlo."
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
@@ -0,0 +1,642 @@
//! Schema declarativo de la metainterfaz (nahual meta-schema).
//!
//! Cada **módulo** declara aquí qué menús, vistas, listas y
//! formularios expone, sin escribir código GPUI ni Rust. Cualquier
//! runtime de UI dirigida por datos (Nakui hoy, otros mañana) lo
//! carga y monta la UI correspondiente.
//!
//! ## Filosofía
//!
//! - **UI como datos**: agregar un módulo = escribir un JSON o un
//! `.ncl`. Ningún recompile, ningún acoplamiento con el binario
//! del runtime.
//! - **Backend-agnostic**: este crate sólo describe la *forma* de
//! la UI. La conexión a un store/log/executor concretos vive en
//! el runtime que lo consume (ej: el meta-runtime de Nakui que
//! wirea esto a `nakui_core` + KCL post-checks).
//! - **Schema primero, semántica después**: validación semántica
//! (referencias rotas a entities, campos faltantes, etc.) vive
//! en el runtime que lo carga, no acá.
//!
//! ## Anatomía de un módulo
//!
//! ```json
//! {
//! "id": "customers",
//! "label": "Clientes",
//! "entities": [
//! { "name": "customer", "fields": [ ... ] }
//! ],
//! "menu": [
//! { "label": "Listar", "view": "list" },
//! { "label": "Nuevo", "view": "form" }
//! ],
//! "views": {
//! "list": { "kind": "list", "entity": "customer", "columns": [...] },
//! "form": { "kind": "form", "entity": "customer", "fields": [...] }
//! }
//! }
//! ```
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Manifiesto de un módulo declarativo de UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Module {
/// Identificador estable. Único dentro del directorio cargado.
pub id: String,
/// Nombre legible para mostrar en el sidebar.
pub label: String,
/// Descripción corta opcional (tooltip / subtítulo).
#[serde(default)]
pub description: Option<String>,
/// Entities que el módulo introduce o consume. El runtime las
/// usa para validar columns/fields y para inicializar el store
/// cuando son nuevas.
#[serde(default)]
pub entities: Vec<EntitySpec>,
/// Path opaco al backend que va a manejar este módulo. Lo
/// interpreta el runtime concreto (no este schema).
///
/// Convención actual de Nakui: directorio con `nsmc.json` +
/// schemas KCL + scripts Rhai. Cuando está set, el runtime
/// Nakui carga un `Executor` para ese path y permite que las
/// acciones `Morphism { name }` despachen al pipeline real
/// (compute → log → apply). Otro backend puede ignorar este
/// campo o darle un significado distinto.
///
/// Path resuelto relativo al directorio del `module.json`
/// o absoluto.
///
/// Si es `None`, los backends que requieren manifest deberían
/// degradar (toast informativo, deshabilitar morphisms, etc.);
/// los `SeedEntity` siguen funcionando — son altas
/// administrativas que no necesitan validación de manifest.
///
/// Nombre conservado por compat con módulos ya escritos.
/// Renombrar a `backend_module_dir` o similar si emerge un
/// segundo backend que también lo use.
#[serde(default, alias = "backend_module_dir")]
pub nakui_module_dir: Option<String>,
/// Items del menú. Cada uno apunta a una key de `views`. Orden
/// importa (es el orden en que se presentan en el sidebar).
pub menu: Vec<MenuItem>,
/// Vistas indexadas por key. Las keys son referenciadas por
/// `MenuItem.view` y por `Action::OpenView.view`.
pub views: BTreeMap<String, View>,
}
/// Item del menú lateral.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MenuItem {
pub label: String,
/// Key de la vista a abrir. Debe existir en `Module.views`.
pub view: String,
/// Icono opcional (texto unicode o emoji; el runtime decide
/// renderización).
#[serde(default)]
pub icon: Option<String>,
}
/// Una vista renderizada en el área principal cuando su menú es seleccionado.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum View {
/// Tabla de instancias de una entity, columnas + acciones por fila.
List(ListView),
/// Formulario de creación / edición.
Form(FormView),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListView {
pub title: String,
/// Entity (del nakui store) cuyas instancias se listan.
pub entity: String,
/// Columnas a mostrar. Orden importa.
pub columns: Vec<Column>,
/// Acciones disponibles a nivel de la lista (ej. "Nuevo" → form).
/// Renderizadas como botones en el header.
#[serde(default)]
pub actions: Vec<Action>,
/// Cuando está set, se muestra una caja de búsqueda que filtra
/// las filas por substring contra los valores de estas columnas.
#[serde(default)]
pub search_in: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Column {
/// Path del campo dentro del record. Para tipos planos: nombre.
/// Para nested: puntos (`address.city`). El runtime navega.
pub field: String,
/// Texto del header.
pub label: String,
/// Ancho relativo (peso flex). Default 1.
#[serde(default = "default_weight")]
pub weight: f32,
}
fn default_weight() -> f32 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormView {
pub title: String,
/// Entity destino del seed/morphism al submit.
pub entity: String,
pub fields: Vec<FieldSpec>,
/// Acción al submit. Típicamente `Action::SeedEntity` para alta
/// directa o `Action::Morphism` cuando hay validación/cálculo.
pub on_submit: Action,
}
/// Especificación de un campo de formulario o columna implícita.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldSpec {
/// Nombre del campo en el record (clave del JSON).
pub name: String,
/// Etiqueta legible.
pub label: String,
/// Tipo del valor — define el widget de input + parseo.
pub kind: FieldKind,
/// Valor por defecto al abrir el form (string raw; el parseo
/// según `kind` lo hace el runtime).
#[serde(default)]
pub default: Option<String>,
/// Si `true`, el form rechaza submit con campo vacío.
#[serde(default)]
pub required: bool,
/// Texto de ayuda mostrado bajo el input.
#[serde(default)]
pub help: Option<String>,
/// Si `kind == EntityRef`, indica qué entity referencia. Sin
/// esto, el runtime no sabe qué records ofrecer en el selector
/// y la validación `Module::validate` rechaza el manifest.
/// Para los demás kinds, este campo se ignora.
#[serde(default)]
pub ref_entity: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FieldKind {
/// Texto libre.
Text,
/// Texto multilínea.
Multiline,
/// Número (i64 o f64; runtime intenta parsear como i64 primero).
Number,
/// Booleano (renderizado como checkbox).
Boolean,
/// Fecha (formato ISO YYYY-MM-DD; almacenada como string).
Date,
/// Referencia a otro record. El runtime renderiza un selector
/// clickable de records existentes de la entity declarada en
/// `FieldSpec.ref_entity`; el value almacenado es el UUID del
/// seleccionado, parseable como cualquier text/UUID al submit.
EntityRef,
}
/// Acciones disparables por menús, botones o submit de formularios.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Action {
/// Cambia la vista activa a otra del mismo módulo.
OpenView {
view: String,
/// Etiqueta del botón / item; default = nombre humano de la vista.
#[serde(default)]
label: Option<String>,
},
/// Crea un seed directo en la entity con los valores del form.
/// Equivalente a `nakui_core::event_log::seed_and_log`.
/// Sin pipeline de morphism — alta administrativa.
SeedEntity {
entity: String,
/// Tras el submit exitoso, opcionalmente abrir esta vista
/// (por convención: `"list"` para volver al listado).
#[serde(default)]
next_view: Option<String>,
},
/// Ejecuta un morphism declarado en el manifest del módulo
/// nakui-core (cuyo path vive en `Module.nakui_module_dir`).
/// Inputs (records existentes) y params (valores escalares) se
/// mapean desde los campos del form.
Morphism {
/// Nombre del morphism declarado en `nsmc.json` del manifest
/// nakui apuntado por el módulo.
name: String,
/// Mapeo `role → field_name`: por cada input declarado en
/// el `MorphismSpec.inputs`, indica qué field del form
/// contiene el UUID del record. El runtime parsea el value
/// como `Uuid` y lo pasa como input al `execute_and_log`.
///
/// Ej: `{ "stock": "stock_id", "caja": "caja_id" }` para un
/// morphism `vender` que toma roles `stock` y `caja`.
#[serde(default)]
inputs: BTreeMap<String, String>,
/// Lista de fields del form cuyos values van al `params`
/// JSON object pasado al morphism. Si está vacío, todos los
/// fields que no estén en `inputs` van a params.
#[serde(default)]
params: Vec<String>,
#[serde(default)]
next_view: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntitySpec {
/// Nombre de la entity (clave en el store). Único dentro del módulo.
pub name: String,
/// Label legible (singular).
pub label: String,
/// Campos esperados en records de esta entity. Usados por el
/// runtime para inferir columnas / validar formularios.
#[serde(default)]
pub fields: Vec<FieldSpec>,
}
#[derive(Debug, Error)]
pub enum SchemaError {
#[error("io leyendo {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("parseo de {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("módulo {id}: la vista '{view}' referenciada por menu_item '{item}' no existe")]
DanglingMenuView {
id: String,
item: String,
view: String,
},
#[error("módulos con id duplicado: '{id}' aparece en {first} y {second}")]
DuplicateModuleId {
id: String,
first: PathBuf,
second: PathBuf,
},
#[error(
"módulo {id} vista '{view}': field '{field}' tiene kind=entity_ref \
pero no declaró ref_entity"
)]
EntityRefMissingTarget {
id: String,
view: String,
field: String,
},
}
impl Module {
/// Carga un module.json desde disco. Validación estructural
/// posterior (vistas referenciadas existen, etc.) la ejecuta el
/// runtime — acá sólo parseamos.
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SchemaError> {
let path = path.as_ref();
let bytes = std::fs::read(path).map_err(|source| SchemaError::Io {
path: path.to_path_buf(),
source,
})?;
serde_json::from_slice(&bytes).map_err(|source| SchemaError::Parse {
path: path.to_path_buf(),
source,
})
}
/// Validación post-parse:
/// - Cada `MenuItem.view` debe existir en `views`.
/// - Cada `FieldSpec` con `kind=EntityRef` debe declarar
/// `ref_entity`.
pub fn validate(&self) -> Result<(), SchemaError> {
for item in &self.menu {
if !self.views.contains_key(&item.view) {
return Err(SchemaError::DanglingMenuView {
id: self.id.clone(),
item: item.label.clone(),
view: item.view.clone(),
});
}
}
for (view_key, view) in &self.views {
if let View::Form(form) = view {
for f in &form.fields {
if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() {
return Err(SchemaError::EntityRefMissingTarget {
id: self.id.clone(),
view: view_key.clone(),
field: f.name.clone(),
});
}
}
}
}
Ok(())
}
}
/// Carga todos los `module.json` encontrados bajo `dir` (recursivo
/// 1 nivel — espera `dir/<modulo>/module.json`). Devuelve la lista
/// ordenada por id, validada.
///
/// Falla si: I/O, parseo, módulo inválido, o ids duplicados.
pub fn load_modules_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Module>, SchemaError> {
let dir = dir.as_ref();
let mut modules: Vec<(PathBuf, Module)> = Vec::new();
let entries = std::fs::read_dir(dir).map_err(|source| SchemaError::Io {
path: dir.to_path_buf(),
source,
})?;
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
let manifest = p.join("module.json");
if manifest.exists() {
let m = Module::from_path(&manifest)?;
m.validate()?;
modules.push((manifest, m));
}
}
}
// Detectar duplicados de id.
modules.sort_by(|a, b| a.1.id.cmp(&b.1.id));
let mut prev: Option<&(PathBuf, Module)> = None;
for cur in &modules {
if let Some(p) = prev {
if p.1.id == cur.1.id {
return Err(SchemaError::DuplicateModuleId {
id: cur.1.id.clone(),
first: p.0.clone(),
second: cur.0.clone(),
});
}
}
prev = Some(cur);
}
Ok(modules.into_iter().map(|(_, m)| m).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_module() -> Module {
Module {
id: "customers".into(),
label: "Clientes".into(),
description: Some("Gestión de clientes".into()),
nakui_module_dir: None,
entities: vec![EntitySpec {
name: "customer".into(),
label: "Cliente".into(),
fields: vec![
FieldSpec {
name: "name".into(),
label: "Nombre".into(),
kind: FieldKind::Text,
default: None,
required: true,
help: None,
ref_entity: None,
},
FieldSpec {
name: "email".into(),
label: "Email".into(),
kind: FieldKind::Text,
default: None,
required: false,
help: Some("Opcional".into()),
ref_entity: None,
},
],
}],
menu: vec![
MenuItem {
label: "Listar".into(),
view: "list".into(),
icon: None,
},
MenuItem {
label: "Nuevo".into(),
view: "form".into(),
icon: None,
},
],
views: BTreeMap::from([
(
"list".into(),
View::List(ListView {
title: "Clientes".into(),
entity: "customer".into(),
columns: vec![
Column {
field: "name".into(),
label: "Nombre".into(),
weight: 2.0,
},
Column {
field: "email".into(),
label: "Email".into(),
weight: 3.0,
},
],
actions: vec![Action::OpenView {
view: "form".into(),
label: Some("Nuevo".into()),
}],
search_in: vec!["name".into(), "email".into()],
}),
),
(
"form".into(),
View::Form(FormView {
title: "Nuevo cliente".into(),
entity: "customer".into(),
fields: vec![FieldSpec {
name: "name".into(),
label: "Nombre".into(),
kind: FieldKind::Text,
default: None,
required: true,
help: None,
ref_entity: None,
}],
on_submit: Action::SeedEntity {
entity: "customer".into(),
next_view: Some("list".into()),
},
}),
),
]),
}
}
#[test]
fn module_serialize_deserialize_roundtrip() {
let m = sample_module();
let s = serde_json::to_string_pretty(&m).unwrap();
let m2: Module = serde_json::from_str(&s).unwrap();
assert_eq!(m.id, m2.id);
assert_eq!(m.menu.len(), m2.menu.len());
assert_eq!(m.views.len(), m2.views.len());
}
#[test]
fn validate_passes_for_well_formed_module() {
sample_module().validate().unwrap();
}
#[test]
fn validate_catches_dangling_menu_view() {
let mut m = sample_module();
m.menu.push(MenuItem {
label: "Roto".into(),
view: "no_existe".into(),
icon: None,
});
let err = m.validate().unwrap_err();
assert!(matches!(err, SchemaError::DanglingMenuView { .. }));
}
#[test]
fn from_path_loads_real_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("module.json");
std::fs::write(&path, serde_json::to_vec_pretty(&sample_module()).unwrap()).unwrap();
let m = Module::from_path(&path).unwrap();
assert_eq!(m.id, "customers");
}
#[test]
fn load_modules_from_dir_finds_subdirs() {
let tmp = tempfile::tempdir().unwrap();
let cust_dir = tmp.path().join("customers");
std::fs::create_dir(&cust_dir).unwrap();
let mut m1 = sample_module();
m1.id = "customers".into();
std::fs::write(
cust_dir.join("module.json"),
serde_json::to_vec_pretty(&m1).unwrap(),
)
.unwrap();
let prod_dir = tmp.path().join("products");
std::fs::create_dir(&prod_dir).unwrap();
let mut m2 = sample_module();
m2.id = "products".into();
std::fs::write(
prod_dir.join("module.json"),
serde_json::to_vec_pretty(&m2).unwrap(),
)
.unwrap();
let mods = load_modules_from_dir(tmp.path()).unwrap();
assert_eq!(mods.len(), 2);
assert_eq!(mods[0].id, "customers");
assert_eq!(mods[1].id, "products");
}
#[test]
fn validate_catches_entity_ref_without_target() {
let mut m = sample_module();
// Inyectamos un form con un campo EntityRef sin ref_entity.
m.views.insert(
"broken_form".into(),
View::Form(FormView {
title: "Roto".into(),
entity: "customer".into(),
fields: vec![FieldSpec {
name: "ref_to_nowhere".into(),
label: "Referencia".into(),
kind: FieldKind::EntityRef,
default: None,
required: true,
help: None,
ref_entity: None,
}],
on_submit: Action::SeedEntity {
entity: "customer".into(),
next_view: None,
},
}),
);
let err = m.validate().unwrap_err();
assert!(
matches!(err, SchemaError::EntityRefMissingTarget { .. }),
"got: {err:?}"
);
}
#[test]
fn entity_ref_with_target_validates_clean() {
let mut m = sample_module();
m.views.insert(
"ok_form".into(),
View::Form(FormView {
title: "OK".into(),
entity: "customer".into(),
fields: vec![FieldSpec {
name: "supplier".into(),
label: "Proveedor".into(),
kind: FieldKind::EntityRef,
default: None,
required: true,
help: None,
ref_entity: Some("supplier".into()),
}],
on_submit: Action::SeedEntity {
entity: "customer".into(),
next_view: None,
},
}),
);
m.menu.push(MenuItem {
label: "OK".into(),
view: "ok_form".into(),
icon: None,
});
m.validate().unwrap();
}
#[test]
fn load_modules_detects_duplicate_id() {
let tmp = tempfile::tempdir().unwrap();
let a_dir = tmp.path().join("a");
let b_dir = tmp.path().join("b");
std::fs::create_dir_all(&a_dir).unwrap();
std::fs::create_dir_all(&b_dir).unwrap();
let m = sample_module(); // id = "customers"
std::fs::write(
a_dir.join("module.json"),
serde_json::to_vec(&m).unwrap(),
)
.unwrap();
std::fs::write(
b_dir.join("module.json"),
serde_json::to_vec(&m).unwrap(),
)
.unwrap();
let err = load_modules_from_dir(tmp.path()).unwrap_err();
assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
}
}
@@ -0,0 +1,119 @@
//! Validación de los 6 módulos demo en `examples/nakui-modules/`.
//!
//! Si esto verde, garantizamos que un usuario que clone el repo y
//! corra `NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui`
//! va a obtener los 6 módulos cargados sin tocar nada.
use nahual_meta_schema::{load_modules_from_dir, FieldKind, View};
fn examples_dir() -> std::path::PathBuf {
// Tests corren desde el dir del crate; el repo root está dos
// niveles arriba: crates/modules/nakui/ui-schema → repo.
// Tras el lift a nahual, el crate vive en
// `crates/modules/ui_engine/libs/meta-schema`, así que el repo
// root queda 5 niveles arriba.
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
here.join("../../../../..").join("examples/nakui-modules")
}
#[test]
fn loads_all_demo_modules() {
let dir = examples_dir();
let mods = load_modules_from_dir(&dir).unwrap_or_else(|e| {
panic!("load failed for {}: {e}", dir.display());
});
let ids: Vec<&str> = mods.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
vec![
"customers",
"inventory_movements",
"invoices",
"products",
"sales_engine",
"sales_orders",
"suppliers",
],
"expected 7 modules in alphabetical order \
(sales_engine se sumó al wirear Action::Morphism)"
);
}
#[test]
fn sales_engine_declares_nakui_module_dir_and_morphism() {
// Sanity del módulo demo de morphism: nakui_module_dir set,
// y al menos una vista con Action::Morphism en su on_submit.
let mods = load_modules_from_dir(examples_dir()).unwrap();
let sales = mods
.iter()
.find(|m| m.id == "sales_engine")
.expect("sales_engine debe estar");
assert!(
sales.nakui_module_dir.is_some(),
"sales_engine debería declarar nakui_module_dir"
);
let has_morphism_view = sales.views.values().any(|v| match v {
nahual_meta_schema::View::Form(form) => {
matches!(form.on_submit, nahual_meta_schema::Action::Morphism { .. })
}
_ => false,
});
assert!(
has_morphism_view,
"sales_engine debería tener al menos una Form con Action::Morphism"
);
}
#[test]
fn every_demo_module_has_list_and_form_views() {
let mods = load_modules_from_dir(examples_dir()).unwrap();
for m in &mods {
let mut has_list = false;
let mut has_form = false;
for v in m.views.values() {
match v {
View::List(_) => has_list = true,
View::Form(_) => has_form = true,
}
}
assert!(
has_list && has_form,
"module {} should expose at least one list + one form view",
m.id
);
}
}
#[test]
fn every_demo_form_field_kind_is_recognized() {
// Sanity: ningún módulo demo usa un kind que no esté en el enum
// (sería rechazado al parsear, pero check explícito no daña).
let mods = load_modules_from_dir(examples_dir()).unwrap();
for m in &mods {
for v in m.views.values() {
if let View::Form(form) = v {
for f in &form.fields {
let _ok = matches!(
f.kind,
FieldKind::Text
| FieldKind::Multiline
| FieldKind::Number
| FieldKind::Boolean
| FieldKind::Date
);
}
}
}
}
}
#[test]
fn every_module_validates_clean() {
// validate() chequea que cada MenuItem.view exista en views.
// Un typo en cualquiera de los 6 módulos haría fallar este test.
let mods = load_modules_from_dir(examples_dir()).unwrap();
for m in &mods {
m.validate()
.unwrap_or_else(|e| panic!("module {} failed validate: {e}", m.id));
}
}
@@ -0,0 +1,13 @@
[package]
name = "nahual-provider-fs"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "DataProvider de filesystem local con discernimiento de contenido (shipote-discern)."
[dependencies]
nahual-core = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }
notify = { workspace = true }
shuma-discern = { path = "../../../../shuma/shuma-discern" }
@@ -0,0 +1,119 @@
//! Provider de filesystem local. Crate puro: cero dependencia de UI.
//! Implementa `nahual_core::DataProvider` listando hijos de un path con
//! `std::fs::read_dir` y leyendo archivos a `Vec<u8>` via `tokio::io`.
use async_trait::async_trait;
use shuma_discern::{DiscernPipeline, Hint};
use std::fs;
use std::io::{Cursor, Read};
use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
use tokio::io::{AsyncRead, AsyncWrite};
use nahual_core::{DataProvider, DisplayType, EntityNode};
pub const PROVIDER_ID: &str = "local_fs";
/// Bytes que samplea el discerner por archivo. 4 KiB cubre headers de
/// formatos comunes (PNG, ELF, JSON/TOML hasta una clave de profundidad
/// razonable) sin saturar I/O al expandir un directorio.
const DISCERN_SAMPLE_BYTES: usize = 4096;
/// Tamaño máximo de archivo que sampleamos. Archivos más grandes se
/// discernen igual via los primeros 4 KiB: el `seek/read` siempre lee
/// head, y el costo es O(SAMPLE) sin importar el size total.
/// Mantenemos esta constante por documentación; no se usa para skipear.
const _DISCERN_SAMPLE_DOC: () = ();
pub struct FileDataProvider {
discerner: Arc<DiscernPipeline>,
}
impl FileDataProvider {
pub fn new() -> Self {
Self {
discerner: Arc::new(DiscernPipeline::default_pipeline()),
}
}
}
impl Default for FileDataProvider {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl DataProvider for FileDataProvider {
fn provider_id(&self) -> String {
PROVIDER_ID.to_string()
}
async fn list_children(&self, parent_id: Option<&str>) -> Result<Vec<EntityNode>, String> {
let path = parent_id.unwrap_or(".");
let mut children = Vec::new();
if let Ok(entries) = fs::read_dir(path) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let is_dir = path.is_dir();
let display_type = if is_dir { DisplayType::Folder } else { DisplayType::File };
// Discernimos sólo archivos. Folders no tienen MIME útil.
let mime_type = if is_dir {
None
} else {
discern_head(&path, &self.discerner)
};
children.push(EntityNode {
id: path.to_string_lossy().into_owned(),
name,
display_type,
mime_type,
});
}
}
Ok(children)
}
async fn get_read_stream(
&self,
entity_id: &str,
) -> Result<Pin<Box<dyn AsyncRead + Send>>, String> {
let content = fs::read(Path::new(entity_id)).map_err(|e| e.to_string())?;
Ok(Box::pin(Cursor::new(content)))
}
async fn get_write_stream(
&self,
_entity_id: &str,
) -> Result<Pin<Box<dyn AsyncWrite + Send>>, String> {
Err("Escritura en streaming no implementada para FS".to_string())
}
}
/// Lee el head del archivo y lo pasa por el DiscernPipeline. Devuelve el
/// MIME detectado (si alguno) o `None` si no hubo match.
///
/// Sync intencional: estamos dentro del runtime que ya es async, pero la
/// lectura es de tamaño fijo (4 KiB) y va a page cache; el costo de
/// `tokio::fs` no compensaría para esto.
fn discern_head(path: &Path, discerner: &DiscernPipeline) -> Option<String> {
let mut buf = vec![0u8; DISCERN_SAMPLE_BYTES];
let mut f = fs::File::open(path).ok()?;
let n = f.read(&mut buf).ok()?;
buf.truncate(n);
let path_str = path.to_str();
let hint = Hint {
path: path_str,
size_total: None,
};
discerner.discern(&buf, &hint).and_then(|d| d.mime)
}
@@ -0,0 +1,12 @@
[package]
name = "nahual-provider-sqlite"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "DataProvider de SQLite (jerarquía vía parent_id)."
[dependencies]
nahual-core = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }
rusqlite = { workspace = true }
@@ -0,0 +1,118 @@
//! Provider de SQLite. Crate puro: cero dependencia de UI.
//! Tabla `items(id, parent_id, name, display_type, content)` con jerarquía
//! por `parent_id NULL` = raíz.
use async_trait::async_trait;
use rusqlite::Connection;
use std::io::Cursor;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncRead, AsyncWrite};
use nahual_core::{DataProvider, DisplayType, EntityNode};
pub const PROVIDER_ID: &str = "sqlite_db";
pub struct SqliteDataProvider {
db: Arc<Mutex<Connection>>,
}
impl SqliteDataProvider {
pub fn new(path: &str) -> Result<Self, String> {
let conn = Connection::open(path).map_err(|e| e.to_string())?;
conn.execute(
"CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
parent_id TEXT,
name TEXT NOT NULL,
display_type TEXT NOT NULL,
content BLOB
)",
[],
)
.map_err(|e| e.to_string())?;
// Seed mínimo si la tabla está vacía — para que el DatabaseExplorer
// tenga algo que mostrar en una primera ejecución sin pre-config.
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0))
.unwrap_or(0);
if count == 0 {
let _ = conn.execute(
"INSERT INTO items (id, parent_id, name, display_type, content) VALUES \
('readme', NULL, 'README.md', 'File', ?), \
('notes', NULL, 'notes', 'Folder', NULL), \
('todo', 'notes', 'TODO.md', 'File', ?)",
rusqlite::params![
b"# Yahweh\n\nDemo readme stored in SQLite.\n",
b"- TreeView gen\xC3\xA9rico\n- containers swappables\n- layout JSON\n",
],
);
}
Ok(Self {
db: Arc::new(Mutex::new(conn)),
})
}
}
#[async_trait]
impl DataProvider for SqliteDataProvider {
fn provider_id(&self) -> String {
PROVIDER_ID.to_string()
}
async fn list_children(&self, parent_id: Option<&str>) -> Result<Vec<EntityNode>, String> {
let db = self.db.lock().unwrap();
let mut stmt = db
.prepare("SELECT id, name, display_type FROM items WHERE parent_id IS ?")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([parent_id], |row| {
let display_type_str: String = row.get(2)?;
let display_type = match display_type_str.as_str() {
"Folder" => DisplayType::Folder,
"Stream" => DisplayType::Stream,
_ => DisplayType::File,
};
Ok(EntityNode {
id: row.get(0)?,
name: row.get(1)?,
display_type,
mime_type: None,
})
})
.map_err(|e| e.to_string())?;
let mut children = Vec::new();
for row in rows {
children.push(row.map_err(|e| e.to_string())?);
}
Ok(children)
}
async fn get_read_stream(
&self,
entity_id: &str,
) -> Result<Pin<Box<dyn AsyncRead + Send>>, String> {
let db = self.db.lock().unwrap();
let content: Vec<u8> = db
.query_row(
"SELECT content FROM items WHERE id = ?",
[entity_id],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
Ok(Box::pin(Cursor::new(content)))
}
async fn get_write_stream(
&self,
_entity_id: &str,
) -> Result<Pin<Box<dyn AsyncWrite + Send>>, String> {
Err("Escritura en streaming no implementada para SQLite (todavía)".to_string())
}
}
@@ -0,0 +1,9 @@
[package]
name = "nahual-theme"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Sistema de temas — paleta + gradientes, Theme como Global GPUI."
[dependencies]
gpui = { workspace = true }
+616
View File
@@ -0,0 +1,616 @@
//! `nahual_theme` — paleta de colores y backgrounds compartidos.
//!
//! El `Theme` se instala como `Global` de GPUI. Los widgets lo leen vía
//! `cx.global::<Theme>()` durante su `render`, y se subscriben con
//! `cx.observe_global::<Theme>(…)` para auto-redibujarse cuando cambia.
//!
//! Filosofía: el theme es **dato puro** (sin lógica de UI). No conoce a
//! ningún widget concreto. Cada widget pide slots semánticos (panel_bg,
//! row_hover, accent…) sin acoplarse a colores hex específicos.
use gpui::{Background, Global, Hsla, hsla, linear_color_stop, linear_gradient};
/// Paleta semántica del theme. Cada slot tiene un nombre funcional, no
/// cromático — así los widgets piden "fondo de panel" sin acoplarse a
/// "azul oscuro".
///
/// Convención de slots:
/// - `bg_*` se devuelve como `Background` (soporta gradientes); los widgets
/// lo pasan a `.bg(...)` directamente.
/// - `fg_*`, `accent`, `border` son `Hsla` (colores planos para texto y
/// ornamentos).
/// - `bg_row_*` son `Hsla` porque las filas de una lista virtualizada se
/// beneficiarían poco de un gradiente individual.
#[derive(Clone, Debug)]
pub struct Theme {
pub name: &'static str,
pub is_dark: bool,
// Fondos.
pub bg_app: Background,
pub bg_panel: Background,
pub bg_panel_alt: Background,
pub bg_row_hover: Hsla,
pub bg_row_active: Hsla,
// Foregrounds.
pub fg_text: Hsla,
pub fg_muted: Hsla,
pub fg_disabled: Hsla,
// Acentos y ornamentos.
pub accent: Hsla,
pub accent_strong: Hsla,
pub border: Hsla,
pub border_strong: Hsla,
/// Marker colors para indicar "este file está abierto en container N".
/// Paleta circular — el N-ésimo container usa `marker_palette[n % len]`.
pub marker_palette: Vec<Hsla>,
}
impl Global for Theme {}
/// Helper privado: deriva los 5 slots "ornament secundario"
/// (bg_input/button/button_hover + accent_destructive +
/// bg_destructive_hover) según `is_dark`.
///
/// Devuelve los slots en el orden de los métodos públicos del
/// `Theme`. Los métodos del impl los exponen individualmente.
fn ornament_slots(is_dark: bool) -> (Hsla, Hsla, Hsla, Hsla, Hsla) {
if is_dark {
(
// bg_input: muy oscuro, sutil tinte azul/gris
hsla(220.0 / 360.0, 0.20, 0.07, 1.0),
// bg_button: medio oscuro
hsla(220.0 / 360.0, 0.18, 0.20, 1.0),
// bg_button_hover: un poco más claro
hsla(220.0 / 360.0, 0.20, 0.27, 1.0),
// accent_destructive: rojo medio-claro para visibilidad
hsla(0.0, 0.55, 0.65, 1.0),
// bg_destructive_hover: rojo oscuro de fondo
hsla(0.0, 0.55, 0.18, 1.0),
)
} else {
(
hsla(220.0 / 360.0, 0.10, 0.97, 1.0),
hsla(220.0 / 360.0, 0.15, 0.85, 1.0),
hsla(220.0 / 360.0, 0.20, 0.75, 1.0),
hsla(0.0, 0.65, 0.45, 1.0),
hsla(0.0, 0.55, 0.92, 1.0),
)
}
}
impl Theme {
/// Bg sutil para fields editables que se quieren marcar como
/// "input target" sin ser un panel. Derivado de `is_dark`.
pub fn bg_input(&self) -> Hsla {
ornament_slots(self.is_dark).0
}
/// Bg para clickable controls (botones secundarios, edit/delete
/// icons en filas). Más prominente que `bg_panel_alt`, menos que
/// `accent`. Derivado de `is_dark`.
pub fn bg_button(&self) -> Hsla {
ornament_slots(self.is_dark).1
}
/// Hover de [`Self::bg_button`].
pub fn bg_button_hover(&self) -> Hsla {
ornament_slots(self.is_dark).2
}
/// Accent rojo para acciones destructivas (delete, drop, force).
pub fn accent_destructive(&self) -> Hsla {
ornament_slots(self.is_dark).3
}
/// Bg de hover sobre clickable destructive elements (icon ✕,
/// botones de "borrar"). Más oscuro que `accent_destructive`.
pub fn bg_destructive_hover(&self) -> Hsla {
ornament_slots(self.is_dark).4
}
pub fn global(cx: &gpui::App) -> &Self {
cx.global::<Self>()
}
/// Carga el theme persistido si existe + es válido; sino default
/// a Nebula. El persisted lo escribe [`Self::set`] cada vez que el
/// theme cambia (típicamente vía `theme_switcher`).
pub fn install_default(cx: &mut gpui::App) {
let theme = load_persisted().unwrap_or_else(Self::nebula);
cx.set_global(theme);
}
/// Reemplaza el theme global y persiste su `name` al config file.
/// GPUI notifica a todos los `observe_global` suscriptores en el
/// siguiente frame.
///
/// La persistencia es best-effort: si write falla (no hay home,
/// permission denied, etc.), el theme cambia in-memory pero no
/// sobrevive al restart. No se rebota — la UX no se interrumpe
/// por un I/O secundario.
pub fn set(cx: &mut gpui::App, theme: Self) {
let _ = persist(&theme);
cx.set_global(theme);
}
/// Lista todos los presets en orden estable. Usado por el switcher para
/// ciclar y por el menú de "Tema" cuando lo agreguemos.
pub fn all() -> Vec<Self> {
vec![
Self::nebula(),
Self::aurora(),
Self::sunset(),
Self::flat_dark(),
Self::solarized_light(),
Self::high_contrast(),
Self::print_color(),
Self::print_bw(),
]
}
/// Devuelve el preset cuyo `name` matchea (case-insensitive). `None` si
/// el nombre no existe — útil para validar input de usuario al cargar
/// preferencias persistidas.
pub fn by_name(name: &str) -> Option<Self> {
Self::all()
.into_iter()
.find(|t| t.name.eq_ignore_ascii_case(name))
}
/// Próximo preset en la rotación de `all()`. Si `current` no está, se
/// vuelve al primero. La rotación es circular (último → primero).
pub fn next_after(current: &str) -> Self {
let all = Self::all();
let idx = all.iter().position(|t| t.name == current);
match idx {
Some(i) => all[(i + 1) % all.len()].clone(),
None => all[0].clone(),
}
}
// =====================================================================
// Presets
// =====================================================================
/// **Nebula** — default. Gradiente vertical violáceo profundo → teal
/// medianoche. Pensado para sentirse moderno y descansado de noche.
pub fn nebula() -> Self {
let bg_app = linear_gradient(
165.0,
linear_color_stop(hsla(265.0 / 360.0, 0.38, 0.07, 1.0), 0.0),
linear_color_stop(hsla(195.0 / 360.0, 0.42, 0.09, 1.0), 1.0),
);
let bg_panel = linear_gradient(
165.0,
linear_color_stop(hsla(245.0 / 360.0, 0.28, 0.10, 1.0), 0.0),
linear_color_stop(hsla(210.0 / 360.0, 0.30, 0.12, 1.0), 1.0),
);
let bg_panel_alt = linear_gradient(
165.0,
linear_color_stop(hsla(255.0 / 360.0, 0.25, 0.13, 1.0), 0.0),
linear_color_stop(hsla(220.0 / 360.0, 0.27, 0.14, 1.0), 1.0),
);
Self {
name: "Nebula",
is_dark: true,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(220.0 / 360.0, 0.30, 0.20, 0.45),
bg_row_active: hsla(280.0 / 360.0, 0.55, 0.28, 0.65),
fg_text: hsla(210.0 / 360.0, 0.35, 0.88, 1.0),
fg_muted: hsla(215.0 / 360.0, 0.22, 0.58, 1.0),
fg_disabled: hsla(215.0 / 360.0, 0.10, 0.40, 1.0),
accent: hsla(280.0 / 360.0, 0.65, 0.65, 1.0),
accent_strong: hsla(285.0 / 360.0, 0.78, 0.74, 1.0),
border: hsla(225.0 / 360.0, 0.20, 0.22, 1.0),
border_strong: hsla(280.0 / 360.0, 0.40, 0.45, 1.0),
marker_palette: vec![
hsla(280.0 / 360.0, 0.65, 0.55, 0.45),
hsla(195.0 / 360.0, 0.65, 0.50, 0.45),
hsla(35.0 / 360.0, 0.75, 0.55, 0.45),
hsla(135.0 / 360.0, 0.55, 0.50, 0.45),
hsla(0.0, 0.60, 0.55, 0.45),
],
}
}
/// **Aurora** — verdes-cian-azul, evoca aurora boreal. Más frío que
/// Nebula, contraste alto.
pub fn aurora() -> Self {
let bg_app = linear_gradient(
190.0,
linear_color_stop(hsla(170.0 / 360.0, 0.45, 0.06, 1.0), 0.0),
linear_color_stop(hsla(220.0 / 360.0, 0.50, 0.09, 1.0), 1.0),
);
let bg_panel = linear_gradient(
190.0,
linear_color_stop(hsla(165.0 / 360.0, 0.32, 0.10, 1.0), 0.0),
linear_color_stop(hsla(215.0 / 360.0, 0.36, 0.12, 1.0), 1.0),
);
let bg_panel_alt = linear_gradient(
190.0,
linear_color_stop(hsla(170.0 / 360.0, 0.30, 0.13, 1.0), 0.0),
linear_color_stop(hsla(220.0 / 360.0, 0.32, 0.15, 1.0), 1.0),
);
Self {
name: "Aurora",
is_dark: true,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(180.0 / 360.0, 0.40, 0.22, 0.50),
bg_row_active: hsla(160.0 / 360.0, 0.55, 0.30, 0.65),
fg_text: hsla(180.0 / 360.0, 0.20, 0.92, 1.0),
fg_muted: hsla(185.0 / 360.0, 0.18, 0.62, 1.0),
fg_disabled: hsla(185.0 / 360.0, 0.10, 0.40, 1.0),
accent: hsla(150.0 / 360.0, 0.70, 0.55, 1.0),
accent_strong: hsla(160.0 / 360.0, 0.85, 0.65, 1.0),
border: hsla(195.0 / 360.0, 0.25, 0.20, 1.0),
border_strong: hsla(160.0 / 360.0, 0.55, 0.45, 1.0),
marker_palette: vec![
hsla(150.0 / 360.0, 0.75, 0.50, 0.45),
hsla(195.0 / 360.0, 0.70, 0.50, 0.45),
hsla(225.0 / 360.0, 0.70, 0.55, 0.45),
hsla(85.0 / 360.0, 0.65, 0.50, 0.45),
hsla(330.0 / 360.0, 0.65, 0.55, 0.45),
],
}
}
/// **Sunset** — naranjas-rosas-violetas profundos. Cálido, alto contraste
/// con texto claro.
pub fn sunset() -> Self {
let bg_app = linear_gradient(
170.0,
linear_color_stop(hsla(20.0 / 360.0, 0.50, 0.08, 1.0), 0.0),
linear_color_stop(hsla(310.0 / 360.0, 0.45, 0.10, 1.0), 1.0),
);
let bg_panel = linear_gradient(
170.0,
linear_color_stop(hsla(15.0 / 360.0, 0.32, 0.12, 1.0), 0.0),
linear_color_stop(hsla(315.0 / 360.0, 0.30, 0.13, 1.0), 1.0),
);
let bg_panel_alt = linear_gradient(
170.0,
linear_color_stop(hsla(20.0 / 360.0, 0.30, 0.15, 1.0), 0.0),
linear_color_stop(hsla(320.0 / 360.0, 0.28, 0.16, 1.0), 1.0),
);
Self {
name: "Sunset",
is_dark: true,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(25.0 / 360.0, 0.40, 0.25, 0.45),
bg_row_active: hsla(5.0 / 360.0, 0.55, 0.32, 0.65),
fg_text: hsla(30.0 / 360.0, 0.30, 0.92, 1.0),
fg_muted: hsla(25.0 / 360.0, 0.20, 0.62, 1.0),
fg_disabled: hsla(25.0 / 360.0, 0.10, 0.42, 1.0),
accent: hsla(15.0 / 360.0, 0.78, 0.62, 1.0),
accent_strong: hsla(355.0 / 360.0, 0.85, 0.68, 1.0),
border: hsla(15.0 / 360.0, 0.25, 0.25, 1.0),
border_strong: hsla(355.0 / 360.0, 0.55, 0.45, 1.0),
marker_palette: vec![
hsla(15.0 / 360.0, 0.80, 0.55, 0.45),
hsla(310.0 / 360.0, 0.65, 0.55, 0.45),
hsla(45.0 / 360.0, 0.80, 0.55, 0.45),
hsla(285.0 / 360.0, 0.65, 0.60, 0.45),
hsla(355.0 / 360.0, 0.70, 0.55, 0.45),
],
}
}
/// **Flat Dark** — sin gradientes, paleta cool gris-azulado. Para quien
/// prefiere monocromía. Útil para contrastar visualmente con los temas
/// de gradiente.
pub fn flat_dark() -> Self {
let bg_app: Background = hsla(220.0 / 360.0, 0.15, 0.09, 1.0).into();
let bg_panel: Background = hsla(220.0 / 360.0, 0.15, 0.12, 1.0).into();
let bg_panel_alt: Background = hsla(220.0 / 360.0, 0.15, 0.14, 1.0).into();
Self {
name: "Flat Dark",
is_dark: true,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(220.0 / 360.0, 0.20, 0.20, 1.0),
bg_row_active: hsla(220.0 / 360.0, 0.40, 0.30, 1.0),
fg_text: hsla(210.0 / 360.0, 0.20, 0.85, 1.0),
fg_muted: hsla(215.0 / 360.0, 0.15, 0.55, 1.0),
fg_disabled: hsla(215.0 / 360.0, 0.10, 0.40, 1.0),
accent: hsla(210.0 / 360.0, 0.70, 0.55, 1.0),
accent_strong: hsla(210.0 / 360.0, 0.85, 0.65, 1.0),
border: hsla(220.0 / 360.0, 0.15, 0.20, 1.0),
border_strong: hsla(220.0 / 360.0, 0.30, 0.35, 1.0),
marker_palette: vec![
hsla(210.0 / 360.0, 0.65, 0.55, 0.40),
hsla(160.0 / 360.0, 0.55, 0.50, 0.40),
hsla(30.0 / 360.0, 0.75, 0.55, 0.40),
hsla(0.0, 0.55, 0.55, 0.40),
],
}
}
/// **Solarized Light** — preset claro inspirado en la paleta clásica de
/// Schoonover. Sin gradientes (en light un gradiente sutil pasa
/// desapercibido y solo introduce ruido).
pub fn solarized_light() -> Self {
let bg_app: Background = hsla(44.0 / 360.0, 0.87, 0.94, 1.0).into();
let bg_panel: Background = hsla(46.0 / 360.0, 0.42, 0.88, 1.0).into();
let bg_panel_alt: Background = hsla(46.0 / 360.0, 0.42, 0.92, 1.0).into();
Self {
name: "Solarized Light",
is_dark: false,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(46.0 / 360.0, 0.45, 0.80, 0.65),
bg_row_active: hsla(45.0 / 360.0, 0.55, 0.72, 0.85),
fg_text: hsla(196.0 / 360.0, 0.13, 0.30, 1.0),
fg_muted: hsla(196.0 / 360.0, 0.13, 0.45, 1.0),
fg_disabled: hsla(196.0 / 360.0, 0.10, 0.62, 1.0),
accent: hsla(205.0 / 360.0, 0.69, 0.42, 1.0),
accent_strong: hsla(205.0 / 360.0, 0.82, 0.38, 1.0),
border: hsla(46.0 / 360.0, 0.30, 0.78, 1.0),
border_strong: hsla(205.0 / 360.0, 0.40, 0.55, 1.0),
marker_palette: vec![
hsla(205.0 / 360.0, 0.69, 0.42, 0.30),
hsla(175.0 / 360.0, 0.74, 0.32, 0.30),
hsla(45.0 / 360.0, 1.00, 0.36, 0.30),
hsla(331.0 / 360.0, 0.74, 0.42, 0.30),
hsla(18.0 / 360.0, 0.89, 0.40, 0.30),
],
}
}
/// **Print Color** — preview de impresión a color sobre papel.
/// Fondo crema cálido (#f7f4ea-ish), texto y ornamentos en
/// luminancias bajas para que sobrevivan ink-bleed. Sin gradientes
/// (los gradients no imprimen bien) y sin glow.
pub fn print_color() -> Self {
let bg_app: Background = hsla(42.0 / 360.0, 0.30, 0.94, 1.0).into();
let bg_panel: Background = hsla(40.0 / 360.0, 0.25, 0.97, 1.0).into();
let bg_panel_alt: Background = hsla(40.0 / 360.0, 0.20, 0.92, 1.0).into();
Self {
name: "Print Color",
is_dark: false,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(40.0 / 360.0, 0.30, 0.86, 0.70),
bg_row_active: hsla(35.0 / 360.0, 0.45, 0.78, 0.85),
fg_text: hsla(30.0 / 360.0, 0.15, 0.18, 1.0),
fg_muted: hsla(30.0 / 360.0, 0.12, 0.40, 1.0),
fg_disabled: hsla(30.0 / 360.0, 0.08, 0.62, 1.0),
accent: hsla(15.0 / 360.0, 0.70, 0.40, 1.0),
accent_strong: hsla(355.0 / 360.0, 0.78, 0.36, 1.0),
border: hsla(40.0 / 360.0, 0.22, 0.82, 1.0),
border_strong: hsla(30.0 / 360.0, 0.30, 0.55, 1.0),
marker_palette: vec![
hsla(15.0 / 360.0, 0.70, 0.35, 0.30),
hsla(210.0 / 360.0, 0.65, 0.35, 0.30),
hsla(140.0 / 360.0, 0.55, 0.30, 0.30),
hsla(285.0 / 360.0, 0.55, 0.38, 0.30),
hsla(40.0 / 360.0, 0.85, 0.40, 0.30),
],
}
}
/// **Print B&W** — preview de impresión monocromática. Fondo
/// blanco puro, todo en escala de grises. Cualquier slot que
/// dependa de "color" en widgets astrológicos se diferencia por
/// forma o por dash pattern, no por tinte.
pub fn print_bw() -> Self {
let bg_app: Background = hsla(0.0, 0.0, 1.00, 1.0).into();
let bg_panel: Background = hsla(0.0, 0.0, 0.99, 1.0).into();
let bg_panel_alt: Background = hsla(0.0, 0.0, 0.95, 1.0).into();
Self {
name: "Print B&W",
is_dark: false,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(0.0, 0.0, 0.88, 0.85),
bg_row_active: hsla(0.0, 0.0, 0.78, 0.95),
fg_text: hsla(0.0, 0.0, 0.10, 1.0),
fg_muted: hsla(0.0, 0.0, 0.40, 1.0),
fg_disabled: hsla(0.0, 0.0, 0.65, 1.0),
accent: hsla(0.0, 0.0, 0.20, 1.0),
accent_strong: hsla(0.0, 0.0, 0.05, 1.0),
border: hsla(0.0, 0.0, 0.80, 1.0),
border_strong: hsla(0.0, 0.0, 0.40, 1.0),
marker_palette: vec![
hsla(0.0, 0.0, 0.30, 0.35),
hsla(0.0, 0.0, 0.50, 0.35),
hsla(0.0, 0.0, 0.20, 0.35),
hsla(0.0, 0.0, 0.60, 0.35),
],
}
}
/// **High Contrast** — accesibilidad. Negro puro con texto blanco y
/// ornamentos amarillo/verde fuertes. Suficientemente diferente para
/// notar inmediatamente al usar el switcher.
pub fn high_contrast() -> Self {
let bg_app: Background = hsla(0.0, 0.0, 0.0, 1.0).into();
let bg_panel: Background = hsla(0.0, 0.0, 0.05, 1.0).into();
let bg_panel_alt: Background = hsla(0.0, 0.0, 0.10, 1.0).into();
Self {
name: "High Contrast",
is_dark: true,
bg_app,
bg_panel,
bg_panel_alt,
bg_row_hover: hsla(60.0 / 360.0, 1.00, 0.50, 0.35),
bg_row_active: hsla(120.0 / 360.0, 1.00, 0.40, 0.55),
fg_text: hsla(0.0, 0.0, 1.0, 1.0),
fg_muted: hsla(0.0, 0.0, 0.75, 1.0),
fg_disabled: hsla(0.0, 0.0, 0.50, 1.0),
accent: hsla(60.0 / 360.0, 1.00, 0.60, 1.0),
accent_strong: hsla(60.0 / 360.0, 1.00, 0.75, 1.0),
border: hsla(0.0, 0.0, 0.30, 1.0),
border_strong: hsla(60.0 / 360.0, 1.00, 0.60, 1.0),
marker_palette: vec![
hsla(60.0 / 360.0, 1.00, 0.55, 0.50),
hsla(120.0 / 360.0, 1.00, 0.50, 0.50),
hsla(180.0 / 360.0, 1.00, 0.55, 0.50),
hsla(0.0, 1.00, 0.60, 0.50),
hsla(300.0 / 360.0, 1.00, 0.65, 0.50),
],
}
}
}
// ============================================================================
// Persistencia de la preferencia de theme
// ============================================================================
use std::path::{Path, PathBuf};
const CONFIG_SUBDIR: &str = "nahual";
const CONFIG_FILE: &str = "theme";
/// Path al archivo donde se persiste la preferencia de theme.
///
/// Convención XDG: `$XDG_CONFIG_HOME/nahual/theme` si está set;
/// sino `$HOME/.config/nahual/theme`. `None` si ni `XDG_CONFIG_HOME`
/// ni `HOME` están definidos (típicamente en sandboxes / CI).
pub fn config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.filter(|s| !s.is_empty())
.map(|h| PathBuf::from(h).join(".config"))
})?;
Some(base.join(CONFIG_SUBDIR).join(CONFIG_FILE))
}
/// Lee el theme persistido. `None` si: no hay config dir, file no
/// existe, lectura falla, o el name guardado no matchea ningún
/// preset (ej. tema renombrado entre versiones).
pub fn load_persisted() -> Option<Theme> {
let path = config_path()?;
load_from_path(&path)
}
/// Persiste el name del theme al config file. Crea el dir parent si
/// no existe. Devuelve el `io::Error` para que tests puedan
/// asertar; los call sites de producción pueden ignorarlo
/// (best-effort persistence).
pub fn persist(theme: &Theme) -> std::io::Result<()> {
let path = config_path().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"no se pudo determinar config dir (HOME/XDG_CONFIG_HOME no set)",
)
})?;
persist_to_path(theme, &path)
}
/// Variante de [`load_persisted`] que toma un path explícito. Útil
/// para tests + para apps que quieren su propio path
/// (ej. multi-user single-machine).
pub fn load_from_path(path: &Path) -> Option<Theme> {
let raw = std::fs::read_to_string(path).ok()?;
let name = raw.trim();
Theme::by_name(name)
}
/// Variante de [`persist`] que toma un path explícito.
pub fn persist_to_path(theme: &Theme, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, theme.name)
}
#[cfg(test)]
mod persistence_tests {
use super::*;
fn unique_path(label: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"nahual-theme-test-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
label
));
p
}
#[test]
fn persist_then_load_round_trip() {
let path = unique_path("round-trip");
let theme = Theme::aurora();
persist_to_path(&theme, &path).unwrap();
let loaded = load_from_path(&path).expect("load");
assert_eq!(loaded.name, "Aurora");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(path.parent().unwrap());
}
#[test]
fn load_from_missing_file_returns_none() {
let path = unique_path("missing");
// path NO existe.
assert!(load_from_path(&path).is_none());
}
#[test]
fn load_from_unknown_name_returns_none() {
let path = unique_path("unknown");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "DefinitelyNotARealTheme").unwrap();
assert!(load_from_path(&path).is_none());
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(path.parent().unwrap());
}
#[test]
fn persist_creates_parent_dir_if_missing() {
let path = unique_path("nested-create");
// Aseguramos que el parent NO existe antes.
let _ = std::fs::remove_dir_all(path.parent().unwrap());
persist_to_path(&Theme::sunset(), &path).unwrap();
assert!(path.exists());
let loaded = load_from_path(&path).unwrap();
assert_eq!(loaded.name, "Sunset");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(path.parent().unwrap());
}
#[test]
fn config_path_uses_xdg_config_home_when_set() {
// Snapshot del env, mutación local, restauración.
let prev = std::env::var("XDG_CONFIG_HOME").ok();
// SAFETY: tests del crate single-thread por default; este
// env mutation no impacta otros tests del mismo proceso.
unsafe {
std::env::set_var("XDG_CONFIG_HOME", "/custom/xdg");
}
let p = config_path().unwrap();
unsafe {
match prev {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert_eq!(p, PathBuf::from("/custom/xdg/nahual/theme"));
}
}