gioser

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Sutichaykuna

⎇ c56ef2554615e03b5fcdfb80c0e5e5c826a94f00 crates/modules/nahual/libs/meta-runtime/src/parse.rs Rust · 263 siq'ikuna · 8.6 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(nakui): Fase 7 del ERP — pulido (cierra el plan maestro) c56ef2554 · sergio ·

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            section: None,
126        }
127    }
128
129    #[test]
130    fn infer_handles_basic_types() {
131        assert_eq!(infer_param_value(""), Value::Null);
132        assert_eq!(infer_param_value("42"), json!(42));
133        assert_eq!(infer_param_value("2.5"), json!(2.5));
134        assert_eq!(infer_param_value("true"), json!(true));
135        assert_eq!(infer_param_value("false"), json!(false));
136        assert_eq!(infer_param_value("hola"), json!("hola"));
137    }
138
139    #[test]
140    fn parse_text_passthrough() {
141        let v = parse_field_value(FieldKind::Text, "hola").unwrap();
142        assert_eq!(v, json!("hola"));
143    }
144
145    #[test]
146    fn parse_select_and_auto_id_passthrough() {
147        // Select guarda el valor de la opción elegida.
148        assert_eq!(
149            parse_field_value(FieldKind::Select, "ganada").unwrap(),
150            json!("ganada")
151        );
152        // AutoId guarda el UUID autogenerado tal cual.
153        let id = Uuid::new_v4().to_string();
154        assert_eq!(
155            parse_field_value(FieldKind::AutoId, &id).unwrap(),
156            json!(id)
157        );
158    }
159
160    #[test]
161    fn parse_number_i64_or_f64() {
162        assert_eq!(
163            parse_field_value(FieldKind::Number, "42").unwrap(),
164            json!(42)
165        );
166        assert_eq!(
167            parse_field_value(FieldKind::Number, "2.5").unwrap(),
168            json!(2.5)
169        );
170        assert!(parse_field_value(FieldKind::Number, "abc").is_err());
171    }
172
173    #[test]
174    fn parse_boolean_recognizes_variants() {
175        for s in ["true", "yes", "1", "on", "y"] {
176            assert_eq!(
177                parse_field_value(FieldKind::Boolean, s).unwrap(),
178                json!(true)
179            );
180        }
181        for s in ["false", "no", "0", "off", "n", ""] {
182            assert_eq!(
183                parse_field_value(FieldKind::Boolean, s).unwrap(),
184                json!(false)
185            );
186        }
187        assert!(parse_field_value(FieldKind::Boolean, "abc").is_err());
188    }
189
190    #[test]
191    fn parse_entity_ref_accepts_valid_uuid() {
192        let id = Uuid::new_v4();
193        let v = parse_field_value(FieldKind::EntityRef, &id.to_string()).unwrap();
194        assert_eq!(v, json!(id.to_string()));
195    }
196
197    #[test]
198    fn parse_entity_ref_trims_whitespace() {
199        let id = Uuid::new_v4();
200        let padded = format!("  {id}\n");
201        let v = parse_field_value(FieldKind::EntityRef, &padded).unwrap();
202        assert_eq!(v, json!(id.to_string()));
203    }
204
205    #[test]
206    fn parse_entity_ref_rejects_non_uuid() {
207        let err = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap_err();
208        assert!(err.contains("'abc-123'"));
209        assert!(err.contains("UUID") || err.contains("uuid"));
210    }
211
212    #[test]
213    fn parse_entity_ref_rejects_empty_string() {
214        let err = parse_field_value(FieldKind::EntityRef, "").unwrap_err();
215        assert!(err.contains("UUID"));
216    }
217
218    #[test]
219    fn resolve_param_strict_number_parses_i64() {
220        let s = spec("qty", FieldKind::Number, true);
221        let v = resolve_param_value("qty", "42", Some(&s)).unwrap();
222        assert_eq!(v, json!(42));
223    }
224
225    #[test]
226    fn resolve_param_strict_boolean_rejects_non_boolean() {
227        let s = spec("active", FieldKind::Boolean, true);
228        let err = resolve_param_value("active", "abc", Some(&s)).unwrap_err();
229        assert!(err.contains("active"));
230    }
231
232    #[test]
233    fn resolve_param_required_empty_rejected() {
234        let s = spec("name", FieldKind::Text, true);
235        let err = resolve_param_value("name", "   ", Some(&s)).unwrap_err();
236        assert!(err.contains("obligatorio"));
237    }
238
239    #[test]
240    fn resolve_param_optional_empty_returns_null() {
241        let s = spec("notes", FieldKind::Text, false);
242        let v = resolve_param_value("notes", "", Some(&s)).unwrap();
243        assert_eq!(v, Value::Null);
244    }
245
246    #[test]
247    fn resolve_param_no_spec_falls_back_to_infer() {
248        let v = resolve_param_value("foo", "42", None).unwrap();
249        assert_eq!(v, json!(42));
250        let v = resolve_param_value("foo", "true", None).unwrap();
251        assert_eq!(v, json!(true));
252        let v = resolve_param_value("foo", "x", None).unwrap();
253        assert_eq!(v, json!("x"));
254    }
255
256    #[test]
257    fn resolve_param_strict_entity_ref_propagates_error() {
258        let s = spec("stock_ref", FieldKind::EntityRef, true);
259        let err = resolve_param_value("stock_ref", "not-a-uuid", Some(&s)).unwrap_err();
260        assert!(err.contains("stock_ref"));
261        assert!(err.contains("UUID"));
262    }
263}