Skip to main content

tycode_core/tools/
fuzzy_json.rs

1use anyhow::{Context, Result};
2use serde_json::Value;
3
4/// Attempts to coerce a JSON value to match the expected schema.
5/// Handles common model mistakes like numeric values as strings,
6/// JSON arrays encoded as strings, etc.
7pub fn coerce_to_schema(value: &Value, schema: &Value) -> Result<Value> {
8    let schema_type = schema
9        .get("type")
10        .and_then(|v| v.as_str())
11        .unwrap_or("object");
12
13    match schema_type {
14        "object" => coerce_object(value, schema),
15        "array" => coerce_array(value, schema),
16        "string" => coerce_string(value),
17        "integer" | "number" => coerce_number(value, schema_type),
18        "boolean" => coerce_boolean(value),
19        _ => Ok(value.clone()),
20    }
21}
22
23fn coerce_object(value: &Value, schema: &Value) -> Result<Value> {
24    match value {
25        Value::Object(map) => {
26            let properties = schema.get("properties");
27            if properties.is_none() {
28                return Ok(value.clone());
29            }
30
31            let properties = properties.unwrap().as_object();
32            if properties.is_none() {
33                return Ok(value.clone());
34            }
35
36            let properties = properties.unwrap();
37            let mut coerced = serde_json::Map::new();
38
39            for (key, val) in map {
40                if let Some(prop_schema) = properties.get(key) {
41                    let coerced_val = coerce_to_schema(val, prop_schema)
42                        .with_context(|| format!("Failed to coerce property '{key}'"))?;
43                    coerced.insert(key.clone(), coerced_val);
44                } else {
45                    coerced.insert(key.clone(), val.clone());
46                }
47            }
48
49            Ok(Value::Object(coerced))
50        }
51        _ => Ok(value.clone()),
52    }
53}
54
55fn coerce_array(value: &Value, schema: &Value) -> Result<Value> {
56    match value {
57        Value::Array(arr) => {
58            if let Some(items_schema) = schema.get("items") {
59                let coerced: Result<Vec<Value>> = arr
60                    .iter()
61                    .map(|item| coerce_to_schema(item, items_schema))
62                    .collect();
63                Ok(Value::Array(coerced?))
64            } else {
65                Ok(value.clone())
66            }
67        }
68        Value::String(s) => match serde_json::from_str::<Value>(s) {
69            Ok(Value::Array(arr)) => {
70                if let Some(items_schema) = schema.get("items") {
71                    let coerced: Result<Vec<Value>> = arr
72                        .iter()
73                        .map(|item| coerce_to_schema(item, items_schema))
74                        .collect();
75                    Ok(Value::Array(coerced?))
76                } else {
77                    Ok(Value::Array(arr))
78                }
79            }
80            Ok(other) => {
81                if let Some(items_schema) = schema.get("items") {
82                    coerce_to_schema(&other, items_schema)
83                } else {
84                    Ok(other)
85                }
86            }
87            Err(_) => Ok(value.clone()),
88        },
89        _ => Ok(value.clone()),
90    }
91}
92
93fn coerce_string(value: &Value) -> Result<Value> {
94    match value {
95        Value::String(_) => Ok(value.clone()),
96        Value::Number(n) => Ok(Value::String(n.to_string())),
97        Value::Bool(b) => Ok(Value::String(b.to_string())),
98        _ => Ok(value.clone()),
99    }
100}
101
102fn coerce_number(value: &Value, schema_type: &str) -> Result<Value> {
103    match value {
104        Value::Number(_) => Ok(value.clone()),
105        Value::String(s) => {
106            let trimmed = s.trim();
107            if schema_type == "integer" {
108                if let Ok(n) = trimmed.parse::<i64>() {
109                    return Ok(Value::Number(n.into()));
110                }
111            }
112            if let Ok(n) = trimmed.parse::<f64>() {
113                if let Some(num) = serde_json::Number::from_f64(n) {
114                    return Ok(Value::Number(num));
115                }
116            }
117            Ok(value.clone())
118        }
119        _ => Ok(value.clone()),
120    }
121}
122
123fn coerce_boolean(value: &Value) -> Result<Value> {
124    match value {
125        Value::Bool(_) => Ok(value.clone()),
126        Value::String(s) => {
127            let trimmed = s.trim().to_lowercase();
128            match trimmed.as_str() {
129                "true" | "1" | "yes" => Ok(Value::Bool(true)),
130                "false" | "0" | "no" => Ok(Value::Bool(false)),
131                _ => Ok(value.clone()),
132            }
133        }
134        Value::Number(n) => {
135            if let Some(i) = n.as_i64() {
136                Ok(Value::Bool(i != 0))
137            } else {
138                Ok(value.clone())
139            }
140        }
141        _ => Ok(value.clone()),
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use serde_json::json;
149
150    #[test]
151    fn test_coerce_string_to_integer() {
152        let value = json!("120");
153        let schema = json!({"type": "integer"});
154        let result = coerce_to_schema(&value, &schema).unwrap();
155        assert_eq!(result, json!(120));
156    }
157
158    #[test]
159    fn test_coerce_string_to_number() {
160        let value = json!("3.14");
161        let schema = json!({"type": "number"});
162        let result = coerce_to_schema(&value, &schema).unwrap();
163        assert_eq!(result, json!(3.14));
164    }
165
166    #[test]
167    fn test_coerce_string_to_boolean() {
168        let value = json!("true");
169        let schema = json!({"type": "boolean"});
170        let result = coerce_to_schema(&value, &schema).unwrap();
171        assert_eq!(result, json!(true));
172    }
173
174    #[test]
175    fn test_coerce_object_with_numeric_string() {
176        let value = json!({
177            "timeout_seconds": "120",
178            "command": "cargo build"
179        });
180        let schema = json!({
181            "type": "object",
182            "properties": {
183                "timeout_seconds": {"type": "integer"},
184                "command": {"type": "string"}
185            }
186        });
187        let result = coerce_to_schema(&value, &schema).unwrap();
188        assert_eq!(
189            result,
190            json!({
191                "timeout_seconds": 120,
192                "command": "cargo build"
193            })
194        );
195    }
196
197    #[test]
198    fn test_coerce_stringified_array() {
199        let value = json!("[1, 2, 3]");
200        let schema = json!({
201            "type": "array",
202            "items": {"type": "integer"}
203        });
204        let result = coerce_to_schema(&value, &schema).unwrap();
205        assert_eq!(result, json!([1, 2, 3]));
206    }
207
208    #[test]
209    fn test_preserve_valid_values() {
210        let value = json!({"count": 42, "name": "test"});
211        let schema = json!({
212            "type": "object",
213            "properties": {
214                "count": {"type": "integer"},
215                "name": {"type": "string"}
216            }
217        });
218        let result = coerce_to_schema(&value, &schema).unwrap();
219        assert_eq!(result, value);
220    }
221
222    #[test]
223    fn test_malformed_stringified_array_returns_original() {
224        let malformed_json = json!("[{\"op\": \"replace\", \"path\": \"/foo\"}");
225        let schema = json!({
226            "type": "array",
227            "items": {"type": "object"}
228        });
229        let result = coerce_to_schema(&malformed_json, &schema).unwrap();
230        assert_eq!(result, malformed_json);
231    }
232}