⎇ eba629a806c7f861dd00a37c20b0f9e9d1ef3a52
crates/modules/nahual/libs/meta-runtime/src/format.rs
Historia
Quién tocó qué
crudo
feat(nakui): Fase 2 del ERP — relaciones legibles + formato
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}