wallfacer_core/
differential.rs1use std::{
2 collections::{BTreeMap, BTreeSet},
3 fs, io,
4 path::{Path, PathBuf},
5};
6
7use serde_json::{json, Map, Value};
8use thiserror::Error;
9
10use crate::corpus::sanitize_tool_name;
11
12#[derive(Debug, Error)]
13pub enum DifferentialError {
14 #[error("failed to create schema directory {path}: {source}")]
15 CreateDir { path: PathBuf, source: io::Error },
16 #[error("failed to write schema {path}: {source}")]
17 Write { path: PathBuf, source: io::Error },
18 #[error("failed to read schema {path}: {source}")]
19 Read { path: PathBuf, source: io::Error },
20 #[error("failed to parse schema {path}: {source}")]
21 Parse {
22 path: PathBuf,
23 source: serde_json::Error,
24 },
25}
26
27pub type Result<T> = std::result::Result<T, DifferentialError>;
28
29pub fn inferred_schema_dir() -> PathBuf {
30 PathBuf::from(".wallfacer/inferred_schemas")
31}
32
33pub fn schema_path(dir: &Path, tool_name: &str) -> PathBuf {
34 dir.join(format!("{}.json", sanitize_tool_name(tool_name)))
35}
36
37pub fn save_schema(dir: &Path, tool_name: &str, schema: &Value) -> Result<PathBuf> {
38 fs::create_dir_all(dir).map_err(|source| DifferentialError::CreateDir {
39 path: dir.to_path_buf(),
40 source,
41 })?;
42
43 let path = schema_path(dir, tool_name);
44 let body = serde_json::to_string_pretty(schema).unwrap_or_else(|_| "{}".to_string());
46 fs::write(&path, body).map_err(|source| DifferentialError::Write {
47 path: path.clone(),
48 source,
49 })?;
50 Ok(path)
51}
52
53pub fn load_schema(dir: &Path, tool_name: &str) -> Result<Option<Value>> {
54 let path = schema_path(dir, tool_name);
55 if !path.is_file() {
56 return Ok(None);
57 }
58
59 let body = fs::read_to_string(&path).map_err(|source| DifferentialError::Read {
60 path: path.clone(),
61 source,
62 })?;
63 let schema = serde_json::from_str(&body).map_err(|source| DifferentialError::Parse {
64 path: path.clone(),
65 source,
66 })?;
67 Ok(Some(schema))
68}
69
70pub fn response_value(result: &rmcp::model::CallToolResult) -> Value {
71 result.structured_content.clone().unwrap_or_else(|| {
72 serde_json::to_value(result).unwrap_or(Value::Null)
75 })
76}
77
78pub fn boundary_payload(schema: &Value) -> Value {
79 match boundary_value(schema) {
80 Value::Object(map) => Value::Object(map),
81 _ => Value::Object(Map::new()),
82 }
83}
84
85pub fn infer_schema(values: &[Value]) -> Value {
86 if values.is_empty() {
87 return json!({});
88 }
89 infer_values(values)
90}
91
92fn infer_values(values: &[Value]) -> Value {
93 if values.iter().all(Value::is_null) {
94 return json!({"type": "null"});
95 }
96
97 let mut types = values.iter().map(type_name).collect::<BTreeSet<_>>();
98 if types.len() > 1 {
99 let type_values = types.into_iter().map(Value::String).collect::<Vec<_>>();
100 return json!({ "type": type_values });
101 }
102
103 match types.pop_first().as_deref() {
104 Some("object") => infer_object(values),
105 Some("array") => infer_array(values),
106 Some("integer") => json!({"type": "integer"}),
107 Some("number") => json!({"type": "number"}),
108 Some("string") => json!({"type": "string"}),
109 Some("boolean") => json!({"type": "boolean"}),
110 Some("null") => json!({"type": "null"}),
111 _ => json!({}),
112 }
113}
114
115fn infer_object(values: &[Value]) -> Value {
116 let objects = values
117 .iter()
118 .filter_map(Value::as_object)
119 .collect::<Vec<_>>();
120 let mut property_values = BTreeMap::<String, Vec<Value>>::new();
121 let mut required = BTreeSet::<String>::new();
122
123 if let Some(first) = objects.first() {
124 required.extend(first.keys().cloned());
125 }
126
127 for object in &objects {
128 let keys = object.keys().cloned().collect::<BTreeSet<_>>();
129 required = required.intersection(&keys).cloned().collect();
130
131 for (key, value) in *object {
132 property_values
133 .entry(key.clone())
134 .or_default()
135 .push(value.clone());
136 }
137 }
138
139 let mut properties = Map::new();
140 for (key, values) in property_values {
141 properties.insert(key, infer_values(&values));
142 }
143
144 json!({
145 "type": "object",
146 "properties": properties,
147 "required": required.into_iter().collect::<Vec<_>>(),
148 "additionalProperties": true
149 })
150}
151
152fn infer_array(values: &[Value]) -> Value {
153 let items = values
154 .iter()
155 .filter_map(Value::as_array)
156 .flat_map(|items| items.iter().cloned())
157 .collect::<Vec<_>>();
158
159 json!({
160 "type": "array",
161 "items": infer_schema(&items)
162 })
163}
164
165fn boundary_value(schema: &Value) -> Value {
166 if let Some(value) = schema.get("const") {
167 return value.clone();
168 }
169
170 if let Some(values) = schema.get("enum").and_then(Value::as_array) {
171 return values.first().cloned().unwrap_or(Value::Null);
172 }
173
174 match schema_type(schema).as_deref() {
175 Some("object") | None => {
176 let mut object = Map::new();
177 let properties = schema
178 .get("properties")
179 .and_then(Value::as_object)
180 .cloned()
181 .unwrap_or_default();
182 let required = schema
183 .get("required")
184 .and_then(Value::as_array)
185 .map(|values| {
186 values
187 .iter()
188 .filter_map(Value::as_str)
189 .map(ToOwned::to_owned)
190 .collect::<Vec<_>>()
191 })
192 .unwrap_or_else(|| properties.keys().cloned().collect());
193
194 for key in required {
195 if let Some(property_schema) = properties.get(&key) {
196 object.insert(key, boundary_value(property_schema));
197 }
198 }
199 Value::Object(object)
200 }
201 Some("integer") => {
202 if let Some(max) = schema.get("maximum").and_then(Value::as_i64) {
203 json!(max)
204 } else if let Some(min) = schema.get("minimum").and_then(Value::as_i64) {
205 json!(min)
206 } else {
207 json!(1)
208 }
209 }
210 Some("number") => {
211 if let Some(max) = schema.get("maximum").and_then(Value::as_f64) {
212 json!(max)
213 } else if let Some(min) = schema.get("minimum").and_then(Value::as_f64) {
214 json!(min)
215 } else {
216 json!(1.0)
217 }
218 }
219 Some("string") => Value::String("wallfacer".to_string()),
220 Some("boolean") => Value::Bool(true),
221 Some("array") => Value::Array(Vec::new()),
222 Some("null") => Value::Null,
223 Some(_) => Value::Null,
224 }
225}
226
227fn schema_type(schema: &Value) -> Option<String> {
228 match schema.get("type") {
229 Some(Value::String(value)) => Some(value.clone()),
230 Some(Value::Array(values)) => values
231 .iter()
232 .filter_map(Value::as_str)
233 .find(|value| *value != "null")
234 .map(ToOwned::to_owned),
235 _ if schema.get("properties").is_some() => Some("object".to_string()),
236 _ => None,
237 }
238}
239
240fn type_name(value: &Value) -> String {
241 match value {
242 Value::Null => "null",
243 Value::Bool(_) => "boolean",
244 Value::Number(number) if number.is_i64() || number.is_u64() => "integer",
245 Value::Number(_) => "number",
246 Value::String(_) => "string",
247 Value::Array(_) => "array",
248 Value::Object(_) => "object",
249 }
250 .to_string()
251}