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:
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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(¤t, &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(¤t, &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(¤t, &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(¤t, &proposed).is_empty());
|
||||
|
||||
let current_str = json!({"qty": "100"});
|
||||
let proposed_int = map(&[("qty", json!(100_i64))]);
|
||||
assert_eq!(compute_field_delta(¤t_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(¤t, &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(¤t, &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(¤t, &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(¤t, &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, ¶ms)?;
|
||||
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 }
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user