⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc
crates/modules/ui_engine/libs/meta-runtime/src/parse.rs
Historia
Quién tocó qué
crudo
refactor(yahweh): Fase 2 — extraer helpers puros a yahweh-meta-runtime
1//! Parseo de inputs del form a `serde_json::Value` tipado.
2
3use serde_json::{json, Value};
4use uuid::Uuid;
5
6use yahweh_meta_schema::{FieldKind, FieldSpec};
7
8/// Convierte el texto raw de un input al `Value` tipado según el
9/// `kind` del spec.
10///
11/// - `Text` / `Multiline` / `Date` → string passthrough.
12/// - `EntityRef` → string del UUID **trimmed**, validado como UUID
13/// parseable. Falla con mensaje claro si no parsea.
14/// - `Boolean` → variantes comunes (`true/yes/1/on/y` y `false/no/0/off/n`).
15/// - `Number` → i64 si parsea, sino f64.
16pub fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
17 match kind {
18 FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
19 // EntityRef se almacena como string del UUID seleccionado.
20 // El selector clickable garantiza UUIDs válidos en happy
21 // path; este check protege paste manual o garbage tipeado.
22 FieldKind::EntityRef => {
23 let trimmed = raw.trim();
24 Uuid::parse_str(trimmed).map_err(|_| {
25 format!("'{raw}' no es UUID válido (usá el selector de records)")
26 })?;
27 Ok(json!(trimmed))
28 }
29 FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
30 "true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
31 "" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
32 other => Err(format!("'{other}' no es booleano")),
33 },
34 FieldKind::Number => {
35 if let Ok(i) = raw.parse::<i64>() {
36 Ok(json!(i))
37 } else if let Ok(f) = raw.parse::<f64>() {
38 Ok(json!(f))
39 } else {
40 Err(format!("'{raw}' no es número"))
41 }
42 }
43 }
44}
45
46/// Resuelve un param de morphism a su `Value` según el `FieldSpec`
47/// del form. **Strict path**: si hay spec, valida `required` y parsea
48/// con el `kind` declarado (ej. Boolean rebota con "abc" antes de
49/// llegar al morphism). **Fallback path**: si no hay spec (param
50/// declarado en `Action::Morphism.params` que no aparece en
51/// `form.fields`), usa la heurística [`infer_param_value`] para no
52/// quedar atado a un schema mal-formado.
53///
54/// Errores tienen el label legible del spec, así el toast de la UI
55/// es interpretable.
56pub fn resolve_param_value(
57 field_name: &str,
58 raw: &str,
59 spec: Option<&FieldSpec>,
60) -> Result<Value, String> {
61 let Some(s) = spec else {
62 return Ok(infer_param_value(raw));
63 };
64
65 let label = if s.label.is_empty() { field_name } else { &s.label };
66
67 if s.required && raw.trim().is_empty() {
68 return Err(format!("param '{label}' es obligatorio y está vacío"));
69 }
70 if raw.is_empty() && !s.required {
71 return Ok(Value::Null);
72 }
73 parse_field_value(s.kind, raw).map_err(|e| format!("param '{label}': {e}"))
74}
75
76/// Inferencia de tipo para values pasados como `params` a un
77/// morphism. Usada como fallback en [`resolve_param_value`] cuando el
78/// param declarado en `Action::Morphism.params` no aparece en los
79/// `form.fields` (módulo mal-formado).
80///
81/// Heurística simple: int → i64, float → f64, "true"/"false" → bool,
82/// resto → string.
83pub fn infer_param_value(raw: &str) -> Value {
84 if raw.is_empty() {
85 return Value::Null;
86 }
87 if let Ok(i) = raw.parse::<i64>() {
88 return json!(i);
89 }
90 if let Ok(f) = raw.parse::<f64>() {
91 return json!(f);
92 }
93 match raw {
94 "true" => return json!(true),
95 "false" => return json!(false),
96 _ => {}
97 }
98 json!(raw)
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use yahweh_meta_schema::FieldSpec;
105
106 fn spec(name: &str, kind: FieldKind, required: bool) -> FieldSpec {
107 FieldSpec {
108 name: name.into(),
109 label: name.into(),
110 kind,
111 default: None,
112 required,
113 help: None,
114 ref_entity: None,
115 }
116 }
117
118 #[test]
119 fn infer_handles_basic_types() {
120 assert_eq!(infer_param_value(""), Value::Null);
121 assert_eq!(infer_param_value("42"), json!(42));
122 assert_eq!(infer_param_value("3.14"), json!(3.14));
123 assert_eq!(infer_param_value("true"), json!(true));
124 assert_eq!(infer_param_value("false"), json!(false));
125 assert_eq!(infer_param_value("hola"), json!("hola"));
126 }
127
128 #[test]
129 fn parse_text_passthrough() {
130 let v = parse_field_value(FieldKind::Text, "hola").unwrap();
131 assert_eq!(v, json!("hola"));
132 }
133
134 #[test]
135 fn parse_number_i64_or_f64() {
136 assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42));
137 assert_eq!(
138 parse_field_value(FieldKind::Number, "3.14").unwrap(),
139 json!(3.14)
140 );
141 assert!(parse_field_value(FieldKind::Number, "abc").is_err());
142 }
143
144 #[test]
145 fn parse_boolean_recognizes_variants() {
146 for s in ["true", "yes", "1", "on", "y"] {
147 assert_eq!(parse_field_value(FieldKind::Boolean, s).unwrap(), json!(true));
148 }
149 for s in ["false", "no", "0", "off", "n", ""] {
150 assert_eq!(
151 parse_field_value(FieldKind::Boolean, s).unwrap(),
152 json!(false)
153 );
154 }
155 assert!(parse_field_value(FieldKind::Boolean, "abc").is_err());
156 }
157
158 #[test]
159 fn parse_entity_ref_accepts_valid_uuid() {
160 let id = Uuid::new_v4();
161 let v = parse_field_value(FieldKind::EntityRef, &id.to_string()).unwrap();
162 assert_eq!(v, json!(id.to_string()));
163 }
164
165 #[test]
166 fn parse_entity_ref_trims_whitespace() {
167 let id = Uuid::new_v4();
168 let padded = format!(" {id}\n");
169 let v = parse_field_value(FieldKind::EntityRef, &padded).unwrap();
170 assert_eq!(v, json!(id.to_string()));
171 }
172
173 #[test]
174 fn parse_entity_ref_rejects_non_uuid() {
175 let err = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap_err();
176 assert!(err.contains("'abc-123'"));
177 assert!(err.contains("UUID") || err.contains("uuid"));
178 }
179
180 #[test]
181 fn parse_entity_ref_rejects_empty_string() {
182 let err = parse_field_value(FieldKind::EntityRef, "").unwrap_err();
183 assert!(err.contains("UUID"));
184 }
185
186 #[test]
187 fn resolve_param_strict_number_parses_i64() {
188 let s = spec("qty", FieldKind::Number, true);
189 let v = resolve_param_value("qty", "42", Some(&s)).unwrap();
190 assert_eq!(v, json!(42));
191 }
192
193 #[test]
194 fn resolve_param_strict_boolean_rejects_non_boolean() {
195 let s = spec("active", FieldKind::Boolean, true);
196 let err = resolve_param_value("active", "abc", Some(&s)).unwrap_err();
197 assert!(err.contains("active"));
198 }
199
200 #[test]
201 fn resolve_param_required_empty_rejected() {
202 let s = spec("name", FieldKind::Text, true);
203 let err = resolve_param_value("name", " ", Some(&s)).unwrap_err();
204 assert!(err.contains("obligatorio"));
205 }
206
207 #[test]
208 fn resolve_param_optional_empty_returns_null() {
209 let s = spec("notes", FieldKind::Text, false);
210 let v = resolve_param_value("notes", "", Some(&s)).unwrap();
211 assert_eq!(v, Value::Null);
212 }
213
214 #[test]
215 fn resolve_param_no_spec_falls_back_to_infer() {
216 let v = resolve_param_value("foo", "42", None).unwrap();
217 assert_eq!(v, json!(42));
218 let v = resolve_param_value("foo", "true", None).unwrap();
219 assert_eq!(v, json!(true));
220 let v = resolve_param_value("foo", "x", None).unwrap();
221 assert_eq!(v, json!("x"));
222 }
223
224 #[test]
225 fn resolve_param_strict_entity_ref_propagates_error() {
226 let s = spec("stock_ref", FieldKind::EntityRef, true);
227 let err = resolve_param_value("stock_ref", "not-a-uuid", Some(&s)).unwrap_err();
228 assert!(err.contains("stock_ref"));
229 assert!(err.contains("UUID"));
230 }
231}