Files
brahman/crates/modules/nahual/libs/meta-runtime/src/format.rs
T
sergio ab1cf9998a feat(nakui): Fase 4 del ERP — listas profesionales (orden/búsqueda/página)
Las vistas de lista de meta-form ganan: orden por columna (clic en
header cicla asc→desc→off con indicador ▲/▼), búsqueda en vivo (caja 🔍
que filtra por search_in mientras se teclea, vía cx.observe del
TextInput) y paginación (25/página, controles ◀▶). Sin cambios de
schema: son estado del widget. Helpers puros cmp_values (meta-runtime)
y next_sort con tests.

Tests verdes (meta-runtime 63, meta-form 8); clippy limpio.

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

345 lines
11 KiB
Rust

//! 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 std::cmp::Ordering;
use serde_json::Value;
use uuid::Uuid;
use nahual_meta_schema::ValueFormat;
/// Compara dos valores de celda para ordenar una lista. `None`/`null`
/// ordenan antes que cualquier valor. Números por valor numérico,
/// strings case-insensitive, bools `false < true`; tipos mixtos por su
/// forma string (orden estable, no semántico).
pub fn cmp_values(a: Option<&Value>, b: Option<&Value>) -> Ordering {
let nullish = |v: Option<&Value>| matches!(v, None | Some(Value::Null));
match (nullish(a), nullish(b)) {
(true, true) => return Ordering::Equal,
(true, false) => return Ordering::Less,
(false, true) => return Ordering::Greater,
(false, false) => {}
}
match (a, b) {
(Some(Value::Number(x)), Some(Value::Number(y))) => x
.as_f64()
.partial_cmp(&y.as_f64())
.unwrap_or(Ordering::Equal),
(Some(Value::String(x)), Some(Value::String(y))) => x.to_lowercase().cmp(&y.to_lowercase()),
(Some(Value::Bool(x)), Some(Value::Bool(y))) => x.cmp(y),
(Some(x), Some(y)) => x.to_string().cmp(&y.to_string()),
// Inalcanzable: el chequeo nullish de arriba cubre los None.
_ => Ordering::Equal,
}
}
/// Etiqueta humana para representar un record en el selector de
/// EntityRef y en columnas de referencia. Heurística: prefiere campos
/// de nombre comunes (ES + EN); fallback al UUID corto.
pub fn human_label_for_record(value: &Value, id: &Uuid) -> String {
for key in [
"name", "nombre", "label", "title", "titulo", "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(),
}
}
/// Render de un valor de celda según un [`ValueFormat`]. `Plain`
/// delega en [`render_value`]; `Number`/`Currency` agrupan miles. Un
/// valor no numérico bajo `Number`/`Currency` cae a `render_value`.
pub fn format_value(v: Option<&Value>, fmt: &ValueFormat) -> String {
match fmt {
ValueFormat::Plain => render_value(v),
ValueFormat::Number => match v {
Some(Value::Number(n)) => group_thousands(n),
_ => render_value(v),
},
ValueFormat::Currency { symbol } => match v {
Some(Value::Number(n)) => format!("{symbol}{}", group_thousands(n)),
_ => render_value(v),
},
}
}
/// Formatea un `Number` con separador de miles. Enteros sin decimales;
/// flotantes con dos.
fn group_thousands(n: &serde_json::Number) -> String {
if let Some(i) = n.as_i64() {
group_int(i)
} else if let Some(f) = n.as_f64() {
let neg = f.is_sign_negative();
let cents = (f.abs() * 100.0).round() as i64;
format!(
"{}{}.{:02}",
if neg { "-" } else { "" },
group_int(cents / 100),
cents % 100,
)
} else {
n.to_string()
}
}
/// Inserta comas cada tres dígitos en un entero con signo.
fn group_int(i: i64) -> String {
let digits = i.unsigned_abs().to_string();
let bytes = digits.as_bytes();
let mut out = String::new();
for (idx, &b) in bytes.iter().enumerate() {
if idx > 0 && (bytes.len() - idx).is_multiple_of(3) {
out.push(',');
}
out.push(b as char);
}
if i < 0 {
format!("-{out}")
} else {
out
}
}
/// 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 human_label_recognizes_spanish_name_fields() {
let id = Uuid::new_v4();
assert!(human_label_for_record(&json!({"nombre": "Acme"}), &id).starts_with("Acme "));
assert!(human_label_for_record(&json!({"titulo": "Trato"}), &id).starts_with("Trato "));
}
#[test]
fn format_value_number_groups_thousands() {
assert_eq!(
format_value(Some(&json!(12000)), &ValueFormat::Number),
"12,000"
);
assert_eq!(format_value(Some(&json!(5)), &ValueFormat::Number), "5");
assert_eq!(
format_value(Some(&json!(-1234567)), &ValueFormat::Number),
"-1,234,567"
);
}
#[test]
fn format_value_currency_prefixes_symbol() {
let fmt = ValueFormat::Currency { symbol: "$".into() };
assert_eq!(format_value(Some(&json!(25000)), &fmt), "$25,000");
}
#[test]
fn format_value_float_gets_two_decimals() {
assert_eq!(
format_value(Some(&json!(1234.5)), &ValueFormat::Number),
"1,234.50"
);
}
#[test]
fn cmp_values_orders_numbers_strings_nulls() {
// Números por valor, no lexicográfico.
assert_eq!(
cmp_values(Some(&json!(2)), Some(&json!(10))),
Ordering::Less
);
// Strings case-insensitive.
assert_eq!(
cmp_values(Some(&json!("banana")), Some(&json!("Apple"))),
Ordering::Greater
);
// null/None ordena primero.
assert_eq!(cmp_values(None, Some(&json!(1))), Ordering::Less);
assert_eq!(
cmp_values(Some(&Value::Null), Some(&json!("x"))),
Ordering::Less
);
assert_eq!(
cmp_values(Some(&json!(5)), Some(&json!(5))),
Ordering::Equal
);
// Bools.
assert_eq!(
cmp_values(Some(&json!(false)), Some(&json!(true))),
Ordering::Less
);
}
#[test]
fn format_value_non_number_falls_back_to_render_value() {
assert_eq!(
format_value(Some(&json!("hola")), &ValueFormat::Plain),
"hola"
);
let fmt = ValueFormat::Currency { symbol: "$".into() };
assert_eq!(format_value(Some(&json!("x")), &fmt), "x");
assert_eq!(format_value(None, &ValueFormat::Number), "");
}
#[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);
}
}