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