tawasuyu

tawasuyu/brahman

Rust 467TOML 184Markdown 49JSON 176411 Definiciones

⎇ eba629a806c7f861dd00a37c20b0f9e9d1ef3a52 crates/modules/nahual/libs/meta-runtime/src/format.rs Rust · 288 líneas · 9.4 KB Historia Quién tocó qué crudo

feat(nakui): Fase 2 del ERP — relaciones legibles + formato eba629a80 · sergio ·

1//! Helpers de presentación humana para records y values.
2//!
3//! Sin GPUI: devuelven `String`s. El widget renderer los wrap-ea
4//! en `div().child(...)` o equivalente.
5
6use serde_json::Value;
7use uuid::Uuid;
8
9use nahual_meta_schema::ValueFormat;
10
11/// Etiqueta humana para representar un record en el selector de
12/// EntityRef y en columnas de referencia. Heurística: prefiere campos
13/// de nombre comunes (ES + EN); fallback al UUID corto.
14pub fn human_label_for_record(value: &Value, id: &Uuid) -> String {
15    for key in [
16        "name", "nombre", "label", "title", "titulo", "sku", "sku_id",
17    ] {
18        if let Some(v) = value.get(key).and_then(Value::as_str) {
19            if !v.is_empty() {
20                return format!("{} ({})", v, short_uuid(id));
21            }
22        }
23    }
24    short_uuid(id)
25}
26
27/// Render legible de un `Value` arbitrario para mostrar en una celda
28/// de lista. Strings van pelados; bools como ✓/✗; el resto via
29/// `Display`.
30pub fn render_value(v: Option<&Value>) -> String {
31    match v {
32        None | Some(Value::Null) => String::new(),
33        Some(Value::String(s)) => s.clone(),
34        Some(Value::Bool(b)) => if *b { "✓" } else { "✗" }.to_string(),
35        Some(Value::Number(n)) => n.to_string(),
36        Some(other) => other.to_string(),
37    }
38}
39
40/// Render de un valor de celda según un [`ValueFormat`]. `Plain`
41/// delega en [`render_value`]; `Number`/`Currency` agrupan miles. Un
42/// valor no numérico bajo `Number`/`Currency` cae a `render_value`.
43pub fn format_value(v: Option<&Value>, fmt: &ValueFormat) -> String {
44    match fmt {
45        ValueFormat::Plain => render_value(v),
46        ValueFormat::Number => match v {
47            Some(Value::Number(n)) => group_thousands(n),
48            _ => render_value(v),
49        },
50        ValueFormat::Currency { symbol } => match v {
51            Some(Value::Number(n)) => format!("{symbol}{}", group_thousands(n)),
52            _ => render_value(v),
53        },
54    }
55}
56
57/// Formatea un `Number` con separador de miles. Enteros sin decimales;
58/// flotantes con dos.
59fn group_thousands(n: &serde_json::Number) -> String {
60    if let Some(i) = n.as_i64() {
61        group_int(i)
62    } else if let Some(f) = n.as_f64() {
63        let neg = f.is_sign_negative();
64        let cents = (f.abs() * 100.0).round() as i64;
65        format!(
66            "{}{}.{:02}",
67            if neg { "-" } else { "" },
68            group_int(cents / 100),
69            cents % 100,
70        )
71    } else {
72        n.to_string()
73    }
74}
75
76/// Inserta comas cada tres dígitos en un entero con signo.
77fn group_int(i: i64) -> String {
78    let digits = i.unsigned_abs().to_string();
79    let bytes = digits.as_bytes();
80    let mut out = String::new();
81    for (idx, &b) in bytes.iter().enumerate() {
82        if idx > 0 && (bytes.len() - idx).is_multiple_of(3) {
83            out.push(',');
84        }
85        out.push(b as char);
86    }
87    if i < 0 {
88        format!("-{out}")
89    } else {
90        out
91    }
92}
93
94/// Conversión inversa a `parse_field_value`: del JSON al texto raw
95/// que un input puede tomar y volver a parsearse igual al submit.
96/// Usado para pre-llenar inputs en modo edit.
97pub fn value_to_input_text(v: &Value) -> String {
98    match v {
99        Value::Null => String::new(),
100        Value::String(s) => s.clone(),
101        Value::Bool(b) => if *b { "true" } else { "false" }.to_string(),
102        Value::Number(n) => n.to_string(),
103        other => other.to_string(),
104    }
105}
106
107/// Primeros 8 chars del UUID en forma canónica. Útil para logs y UI
108/// donde el UUID full es ruido visual.
109pub fn short_uuid(id: &Uuid) -> String {
110    id.to_string().chars().take(8).collect()
111}
112
113/// Hex string de los primeros 4 bytes de un hash SHA-256 (8
114/// caracteres). Útil para mostrar bundle/schema hashes en UI sin
115/// quemar pantalla con los 64 chars completos.
116pub fn short_hash(h: &[u8; 32]) -> String {
117    use std::fmt::Write;
118    let mut s = String::with_capacity(8);
119    for b in h.iter().take(4) {
120        let _ = write!(s, "{:02x}", b);
121    }
122    s
123}
124
125/// Renderea un `serde_json::Value` en una sola línea, truncado a
126/// `max` caracteres con `...` al final si excede. Para preview en
127/// timelines/cards/listas — NO para edición.
128///
129/// `max` es un upper-bound aproximado: el resultado nunca excede
130/// `max` chars, pero puede ser más corto si el value es chico.
131pub fn preview_value(v: &Value, max: usize) -> String {
132    let s = v.to_string();
133    if s.chars().count() <= max {
134        s
135    } else if max < 3 {
136        s.chars().take(max).collect()
137    } else {
138        let truncated: String = s.chars().take(max - 3).collect();
139        format!("{truncated}...")
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use serde_json::json;
147
148    #[test]
149    fn human_label_prefers_name_over_id() {
150        let id = Uuid::new_v4();
151        let v = json!({"name": "Acme S.A.", "email": "x@y.z"});
152        let label = human_label_for_record(&v, &id);
153        assert!(label.starts_with("Acme S.A."));
154        assert!(label.contains(&short_uuid(&id)));
155    }
156
157    #[test]
158    fn human_label_falls_back_through_label_title_sku() {
159        let id = Uuid::new_v4();
160        let only_label = json!({"label": "X"});
161        assert!(human_label_for_record(&only_label, &id).starts_with("X "));
162        let only_title = json!({"title": "Y"});
163        assert!(human_label_for_record(&only_title, &id).starts_with("Y "));
164        let only_sku = json!({"sku": "Z"});
165        assert!(human_label_for_record(&only_sku, &id).starts_with("Z "));
166        let only_sku_id = json!({"sku_id": "W"});
167        assert!(human_label_for_record(&only_sku_id, &id).starts_with("W "));
168    }
169
170    #[test]
171    fn human_label_falls_back_to_short_uuid_when_no_keys_match() {
172        let id = Uuid::new_v4();
173        let v = json!({"random": "field"});
174        assert_eq!(human_label_for_record(&v, &id), short_uuid(&id));
175    }
176
177    #[test]
178    fn human_label_recognizes_spanish_name_fields() {
179        let id = Uuid::new_v4();
180        assert!(human_label_for_record(&json!({"nombre": "Acme"}), &id).starts_with("Acme "));
181        assert!(human_label_for_record(&json!({"titulo": "Trato"}), &id).starts_with("Trato "));
182    }
183
184    #[test]
185    fn format_value_number_groups_thousands() {
186        assert_eq!(
187            format_value(Some(&json!(12000)), &ValueFormat::Number),
188            "12,000"
189        );
190        assert_eq!(format_value(Some(&json!(5)), &ValueFormat::Number), "5");
191        assert_eq!(
192            format_value(Some(&json!(-1234567)), &ValueFormat::Number),
193            "-1,234,567"
194        );
195    }
196
197    #[test]
198    fn format_value_currency_prefixes_symbol() {
199        let fmt = ValueFormat::Currency { symbol: "$".into() };
200        assert_eq!(format_value(Some(&json!(25000)), &fmt), "$25,000");
201    }
202
203    #[test]
204    fn format_value_float_gets_two_decimals() {
205        assert_eq!(
206            format_value(Some(&json!(1234.5)), &ValueFormat::Number),
207            "1,234.50"
208        );
209    }
210
211    #[test]
212    fn format_value_non_number_falls_back_to_render_value() {
213        assert_eq!(
214            format_value(Some(&json!("hola")), &ValueFormat::Plain),
215            "hola"
216        );
217        let fmt = ValueFormat::Currency { symbol: "$".into() };
218        assert_eq!(format_value(Some(&json!("x")), &fmt), "x");
219        assert_eq!(format_value(None, &ValueFormat::Number), "");
220    }
221
222    #[test]
223    fn render_value_handles_basic_kinds() {
224        assert_eq!(render_value(None), "");
225        assert_eq!(render_value(Some(&Value::Null)), "");
226        assert_eq!(render_value(Some(&json!("hola"))), "hola");
227        assert_eq!(render_value(Some(&json!(true))), "✓");
228        assert_eq!(render_value(Some(&json!(false))), "✗");
229        assert_eq!(render_value(Some(&json!(42))), "42");
230    }
231
232    #[test]
233    fn value_to_input_text_round_trip_with_strings_and_numbers() {
234        assert_eq!(value_to_input_text(&Value::Null), "");
235        assert_eq!(value_to_input_text(&json!("x")), "x");
236        assert_eq!(value_to_input_text(&json!(true)), "true");
237        assert_eq!(value_to_input_text(&json!(false)), "false");
238        assert_eq!(value_to_input_text(&json!(42)), "42");
239    }
240
241    #[test]
242    fn short_hash_takes_first_4_bytes_hex() {
243        let mut h = [0u8; 32];
244        h[0] = 0xaa;
245        h[1] = 0xbb;
246        h[2] = 0xcc;
247        h[3] = 0xdd;
248        assert_eq!(short_hash(&h), "aabbccdd");
249    }
250
251    #[test]
252    fn short_hash_zeros() {
253        let h = [0u8; 32];
254        assert_eq!(short_hash(&h), "00000000");
255    }
256
257    #[test]
258    fn preview_value_keeps_short_strings_intact() {
259        let v = json!({"a": 1});
260        assert_eq!(preview_value(&v, 30), "{\"a\":1}");
261    }
262
263    #[test]
264    fn preview_value_truncates_long_strings_with_ellipsis() {
265        let v = json!({"a": "x".repeat(200)});
266        let p = preview_value(&v, 30);
267        assert!(p.chars().count() <= 30);
268        assert!(p.ends_with("..."));
269    }
270
271    #[test]
272    fn preview_value_handles_max_smaller_than_ellipsis() {
273        // Edge case: max < 3 (no espacio para "..."). Devuelve
274        // los primeros `max` chars sin sufijo, sin panic.
275        let v = json!("xxxxxxxxxxxxxxxx");
276        let p = preview_value(&v, 2);
277        assert!(p.chars().count() <= 2);
278    }
279
280    #[test]
281    fn short_uuid_returns_first_8_chars() {
282        let id = Uuid::parse_str("01ARZ3ND-EKTS-V4RR-FFQ6-9G5FAV000000").ok();
283        // Si el parse falla, usamos uno fresco — el invariant es la
284        // longitud, no el contenido.
285        let id = id.unwrap_or_else(Uuid::new_v4);
286        assert_eq!(short_uuid(&id).len(), 8);
287    }
288}