lemma/serializers/
json.rs

1use crate::{LemmaDoc, LemmaError, LemmaType};
2use serde_json::Value;
3use std::collections::HashMap;
4
5/// Serialize a JSON value to Lemma syntax based on expected type
6fn serialize_value(value: &Value, fact_type: &LemmaType) -> Result<String, LemmaError> {
7    match fact_type {
8        LemmaType::Text => match value {
9            Value::String(s) => Ok(format!("\"{}\"", s)),
10            _ => Err(LemmaError::Engine(format!(
11                "Expected string for Text, got {:?}",
12                value
13            ))),
14        },
15        LemmaType::Number => match value {
16            Value::Number(n) => Ok(n.to_string()),
17            Value::String(s) => s
18                .trim()
19                .parse::<f64>()
20                .map(|_| s.trim().to_string())
21                .map_err(|_| LemmaError::Engine(format!("Invalid number string: '{}'", s))),
22            _ => Err(LemmaError::Engine(format!(
23                "Expected number or string for Number, got {:?}",
24                value
25            ))),
26        },
27        LemmaType::Percentage => match value {
28            Value::Number(n) => {
29                let decimal = n.as_f64().ok_or_else(|| {
30                    LemmaError::Engine(format!("Invalid number for percentage: {:?}", n))
31                })?;
32                Ok(format!("{}%", decimal * 100.0))
33            }
34            Value::String(s) => Ok(s.clone()),
35            _ => Err(LemmaError::Engine(format!(
36                "Expected number or string for Percentage, got {:?}",
37                value
38            ))),
39        },
40        LemmaType::Boolean => match value {
41            Value::Bool(b) => Ok(if *b { "true" } else { "false" }.to_string()),
42            Value::String(s) => Ok(s.clone()),
43            _ => Err(LemmaError::Engine(format!(
44                "Expected boolean or string for Boolean, got {:?}",
45                value
46            ))),
47        },
48        LemmaType::Date => match value {
49            Value::String(s) => Ok(s.clone()),
50            _ => Err(LemmaError::Engine(format!(
51                "Expected string for Date, got {:?}",
52                value
53            ))),
54        },
55        LemmaType::Regex => match value {
56            Value::String(s) => {
57                if s.starts_with('/') && s.ends_with('/') {
58                    Ok(s.clone())
59                } else {
60                    Ok(format!("/{}/", s))
61                }
62            }
63            _ => Err(LemmaError::Engine(format!(
64                "Expected string for Regex, got {:?}",
65                value
66            ))),
67        },
68        LemmaType::Mass
69        | LemmaType::Length
70        | LemmaType::Volume
71        | LemmaType::Duration
72        | LemmaType::Temperature
73        | LemmaType::Power
74        | LemmaType::Energy
75        | LemmaType::Force
76        | LemmaType::Pressure
77        | LemmaType::Frequency
78        | LemmaType::Data
79        | LemmaType::Money => match value {
80            Value::String(s) => Ok(s.clone()),
81            _ => Err(LemmaError::Engine(format!(
82                "Expected string with value and unit for {:?} (e.g., \"100 kilogram\"), got {:?}",
83                fact_type, value
84            ))),
85        },
86    }
87}
88
89/// Convert JSON fact overrides to Lemma syntax strings
90///
91/// Expected JSON formats:
92/// - Text: "string value"
93/// - Number: 123 or 45.67 as number, or "123" as string (parsed and passed through)
94/// - Percentage: 0.21 as number (becomes "21%"), or "21%" as string (passed through)
95/// - Boolean: true/false as boolean (becomes "true"/"false"), or "yes"/"no"/"accept"/etc as string (passed through)
96/// - Date: "2024-01-15" or "2024-01-15T14:30:00Z"
97/// - Regex: "pattern" or "/pattern/" (passed through as string)
98/// - Unit types: "100 kilogram" (Lemma syntax as string)
99///
100/// Example:
101/// ```json
102/// {
103///   "name": "John",
104///   "rate": 0.21,
105///   "active": true,
106///   "weight": "75 kilogram",
107///   "start_date": "2024-01-15"
108/// }
109/// ```
110pub fn to_lemma_syntax(
111    json: &[u8],
112    doc: &LemmaDoc,
113    all_docs: &HashMap<String, LemmaDoc>,
114) -> Result<Vec<String>, crate::LemmaError> {
115    let map: HashMap<String, Value> = serde_json::from_slice(json)
116        .map_err(|e| crate::LemmaError::Engine(format!("JSON parse error: {}", e)))?;
117
118    let mut lemma_strings = Vec::new();
119
120    for (name, value) in map {
121        let fact_type = super::find_fact_type(&name, doc, all_docs)?;
122        let lemma_value = serialize_value(&value, &fact_type)?;
123        lemma_strings.push(format!("{}={}", name, lemma_value));
124    }
125
126    Ok(lemma_strings)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::{Engine, LemmaResult};
133
134    #[test]
135    fn test_percentage_as_number() -> LemmaResult<()> {
136        let mut engine = Engine::new();
137        engine.add_lemma_code(
138            r#"
139            doc test
140            fact discount = 10%
141            "#,
142            "test.lemma",
143        )?;
144
145        let doc = engine.get_document("test").unwrap();
146        let all_docs = engine.get_all_documents();
147
148        // Number 0.9 (decimal) should become "90%"
149        let json = r#"{"discount": 0.9}"#;
150        let result = to_lemma_syntax(json.as_bytes(), doc, all_docs)?;
151
152        assert_eq!(result.len(), 1);
153        assert_eq!(result[0], "discount=90%");
154        Ok(())
155    }
156
157    #[test]
158    fn test_percentage_as_string_with_percent() -> LemmaResult<()> {
159        let mut engine = Engine::new();
160        engine.add_lemma_code(
161            r#"
162            doc test
163            fact discount = 10%
164            "#,
165            "test.lemma",
166        )?;
167
168        let doc = engine.get_document("test").unwrap();
169        let all_docs = engine.get_all_documents();
170
171        // String "90%" should become "90%"
172        let json = r#"{"discount": "90%"}"#;
173        let result = to_lemma_syntax(json.as_bytes(), doc, all_docs)?;
174
175        assert_eq!(result.len(), 1);
176        assert_eq!(result[0], "discount=90%");
177        Ok(())
178    }
179
180    #[test]
181    fn test_percentage_as_string_without_percent() -> LemmaResult<()> {
182        let mut engine = Engine::new();
183        engine.add_lemma_code(
184            r#"
185            doc test
186            fact discount = 10%
187            "#,
188            "test.lemma",
189        )?;
190
191        let doc = engine.get_document("test").unwrap();
192        let all_docs = engine.get_all_documents();
193
194        // String "90" should be passed through as-is
195        let json = r#"{"discount": "90"}"#;
196        let result = to_lemma_syntax(json.as_bytes(), doc, all_docs)?;
197
198        assert_eq!(result.len(), 1);
199        assert_eq!(result[0], "discount=90");
200        Ok(())
201    }
202
203    #[test]
204    fn test_text_with_quotes() -> LemmaResult<()> {
205        let mut engine = Engine::new();
206        engine.add_lemma_code(
207            r#"
208            doc test
209            fact name = "Alice"
210            "#,
211            "test.lemma",
212        )?;
213
214        let doc = engine.get_document("test").unwrap();
215        let all_docs = engine.get_all_documents();
216
217        let json = r#"{"name": "Bob"}"#;
218        let result = to_lemma_syntax(json.as_bytes(), doc, all_docs)?;
219
220        assert_eq!(result.len(), 1);
221        assert_eq!(result[0], r#"name="Bob""#);
222        Ok(())
223    }
224
225    #[test]
226    fn test_number_as_string() -> LemmaResult<()> {
227        let mut engine = Engine::new();
228        engine.add_lemma_code(
229            r#"
230            doc test
231            fact age = 30
232            "#,
233            "test.lemma",
234        )?;
235
236        let doc = engine.get_document("test").unwrap();
237        let all_docs = engine.get_all_documents();
238
239        let json = r#"{"age": "42"}"#;
240        let result = to_lemma_syntax(json.as_bytes(), doc, all_docs)?;
241
242        assert_eq!(result.len(), 1);
243        assert_eq!(result[0], "age=42");
244        Ok(())
245    }
246
247    #[test]
248    fn test_unit_as_string() -> LemmaResult<()> {
249        let mut engine = Engine::new();
250        engine.add_lemma_code(
251            r#"
252            doc test
253            fact price = 100 USD
254            fact weight = 50 kilogram
255            "#,
256            "test.lemma",
257        )?;
258
259        let doc = engine.get_document("test").unwrap();
260        let all_docs = engine.get_all_documents();
261
262        let json = r#"{"price": "200 USD", "weight": "75 kilogram"}"#;
263        let result = to_lemma_syntax(json.as_bytes(), doc, all_docs)?;
264
265        assert_eq!(result.len(), 2);
266        assert!(result.contains(&"price=200 USD".to_string()));
267        assert!(result.contains(&"weight=75 kilogram".to_string()));
268        Ok(())
269    }
270
271    #[test]
272    fn test_boolean_values() -> LemmaResult<()> {
273        let mut engine = Engine::new();
274        engine.add_lemma_code(
275            r#"
276            doc test
277            fact active = false
278            "#,
279            "test.lemma",
280        )?;
281
282        let doc = engine.get_document("test").unwrap();
283        let all_docs = engine.get_all_documents();
284
285        let json = r#"{"active": true}"#;
286        let result = to_lemma_syntax(json.as_bytes(), doc, all_docs)?;
287
288        assert_eq!(result.len(), 1);
289        assert_eq!(result[0], "active=true");
290        Ok(())
291    }
292
293    #[test]
294    fn test_boolean_as_string() -> LemmaResult<()> {
295        let mut engine = Engine::new();
296        engine.add_lemma_code(
297            r#"
298            doc test
299            fact status = yes
300            "#,
301            "test.lemma",
302        )?;
303
304        let doc = engine.get_document("test").unwrap();
305        let all_docs = engine.get_all_documents();
306
307        let json = r#"{"status": "no"}"#;
308        let result = to_lemma_syntax(json.as_bytes(), doc, all_docs)?;
309
310        assert_eq!(result.len(), 1);
311        assert_eq!(result[0], "status=no");
312        Ok(())
313    }
314
315    #[test]
316    fn test_date_as_string() -> LemmaResult<()> {
317        let mut engine = Engine::new();
318        engine.add_lemma_code(
319            r#"
320            doc test
321            fact start_date = 2024-01-01
322            "#,
323            "test.lemma",
324        )?;
325
326        let doc = engine.get_document("test").unwrap();
327        let all_docs = engine.get_all_documents();
328
329        let json = r#"{"start_date": "2024-12-25"}"#;
330        let result = to_lemma_syntax(json.as_bytes(), doc, all_docs)?;
331
332        assert_eq!(result.len(), 1);
333        assert_eq!(result[0], "start_date=2024-12-25");
334        Ok(())
335    }
336
337    #[test]
338    fn test_mixed_types() -> LemmaResult<()> {
339        let mut engine = Engine::new();
340        engine.add_lemma_code(
341            r#"
342            doc test
343            fact name = "Alice"
344            fact age = 30
345            fact discount = 10%
346            fact active = true
347            fact price = 100 USD
348            "#,
349            "test.lemma",
350        )?;
351
352        let doc = engine.get_document("test").unwrap();
353        let all_docs = engine.get_all_documents();
354
355        let json = r#"{
356            "name": "Bob",
357            "age": 25,
358            "discount": 0.15,
359            "active": false,
360            "price": "200 USD"
361        }"#;
362        let result = to_lemma_syntax(json.as_bytes(), doc, all_docs)?;
363
364        assert_eq!(result.len(), 5);
365        assert!(result.contains(&r#"name="Bob""#.to_string()));
366        assert!(result.contains(&"age=25".to_string()));
367        assert!(result.contains(&"discount=15%".to_string()));
368        assert!(result.contains(&"active=false".to_string()));
369        assert!(result.contains(&"price=200 USD".to_string()));
370        Ok(())
371    }
372
373    #[test]
374    fn test_type_mismatch_error() {
375        let mut engine = Engine::new();
376        engine
377            .add_lemma_code(
378                r#"
379            doc test
380            fact age = 30
381            "#,
382                "test.lemma",
383            )
384            .unwrap();
385
386        let doc = engine.get_document("test").unwrap();
387        let all_docs = engine.get_all_documents();
388
389        // String for number type should error
390        let json = r#"{"age": "not a number"}"#;
391        let result = to_lemma_syntax(json.as_bytes(), doc, all_docs);
392
393        assert!(result.is_err());
394    }
395
396    #[test]
397    fn test_unknown_fact_error() {
398        let mut engine = Engine::new();
399        engine
400            .add_lemma_code(
401                r#"
402            doc test
403            fact age = 30
404            "#,
405                "test.lemma",
406            )
407            .unwrap();
408
409        let doc = engine.get_document("test").unwrap();
410        let all_docs = engine.get_all_documents();
411
412        // Unknown fact should error
413        let json = r#"{"unknown_fact": 42}"#;
414        let result = to_lemma_syntax(json.as_bytes(), doc, all_docs);
415
416        assert!(result.is_err());
417        assert!(result.unwrap_err().to_string().contains("not found"));
418    }
419}