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

Reorganización física de crates/:
- core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/
- shared/ (3 crates) se redistribuye en protocol/ e init/
- lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/

Renames de proyectos:
- shipote → shuma (runtime de sandboxes)
- nouser → akasha (explorador de Mónadas)
- yahweh → nahual (motor GPUI, antes ui_engine/)
- lapaloma → pineal (data-viz agnóstica)

Fraccionamiento UI → core agnóstico:
- vista-core (DeckState + snap, 175 LOC, 5 tests verdes)
- barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes)
- vista-web y barra-web ahora son thin DOM bindings

Documentación nueva:
- 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat
  + 10 módulos + apps/
- docs/STATUS.md con cifras reales por proyecto
- docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas)
- CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets)

Automatización:
- scripts/reorg.py — script idempotente que: git mv directorios, renombra
  package names, recomputa path = refs, reescribe imports rust, actualiza
  workspace Cargo.toml. Soporta --dry-run.
- scripts/split-changelog.py — particiona CHANGELOG por componente.

Validación:
- cargo check --workspace pasa (124 crates + 2 nuevos cores).
- 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-19 14:48:34 +00:00
parent 86fb6ae20b
commit 550c98f275
375 changed files with 8512 additions and 7155 deletions
@@ -0,0 +1,279 @@
//! `MetaBackend` trait — la frontera entre el widget metainterfaz
//! (nahual) y la implementación concreta de persistencia/ejecución
//! (nakui-core, Surreal, mocks para tests).
//!
//! El widget consume este trait; el binario lo implementa con su
//! stack particular. Esto es lo que hace que el widget sea reusable.
//!
//! Convenciones documentadas en el doc del trait abajo.
use std::collections::BTreeMap;
use serde_json::Value;
use uuid::Uuid;
/// Resultado uniforme de una operación de escritura del backend.
///
/// La UI lo usa para componer el toast: `id` para mostrar el
/// short_uuid, `changed` para diferenciar "actualizado X (3 campos)"
/// vs "sin cambios", `post_status` para concatenar mensajes
/// emitidos por hooks internos del backend (ej. "auto-compact:
/// snapshot @ seq 49") sin que la UI tenga que conocer el detalle.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WriteOutcome {
/// Id del record afectado. `Some` para seed/update/delete;
/// `None` para morphism cuando afecta múltiples records.
pub id: Option<Uuid>,
/// Cantidad de cambios efectivos. `0` = no-op (edit que no
/// modificó ningún campo, etc.).
pub changed: usize,
/// Mensaje de status opcional para concatenar al toast del op
/// original con el separator estándar.
pub post_status: Option<String>,
}
impl WriteOutcome {
/// Constructor para no-op writes (edits sin cambios).
pub fn no_change(id: Uuid) -> Self {
Self {
id: Some(id),
changed: 0,
post_status: None,
}
}
}
/// Backend que un widget de metainterfaz usa para leer y mutar
/// records. Decoupla el widget (nahual) de la implementación
/// concreta (nakui-core, Surreal, mock para tests).
///
/// # Convención sobre ids
///
/// `Uuid` canónico. Backends que internamente usan otros tipos
/// deben mapear via Uuid (hash determinista, wrapper, lo que sirva).
/// Esto evita generic associated types que complicarían el dispatch
/// en `cx.listener` de GPUI.
///
/// # Convención sobre validación
///
/// El backend ES la fuente de verdad sobre invariantes (KCL/Nickel
/// post-checks, conservación, etc.). El widget pre-valida shape
/// (nahual-meta-runtime: `parse_field_value`, `validate_entity_refs`)
/// pero el backend puede rebotar con `Err(...)` si su validación
/// adicional falla — el widget muestra el error al usuario.
///
/// # Convención sobre threading
///
/// `'static` (no `Send + Sync`): el widget vive en `Entity<MetaApp<B>>`
/// que requiere `'static`, pero los handlers son single-threaded en
/// el main UI thread de GPUI. Si en el futuro un backend necesita
/// `cx.spawn`, agregamos los marker traits.
///
/// # Convención sobre delta computation
///
/// El widget pre-computa `set` y `clear` con
/// [`crate::delta::compute_field_delta`] +
/// [`crate::delta::compute_clear_fields`] *antes* de llamar a
/// [`MetaBackend::update`]. El backend no recomputa: si recibe ambos
/// vacíos devuelve `changed = 0` sin escribir nada. Esto evita
/// double-roundtrip al store por el mismo dato.
pub trait MetaBackend: 'static {
/// Snapshot ordenado de records de una entity.
/// Orden estable (lexicográfico por id) para UI determinista.
/// Vacío si no hay records.
fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)>;
/// Lee un record por id. `None` si no existe.
fn load_record(&self, entity: &str, id: Uuid) -> Option<Value>;
/// Crea un record nuevo. El backend asigna el `Uuid`
/// (devuelve en `WriteOutcome.id`). `changed = 1` siempre.
fn seed(
&mut self,
entity: &str,
data: serde_json::Map<String, Value>,
) -> Result<WriteOutcome, String>;
/// Edita un record existente. Aplica `set` (overrides) y
/// `clear` (key removal). `changed = set.len() + clear.len()`.
/// Si ambos están vacíos (no-op edit), devuelve
/// `WriteOutcome::no_change(id)` sin error y sin escribir al log.
fn update(
&mut self,
entity: &str,
id: Uuid,
set: serde_json::Map<String, Value>,
clear: Vec<String>,
) -> Result<WriteOutcome, String>;
/// Borra un record. `changed = 1` si existía, error si no.
fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String>;
/// Ejecuta un morphism declarado por un módulo. El backend
/// resuelve la implementación, valida, computa ops, las aplica.
/// `changed = N ops aplicadas`.
///
/// `module_id` ubica al módulo (el trait no asume estructura del
/// manifest — el backend lo resuelve internamente).
fn morphism(
&mut self,
module_id: &str,
name: &str,
inputs: BTreeMap<String, Uuid>,
params: Value,
) -> Result<WriteOutcome, String>;
}
#[cfg(test)]
mod tests {
//! Tests del trait via [`crate::testing::MockBackend`]. Verifican
//! el contrato genérico (object-safety, semantica de seed/update/
//! delete) sin atar a un backend concreto. Los tests del mock en
//! sí (constructores, with_morphism, etc.) viven en
//! `crate::testing::tests`.
use super::*;
use crate::testing::MockBackend;
use serde_json::json;
fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}
#[test]
fn seed_then_load_round_trip() {
let mut b = MockBackend::new();
let out = b
.seed("Customer", map_of(&[("name", json!("Acme"))]))
.unwrap();
let id = out.id.expect("seed devuelve id");
assert_eq!(out.changed, 1);
assert!(out.post_status.is_none());
let rec = b.load_record("Customer", id).unwrap();
assert_eq!(rec.get("name"), Some(&json!("Acme")));
}
#[test]
fn list_records_filters_by_entity_and_orders_stably() {
let mut b = MockBackend::new();
let _ = b.seed("A", map_of(&[("k", json!(1))])).unwrap();
let _ = b.seed("B", map_of(&[("k", json!(2))])).unwrap();
let _ = b.seed("A", map_of(&[("k", json!(3))])).unwrap();
let a = b.list_records("A");
assert_eq!(a.len(), 2);
let b_only = b.list_records("B");
assert_eq!(b_only.len(), 1);
let none = b.list_records("Missing");
assert!(none.is_empty());
// Orden estable: re-llamadas devuelven mismo orden.
let a_again = b.list_records("A");
assert_eq!(
a.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
a_again.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
);
}
#[test]
fn update_with_set_changes_field() {
let mut b = MockBackend::new();
let id = b
.seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))]))
.unwrap()
.id
.unwrap();
let out = b
.update(
"Customer",
id,
map_of(&[("name", json!("Acme S.A."))]),
vec![],
)
.unwrap();
assert_eq!(out.changed, 1);
assert_eq!(out.id, Some(id));
let rec = b.load_record("Customer", id).unwrap();
assert_eq!(rec.get("name"), Some(&json!("Acme S.A.")));
assert_eq!(rec.get("notes"), Some(&json!("x")), "notes intacto");
}
#[test]
fn update_with_clear_removes_key() {
let mut b = MockBackend::new();
let id = b
.seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))]))
.unwrap()
.id
.unwrap();
let out = b
.update("Customer", id, serde_json::Map::new(), vec!["notes".into()])
.unwrap();
assert_eq!(out.changed, 1);
let rec = b.load_record("Customer", id).unwrap();
assert_eq!(rec.get("name"), Some(&json!("Acme")));
assert!(rec.get("notes").is_none(), "notes debería estar borrado");
}
#[test]
fn update_with_empty_set_and_clear_returns_no_change() {
let mut b = MockBackend::new();
let id = b
.seed("Customer", map_of(&[("name", json!("Acme"))]))
.unwrap()
.id
.unwrap();
let out = b
.update("Customer", id, serde_json::Map::new(), vec![])
.unwrap();
assert_eq!(out, WriteOutcome::no_change(id));
}
#[test]
fn update_on_missing_record_errors() {
let mut b = MockBackend::new();
let id = Uuid::new_v4();
let err = b
.update("Customer", id, map_of(&[("x", json!(1))]), vec![])
.unwrap_err();
assert!(err.contains("not found"));
}
#[test]
fn delete_removes_and_then_load_returns_none() {
let mut b = MockBackend::new();
let id = b
.seed("Customer", map_of(&[("name", json!("Acme"))]))
.unwrap()
.id
.unwrap();
let out = b.delete("Customer", id).unwrap();
assert_eq!(out.changed, 1);
assert_eq!(out.id, Some(id));
assert!(b.load_record("Customer", id).is_none());
}
#[test]
fn delete_on_missing_record_errors() {
let mut b = MockBackend::new();
let id = Uuid::new_v4();
assert!(b.delete("Customer", id).is_err());
}
/// Sanity: el trait acepta llamadas via `&mut dyn MetaBackend`
/// (object-safety). Esto permite que el widget tenga
/// `Box<dyn MetaBackend>` si el use case requiere borrado de
/// tipo (vs. el path normal con `MetaApp<B: MetaBackend>`).
#[test]
fn trait_is_object_safe() {
let mut b: Box<dyn MetaBackend> = Box::new(MockBackend::new());
let _ = b.seed("X", map_of(&[("k", json!(1))])).unwrap();
assert_eq!(b.list_records("X").len(), 1);
}
}
@@ -0,0 +1,134 @@
//! Cálculo del delta entre el record actual y la propuesta del form.
//!
//! Sirve a un runtime de edición para emitir SOLO los Set/Clear que
//! cambian algo: log + apply minimales, no-op edits = 0 entries.
use serde_json::Value;
/// Calcula el delta entre el record actual y los valores propuestos
/// del form. Devuelve un Map con sólo los campos cuyo valor difiere.
///
/// Comparación: igualdad estructural sobre `serde_json::Value`. Un
/// `current=Value::Null` (record no encontrado) hace que todos los
/// campos del `proposed` sean considerados nuevos. Un campo del
/// proposed que coincide con el del current se omite. Campos que
/// están en current pero NO en proposed se preservan tal cual (el
/// edit no los toca; ver [`compute_clear_fields`] para borrar
/// explícito desde un input vacío).
pub fn compute_field_delta(
current: &Value,
proposed: &serde_json::Map<String, Value>,
) -> serde_json::Map<String, Value> {
proposed
.iter()
.filter(|(field, value)| current.get(field.as_str()) != Some(*value))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
/// Decide cuáles fields del `to_clear` candidate list ameritan
/// realmente un `FieldOp::Clear`: sólo los que existen en el current
/// con un valor non-null. Para fields ausentes o ya null, Clear es
/// no-op semántico (el post-state es el mismo) y dropearlos
/// preserva la propiedad "1 op = 1 cambio efectivo" del log.
///
/// Preserva el orden del input para que el log entry sea estable.
pub fn compute_clear_fields(current: &Value, to_clear: &[String]) -> Vec<String> {
to_clear
.iter()
.filter(|f| match current.get(f.as_str()) {
None | Some(Value::Null) => false,
Some(_) => true,
})
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn map(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}
#[test]
fn delta_empty_when_all_fields_match() {
let current = json!({"name": "Acme", "saldo": 100_i64, "currency": "USD"});
let proposed = map(&[
("name", json!("Acme")),
("saldo", json!(100_i64)),
("currency", json!("USD")),
]);
assert!(compute_field_delta(&current, &proposed).is_empty());
}
#[test]
fn delta_includes_only_changed_field() {
let current = json!({"name": "Acme", "saldo": 100_i64});
let proposed = map(&[("name", json!("Acme")), ("saldo", json!(200_i64))]);
let d = compute_field_delta(&current, &proposed);
assert_eq!(d.len(), 1);
assert_eq!(d.get("saldo"), Some(&json!(200_i64)));
}
#[test]
fn delta_treats_missing_record_as_all_new() {
let current = Value::Null;
let proposed = map(&[("name", json!("Acme")), ("saldo", json!(0_i64))]);
assert_eq!(compute_field_delta(&current, &proposed).len(), 2);
}
#[test]
fn delta_distinguishes_int_from_string_repr() {
let current = json!({"qty": 100_i64});
let proposed = map(&[("qty", json!(100_i64))]);
assert!(compute_field_delta(&current, &proposed).is_empty());
let current_str = json!({"qty": "100"});
let proposed_int = map(&[("qty", json!(100_i64))]);
assert_eq!(compute_field_delta(&current_str, &proposed_int).len(), 1);
}
#[test]
fn delta_skips_fields_absent_from_proposed() {
let current = json!({"name": "Acme", "saldo": 100_i64, "extra": "x"});
let proposed = map(&[("name", json!("Acme")), ("saldo", json!(150_i64))]);
let d = compute_field_delta(&current, &proposed);
assert_eq!(d.len(), 1);
assert!(!d.contains_key("extra"));
}
#[test]
fn clear_fields_skips_absent_and_null() {
let current = json!({"name": "Acme", "notes": "lorem", "tag": null});
let to_clear = vec![
"name".into(),
"notes".into(),
"tag".into(),
"missing".into(),
];
assert_eq!(
compute_clear_fields(&current, &to_clear),
vec!["name".to_string(), "notes".to_string()]
);
}
#[test]
fn clear_fields_preserves_input_order() {
let current = json!({"a": 1, "b": 2, "c": 3});
let to_clear = vec!["c".into(), "a".into(), "b".into()];
assert_eq!(
compute_clear_fields(&current, &to_clear),
vec!["c", "a", "b"]
);
}
#[test]
fn clear_fields_empty_when_current_is_null() {
let current = Value::Null;
let to_clear = vec!["name".into()];
assert!(compute_clear_fields(&current, &to_clear).is_empty());
}
}
@@ -0,0 +1,185 @@
//! Helpers de presentación humana para records y values.
//!
//! Sin GPUI: devuelven `String`s. El widget renderer los wrap-ea
//! en `div().child(...)` o equivalente.
use serde_json::Value;
use uuid::Uuid;
/// Etiqueta humana para representar un record en el selector de
/// EntityRef. Heurística: prefiere campos comunes en este orden:
/// `name`, `label`, `title`, `sku`, `sku_id`. Fallback al UUID corto.
pub fn human_label_for_record(value: &Value, id: &Uuid) -> String {
for key in ["name", "label", "title", "sku", "sku_id"] {
if let Some(v) = value.get(key).and_then(Value::as_str) {
if !v.is_empty() {
return format!("{} ({})", v, short_uuid(id));
}
}
}
short_uuid(id)
}
/// Render legible de un `Value` arbitrario para mostrar en una celda
/// de lista. Strings van pelados; bools como ✓/✗; el resto via
/// `Display`.
pub fn render_value(v: Option<&Value>) -> String {
match v {
None | Some(Value::Null) => String::new(),
Some(Value::String(s)) => s.clone(),
Some(Value::Bool(b)) => if *b { "" } else { "" }.to_string(),
Some(Value::Number(n)) => n.to_string(),
Some(other) => other.to_string(),
}
}
/// Conversión inversa a `parse_field_value`: del JSON al texto raw
/// que un input puede tomar y volver a parsearse igual al submit.
/// Usado para pre-llenar inputs en modo edit.
pub fn value_to_input_text(v: &Value) -> String {
match v {
Value::Null => String::new(),
Value::String(s) => s.clone(),
Value::Bool(b) => if *b { "true" } else { "false" }.to_string(),
Value::Number(n) => n.to_string(),
other => other.to_string(),
}
}
/// Primeros 8 chars del UUID en forma canónica. Útil para logs y UI
/// donde el UUID full es ruido visual.
pub fn short_uuid(id: &Uuid) -> String {
id.to_string().chars().take(8).collect()
}
/// Hex string de los primeros 4 bytes de un hash SHA-256 (8
/// caracteres). Útil para mostrar bundle/schema hashes en UI sin
/// quemar pantalla con los 64 chars completos.
pub fn short_hash(h: &[u8; 32]) -> String {
use std::fmt::Write;
let mut s = String::with_capacity(8);
for b in h.iter().take(4) {
let _ = write!(s, "{:02x}", b);
}
s
}
/// Renderea un `serde_json::Value` en una sola línea, truncado a
/// `max` caracteres con `...` al final si excede. Para preview en
/// timelines/cards/listas — NO para edición.
///
/// `max` es un upper-bound aproximado: el resultado nunca excede
/// `max` chars, pero puede ser más corto si el value es chico.
pub fn preview_value(v: &Value, max: usize) -> String {
let s = v.to_string();
if s.chars().count() <= max {
s
} else if max < 3 {
s.chars().take(max).collect()
} else {
let truncated: String = s.chars().take(max - 3).collect();
format!("{truncated}...")
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn human_label_prefers_name_over_id() {
let id = Uuid::new_v4();
let v = json!({"name": "Acme S.A.", "email": "x@y.z"});
let label = human_label_for_record(&v, &id);
assert!(label.starts_with("Acme S.A."));
assert!(label.contains(&short_uuid(&id)));
}
#[test]
fn human_label_falls_back_through_label_title_sku() {
let id = Uuid::new_v4();
let only_label = json!({"label": "X"});
assert!(human_label_for_record(&only_label, &id).starts_with("X "));
let only_title = json!({"title": "Y"});
assert!(human_label_for_record(&only_title, &id).starts_with("Y "));
let only_sku = json!({"sku": "Z"});
assert!(human_label_for_record(&only_sku, &id).starts_with("Z "));
let only_sku_id = json!({"sku_id": "W"});
assert!(human_label_for_record(&only_sku_id, &id).starts_with("W "));
}
#[test]
fn human_label_falls_back_to_short_uuid_when_no_keys_match() {
let id = Uuid::new_v4();
let v = json!({"random": "field"});
assert_eq!(human_label_for_record(&v, &id), short_uuid(&id));
}
#[test]
fn render_value_handles_basic_kinds() {
assert_eq!(render_value(None), "");
assert_eq!(render_value(Some(&Value::Null)), "");
assert_eq!(render_value(Some(&json!("hola"))), "hola");
assert_eq!(render_value(Some(&json!(true))), "");
assert_eq!(render_value(Some(&json!(false))), "");
assert_eq!(render_value(Some(&json!(42))), "42");
}
#[test]
fn value_to_input_text_round_trip_with_strings_and_numbers() {
assert_eq!(value_to_input_text(&Value::Null), "");
assert_eq!(value_to_input_text(&json!("x")), "x");
assert_eq!(value_to_input_text(&json!(true)), "true");
assert_eq!(value_to_input_text(&json!(false)), "false");
assert_eq!(value_to_input_text(&json!(42)), "42");
}
#[test]
fn short_hash_takes_first_4_bytes_hex() {
let mut h = [0u8; 32];
h[0] = 0xaa;
h[1] = 0xbb;
h[2] = 0xcc;
h[3] = 0xdd;
assert_eq!(short_hash(&h), "aabbccdd");
}
#[test]
fn short_hash_zeros() {
let h = [0u8; 32];
assert_eq!(short_hash(&h), "00000000");
}
#[test]
fn preview_value_keeps_short_strings_intact() {
let v = json!({"a": 1});
assert_eq!(preview_value(&v, 30), "{\"a\":1}");
}
#[test]
fn preview_value_truncates_long_strings_with_ellipsis() {
let v = json!({"a": "x".repeat(200)});
let p = preview_value(&v, 30);
assert!(p.chars().count() <= 30);
assert!(p.ends_with("..."));
}
#[test]
fn preview_value_handles_max_smaller_than_ellipsis() {
// Edge case: max < 3 (no espacio para "..."). Devuelve
// los primeros `max` chars sin sufijo, sin panic.
let v = json!("xxxxxxxxxxxxxxxx");
let p = preview_value(&v, 2);
assert!(p.chars().count() <= 2);
}
#[test]
fn short_uuid_returns_first_8_chars() {
let id = Uuid::parse_str("01ARZ3ND-EKTS-V4RR-FFQ6-9G5FAV000000").ok();
// Si el parse falla, usamos uno fresco — el invariant es la
// longitud, no el contenido.
let id = id.unwrap_or_else(Uuid::new_v4);
assert_eq!(short_uuid(&id).len(), 8);
}
}
@@ -0,0 +1,39 @@
//! `nahual-meta-runtime` — helpers puros para runtimes metainterfaz.
//!
//! Consume [`nahual_meta_schema`] (los tipos `Module`/`View`/`FieldSpec`/
//! `FieldKind`/`Action`/etc.) y aporta funciones puras que cualquier
//! widget renderer o backend ejecutor necesita:
//!
//! - **Parse**: convertir el texto de un input a `serde_json::Value`
//! tipado según el `FieldKind` del spec.
//! - **Delta**: calcular qué cambió entre el estado actual y la
//! propuesta del form (Set + Clear).
//! - **Validation**: verificar que cada EntityRef apunte a un record
//! que existe (toma cierre `load`, no trait).
//! - **Format**: presentación humana de records (label heurístico,
//! render de values, UUID corto, round-trip a input text).
//!
//! Sin GPUI, sin acoplamiento a un backend específico. Cualquier
//! implementación de store/log puede consumirlos.
//!
//! El widget render (form/list/modal) vive en otro crate nahual
//! que esto consume; el runtime concreto (`nakui-ui`) implementa la
//! conexión a su event-log/executor y compone ambos.
#![forbid(unsafe_code)]
pub mod backend;
pub mod delta;
pub mod format;
pub mod parse;
pub mod refs;
pub mod testing;
pub use backend::{MetaBackend, WriteOutcome};
pub use delta::{compute_clear_fields, compute_field_delta};
pub use format::{
human_label_for_record, preview_value, render_value, short_hash, short_uuid,
value_to_input_text,
};
pub use parse::{infer_param_value, parse_field_value, resolve_param_value};
pub use refs::validate_entity_refs;
@@ -0,0 +1,231 @@
//! Parseo de inputs del form a `serde_json::Value` tipado.
use serde_json::{json, Value};
use uuid::Uuid;
use nahual_meta_schema::{FieldKind, FieldSpec};
/// Convierte el texto raw de un input al `Value` tipado según el
/// `kind` del spec.
///
/// - `Text` / `Multiline` / `Date` → string passthrough.
/// - `EntityRef` → string del UUID **trimmed**, validado como UUID
/// parseable. Falla con mensaje claro si no parsea.
/// - `Boolean` → variantes comunes (`true/yes/1/on/y` y `false/no/0/off/n`).
/// - `Number` → i64 si parsea, sino f64.
pub fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
match kind {
FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
// EntityRef se almacena como string del UUID seleccionado.
// El selector clickable garantiza UUIDs válidos en happy
// path; este check protege paste manual o garbage tipeado.
FieldKind::EntityRef => {
let trimmed = raw.trim();
Uuid::parse_str(trimmed).map_err(|_| {
format!("'{raw}' no es UUID válido (usá el selector de records)")
})?;
Ok(json!(trimmed))
}
FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
"true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
"" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
other => Err(format!("'{other}' no es booleano")),
},
FieldKind::Number => {
if let Ok(i) = raw.parse::<i64>() {
Ok(json!(i))
} else if let Ok(f) = raw.parse::<f64>() {
Ok(json!(f))
} else {
Err(format!("'{raw}' no es número"))
}
}
}
}
/// Resuelve un param de morphism a su `Value` según el `FieldSpec`
/// del form. **Strict path**: si hay spec, valida `required` y parsea
/// con el `kind` declarado (ej. Boolean rebota con "abc" antes de
/// llegar al morphism). **Fallback path**: si no hay spec (param
/// declarado en `Action::Morphism.params` que no aparece en
/// `form.fields`), usa la heurística [`infer_param_value`] para no
/// quedar atado a un schema mal-formado.
///
/// Errores tienen el label legible del spec, así el toast de la UI
/// es interpretable.
pub fn resolve_param_value(
field_name: &str,
raw: &str,
spec: Option<&FieldSpec>,
) -> Result<Value, String> {
let Some(s) = spec else {
return Ok(infer_param_value(raw));
};
let label = if s.label.is_empty() { field_name } else { &s.label };
if s.required && raw.trim().is_empty() {
return Err(format!("param '{label}' es obligatorio y está vacío"));
}
if raw.is_empty() && !s.required {
return Ok(Value::Null);
}
parse_field_value(s.kind, raw).map_err(|e| format!("param '{label}': {e}"))
}
/// Inferencia de tipo para values pasados como `params` a un
/// morphism. Usada como fallback en [`resolve_param_value`] cuando el
/// param declarado en `Action::Morphism.params` no aparece en los
/// `form.fields` (módulo mal-formado).
///
/// Heurística simple: int → i64, float → f64, "true"/"false" → bool,
/// resto → string.
pub fn infer_param_value(raw: &str) -> Value {
if raw.is_empty() {
return Value::Null;
}
if let Ok(i) = raw.parse::<i64>() {
return json!(i);
}
if let Ok(f) = raw.parse::<f64>() {
return json!(f);
}
match raw {
"true" => return json!(true),
"false" => return json!(false),
_ => {}
}
json!(raw)
}
#[cfg(test)]
mod tests {
use super::*;
use nahual_meta_schema::FieldSpec;
fn spec(name: &str, kind: FieldKind, required: bool) -> FieldSpec {
FieldSpec {
name: name.into(),
label: name.into(),
kind,
default: None,
required,
help: None,
ref_entity: None,
}
}
#[test]
fn infer_handles_basic_types() {
assert_eq!(infer_param_value(""), Value::Null);
assert_eq!(infer_param_value("42"), json!(42));
assert_eq!(infer_param_value("3.14"), json!(3.14));
assert_eq!(infer_param_value("true"), json!(true));
assert_eq!(infer_param_value("false"), json!(false));
assert_eq!(infer_param_value("hola"), json!("hola"));
}
#[test]
fn parse_text_passthrough() {
let v = parse_field_value(FieldKind::Text, "hola").unwrap();
assert_eq!(v, json!("hola"));
}
#[test]
fn parse_number_i64_or_f64() {
assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42));
assert_eq!(
parse_field_value(FieldKind::Number, "3.14").unwrap(),
json!(3.14)
);
assert!(parse_field_value(FieldKind::Number, "abc").is_err());
}
#[test]
fn parse_boolean_recognizes_variants() {
for s in ["true", "yes", "1", "on", "y"] {
assert_eq!(parse_field_value(FieldKind::Boolean, s).unwrap(), json!(true));
}
for s in ["false", "no", "0", "off", "n", ""] {
assert_eq!(
parse_field_value(FieldKind::Boolean, s).unwrap(),
json!(false)
);
}
assert!(parse_field_value(FieldKind::Boolean, "abc").is_err());
}
#[test]
fn parse_entity_ref_accepts_valid_uuid() {
let id = Uuid::new_v4();
let v = parse_field_value(FieldKind::EntityRef, &id.to_string()).unwrap();
assert_eq!(v, json!(id.to_string()));
}
#[test]
fn parse_entity_ref_trims_whitespace() {
let id = Uuid::new_v4();
let padded = format!(" {id}\n");
let v = parse_field_value(FieldKind::EntityRef, &padded).unwrap();
assert_eq!(v, json!(id.to_string()));
}
#[test]
fn parse_entity_ref_rejects_non_uuid() {
let err = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap_err();
assert!(err.contains("'abc-123'"));
assert!(err.contains("UUID") || err.contains("uuid"));
}
#[test]
fn parse_entity_ref_rejects_empty_string() {
let err = parse_field_value(FieldKind::EntityRef, "").unwrap_err();
assert!(err.contains("UUID"));
}
#[test]
fn resolve_param_strict_number_parses_i64() {
let s = spec("qty", FieldKind::Number, true);
let v = resolve_param_value("qty", "42", Some(&s)).unwrap();
assert_eq!(v, json!(42));
}
#[test]
fn resolve_param_strict_boolean_rejects_non_boolean() {
let s = spec("active", FieldKind::Boolean, true);
let err = resolve_param_value("active", "abc", Some(&s)).unwrap_err();
assert!(err.contains("active"));
}
#[test]
fn resolve_param_required_empty_rejected() {
let s = spec("name", FieldKind::Text, true);
let err = resolve_param_value("name", " ", Some(&s)).unwrap_err();
assert!(err.contains("obligatorio"));
}
#[test]
fn resolve_param_optional_empty_returns_null() {
let s = spec("notes", FieldKind::Text, false);
let v = resolve_param_value("notes", "", Some(&s)).unwrap();
assert_eq!(v, Value::Null);
}
#[test]
fn resolve_param_no_spec_falls_back_to_infer() {
let v = resolve_param_value("foo", "42", None).unwrap();
assert_eq!(v, json!(42));
let v = resolve_param_value("foo", "true", None).unwrap();
assert_eq!(v, json!(true));
let v = resolve_param_value("foo", "x", None).unwrap();
assert_eq!(v, json!("x"));
}
#[test]
fn resolve_param_strict_entity_ref_propagates_error() {
let s = spec("stock_ref", FieldKind::EntityRef, true);
let err = resolve_param_value("stock_ref", "not-a-uuid", Some(&s)).unwrap_err();
assert!(err.contains("stock_ref"));
assert!(err.contains("UUID"));
}
}
@@ -0,0 +1,108 @@
//! Validación cross-field de EntityRefs contra el store actual.
//!
//! Decoupling: en vez de un `trait Store` que ate este crate a un
//! backend específico, tomamos un cierre `load: Fn(&str, Uuid) ->
//! Option<Value>`. El caller (nakui-ui o cualquier otro runtime)
//! puede pasarlo trivialmente sobre cualquier store (MemoryStore,
//! SurrealStore, mock, ...).
use serde_json::Value;
use uuid::Uuid;
use crate::format::short_uuid;
/// Valida que cada UUID en `refs` apunte a un record que realmente
/// existe en el store bajo la entity esperada. Devuelve el primer
/// error encontrado (fail-fast).
///
/// `refs` es una lista de `(label, target_entity, uuid)`. El label
/// va al error message, así que conviene que sea legible (ej:
/// `FieldSpec.label` en lugar de `FieldSpec.name`).
///
/// `load` es el cierre que el caller usa para mirar el store —
/// típicamente `|e, id| store.load(e, id)`.
pub fn validate_entity_refs<F>(load: F, refs: &[(String, String, Uuid)]) -> Result<(), String>
where
F: Fn(&str, Uuid) -> Option<Value>,
{
for (label, target, id) in refs {
if load(target, *id).is_none() {
return Err(format!(
"campo '{label}': record {} de '{target}' no existe en el store",
short_uuid(id)
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
/// "Mock store" minimalista para tests: HashMap por (entity, uuid).
fn mk_load(records: HashMap<(String, Uuid), Value>) -> impl Fn(&str, Uuid) -> Option<Value> {
move |e, id| records.get(&(e.to_string(), id)).cloned()
}
#[test]
fn passes_when_all_records_exist() {
let stock = Uuid::new_v4();
let caja = Uuid::new_v4();
let mut records = HashMap::new();
records.insert(("Stock".into(), stock), json!({"sku_id": "abc"}));
records.insert(("Caja".into(), caja), json!({"name": "Principal"}));
let load = mk_load(records);
let refs = vec![
("Stock".into(), "Stock".into(), stock),
("Caja".into(), "Caja".into(), caja),
];
assert!(validate_entity_refs(load, &refs).is_ok());
}
#[test]
fn fails_on_first_missing() {
let stock = Uuid::new_v4();
let mut records = HashMap::new();
records.insert(("Stock".into(), stock), json!({"sku_id": "abc"}));
let load = mk_load(records);
let missing_caja = Uuid::new_v4();
let refs = vec![
("Stock".into(), "Stock".into(), stock),
("Caja".into(), "Caja".into(), missing_caja),
];
let err = validate_entity_refs(load, &refs).unwrap_err();
assert!(err.contains("Caja"));
assert!(err.contains(&short_uuid(&missing_caja)));
}
#[test]
fn uses_label_not_entity_in_msg() {
let load = |_: &str, _: Uuid| -> Option<Value> { None };
let id = Uuid::new_v4();
let refs = vec![("Stock origen".into(), "Stock".into(), id)];
let err = validate_entity_refs(load, &refs).unwrap_err();
assert!(err.contains("Stock origen"));
}
#[test]
fn empty_list_is_ok() {
let load = |_: &str, _: Uuid| -> Option<Value> { None };
assert!(validate_entity_refs(load, &[]).is_ok());
}
#[test]
fn distinguishes_target_from_other_entities() {
let id = Uuid::new_v4();
let mut records = HashMap::new();
// Mismo UUID bajo Customer pero NO bajo Stock.
records.insert(("Customer".into(), id), json!({"name": "Acme"}));
let load = mk_load(records);
let refs = vec![("Stock".into(), "Stock".into(), id)];
assert!(validate_entity_refs(load, &refs).is_err());
}
}
@@ -0,0 +1,339 @@
//! Utilidades de testing para code que consume [`MetaBackend`].
//!
//! Provee [`MockBackend`]: implementación in-memory minimalista
//! del trait, sin acoplamiento a stores reales (event log,
//! SurrealDB, etc.). Útil para:
//!
//! - Tests del widget [`nahual_widget_meta_form::MetaApp`] que
//! necesitan un backend funcional sin levantar nakui-core.
//! - Tests de cualquier consumer que tome `B: MetaBackend` y quiera
//! asserts sobre lecturas/escrituras sin tocar disco.
//! - Fixtures pre-pobladas para demos/screenshots/CI.
//!
//! Está bajo `pub mod testing` (no `#[cfg(test)]`) deliberadamente
//! para que crates downstream puedan importarlo en sus dev/integ
//! tests. No tiene overhead en producción si no se usa.
use std::collections::{BTreeMap, HashMap};
use serde_json::Value;
use uuid::Uuid;
use crate::backend::{MetaBackend, WriteOutcome};
/// Backend in-memory para tests. Implementa el contrato completo
/// del [`MetaBackend`] con semantica simple:
///
/// - `seed`: genera Uuid v4, inserta record. `changed = 1`.
/// - `update`: aplica `set` (overrides) y `clear` (key removal).
/// Si ambos vacíos → `changed = 0`. Falla si record no existe.
/// - `delete`: remueve record. Falla si no existe.
/// - `morphism`: por default rebota con error
/// `"MockBackend no soporta morphism '<name>'"`. Si querés
/// simular morphisms, registrá callbacks via
/// [`MockBackend::with_morphism`].
/// - `list_records`: orden lexicográfico por id (estable).
/// - Sin `post_status`: el mock no tiene tick/compact.
///
/// Métodos de inspección públicos ([`total_records`],
/// [`records_for`], etc.) facilitan asserts en tests sin necesidad
/// de re-leer el state via las APIs del trait.
pub struct MockBackend {
records: HashMap<(String, Uuid), Value>,
morphisms: HashMap<String, MorphismHandler>,
}
type MorphismHandler =
Box<dyn Fn(&BTreeMap<String, Uuid>, &Value) -> Result<usize, String> + Send + Sync>;
impl Default for MockBackend {
fn default() -> Self {
Self::new()
}
}
impl MockBackend {
/// Backend vacío.
pub fn new() -> Self {
Self {
records: HashMap::new(),
morphisms: HashMap::new(),
}
}
/// Pre-popula el backend con records `(entity, uuid, data)`.
/// Útil para fixtures: asserts sobre lecturas sin tener que
/// armar seeds via `seed()`.
pub fn with_records<I>(records: I) -> Self
where
I: IntoIterator<Item = (String, Uuid, Value)>,
{
let mut b = Self::new();
for (entity, id, data) in records {
b.records.insert((entity, id), data);
}
b
}
/// Registra un handler para un morphism de nombre `name`.
/// El handler recibe inputs + params y devuelve `changed` o
/// `Err` para simular fallo del morphism. Sobrescribe cualquier
/// handler previo del mismo nombre.
pub fn with_morphism<F>(mut self, name: impl Into<String>, handler: F) -> Self
where
F: Fn(&BTreeMap<String, Uuid>, &Value) -> Result<usize, String> + Send + Sync + 'static,
{
self.morphisms.insert(name.into(), Box::new(handler));
self
}
/// Cantidad total de records en el backend (todas las entities).
pub fn total_records(&self) -> usize {
self.records.len()
}
/// Records de una entity como `Vec<(Uuid, &Value)>` sin clones
/// (más liviano que `list_records` cuando el caller sólo quiere
/// inspeccionar).
pub fn records_for<'a>(&'a self, entity: &str) -> Vec<(Uuid, &'a Value)> {
self.records
.iter()
.filter(|((e, _), _)| e == entity)
.map(|((_, id), v)| (*id, v))
.collect()
}
}
impl MetaBackend for MockBackend {
fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)> {
let mut out: Vec<(Uuid, Value)> = self
.records
.iter()
.filter(|((e, _), _)| e == entity)
.map(|((_, id), v)| (*id, v.clone()))
.collect();
out.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
out
}
fn load_record(&self, entity: &str, id: Uuid) -> Option<Value> {
self.records.get(&(entity.to_string(), id)).cloned()
}
fn seed(
&mut self,
entity: &str,
data: serde_json::Map<String, Value>,
) -> Result<WriteOutcome, String> {
let id = Uuid::new_v4();
self.records
.insert((entity.to_string(), id), Value::Object(data));
Ok(WriteOutcome {
id: Some(id),
changed: 1,
post_status: None,
})
}
fn update(
&mut self,
entity: &str,
id: Uuid,
set: serde_json::Map<String, Value>,
clear: Vec<String>,
) -> Result<WriteOutcome, String> {
if set.is_empty() && clear.is_empty() {
return Ok(WriteOutcome::no_change(id));
}
let rec = self
.records
.get_mut(&(entity.to_string(), id))
.ok_or_else(|| format!("not found: {entity}/{id}"))?;
let map = rec
.as_object_mut()
.ok_or_else(|| format!("not an object: {entity}/{id}"))?;
let changed = set.len() + clear.len();
for (k, v) in set {
map.insert(k, v);
}
for k in clear {
map.remove(&k);
}
Ok(WriteOutcome {
id: Some(id),
changed,
post_status: None,
})
}
fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String> {
self.records
.remove(&(entity.to_string(), id))
.ok_or_else(|| format!("not found: {entity}/{id}"))?;
Ok(WriteOutcome {
id: Some(id),
changed: 1,
post_status: None,
})
}
fn morphism(
&mut self,
_module_id: &str,
name: &str,
inputs: BTreeMap<String, Uuid>,
params: Value,
) -> Result<WriteOutcome, String> {
match self.morphisms.get(name) {
Some(handler) => {
let changed = handler(&inputs, &params)?;
Ok(WriteOutcome {
id: None,
changed,
post_status: None,
})
}
None => Err(format!("MockBackend no soporta morphism '{name}'")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}
#[test]
fn with_records_populates_state() {
let id = Uuid::new_v4();
let b = MockBackend::with_records([(
"Customer".into(),
id,
json!({"name": "Acme"}),
)]);
assert_eq!(b.total_records(), 1);
assert_eq!(
b.load_record("Customer", id),
Some(json!({"name": "Acme"}))
);
}
#[test]
fn seed_then_load_round_trip_via_trait() {
let mut b = MockBackend::new();
let out = b
.seed("X", map_of(&[("k", json!(1))]))
.unwrap();
let id = out.id.unwrap();
assert_eq!(out.changed, 1);
assert_eq!(b.load_record("X", id), Some(json!({"k": 1})));
}
#[test]
fn update_no_op_returns_no_change() {
let id = Uuid::new_v4();
let mut b = MockBackend::with_records([(
"X".into(),
id,
json!({"k": 1}),
)]);
let out = b
.update("X", id, serde_json::Map::new(), vec![])
.unwrap();
assert_eq!(out, WriteOutcome::no_change(id));
}
#[test]
fn update_set_and_clear_aplica_ambos() {
let id = Uuid::new_v4();
let mut b = MockBackend::with_records([(
"X".into(),
id,
json!({"a": 1, "b": 2}),
)]);
let out = b
.update("X", id, map_of(&[("a", json!(10))]), vec!["b".into()])
.unwrap();
assert_eq!(out.changed, 2);
let rec = b.load_record("X", id).unwrap();
assert_eq!(rec.get("a"), Some(&json!(10)));
assert!(rec.get("b").is_none());
}
#[test]
fn delete_then_load_returns_none() {
let id = Uuid::new_v4();
let mut b = MockBackend::with_records([(
"X".into(),
id,
json!({"k": 1}),
)]);
b.delete("X", id).unwrap();
assert!(b.load_record("X", id).is_none());
}
#[test]
fn morphism_without_handler_errors_clearly() {
let mut b = MockBackend::new();
let err = b
.morphism("mod", "foo", BTreeMap::new(), json!({}))
.unwrap_err();
assert!(err.contains("foo"));
}
#[test]
fn with_morphism_lets_caller_simulate_logic() {
let mut b = MockBackend::new().with_morphism(
"double_qty",
|inputs, params| {
assert!(inputs.is_empty());
let qty = params.get("qty").and_then(|v| v.as_i64()).unwrap_or(0);
if qty <= 0 {
return Err("qty must be positive".into());
}
Ok(qty as usize)
},
);
let out = b
.morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 7}))
.unwrap();
assert_eq!(out.changed, 7);
assert!(out.id.is_none(), "morphism no devuelve id por convención");
let err = b
.morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 0}))
.unwrap_err();
assert!(err.contains("positive"));
}
#[test]
fn list_records_orders_lexicographically() {
let id_a = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
let id_b = Uuid::parse_str("ffffffff-0000-0000-0000-000000000000").unwrap();
let b = MockBackend::with_records([
("X".into(), id_b, json!({"n": 2})),
("X".into(), id_a, json!({"n": 1})),
]);
let rows = b.list_records("X");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].0, id_a, "menor uuid primero (orden lex)");
}
#[test]
fn records_for_returns_borrowed_view() {
let id = Uuid::new_v4();
let b = MockBackend::with_records([(
"X".into(),
id,
json!({"k": 1}),
)]);
let view = b.records_for("X");
assert_eq!(view.len(), 1);
assert_eq!(view[0].0, id);
assert_eq!(view[0].1.get("k"), Some(&json!(1)));
}
}