tycode_core/tools/
fuzzy_json.rs1use anyhow::{Context, Result};
2use serde_json::Value;
3
4pub 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}