1use serde_json::{Map, Value as Json, json};
23
24use crate::error::{Result, SurfaceError};
25
26#[must_use]
29pub fn arguments_hint(tool: &str, schema: &Json) -> String {
30 let mut names: Vec<&str> = properties(schema).keys().map(String::as_str).collect();
31 names.sort_unstable();
32 if names.is_empty() {
33 return format!("{tool} takes no arguments");
34 }
35 let required = required(schema);
36 let list: Vec<String> = names
37 .iter()
38 .map(|n| {
39 if required.contains(n) {
40 (*n).to_owned()
41 } else {
42 format!("{n}?")
43 }
44 })
45 .collect();
46 format!("{tool} takes {}", list.join(", "))
47}
48
49#[must_use]
51pub fn argument_error(tool: &str, message: &str, schema: &Json) -> SurfaceError {
52 let full = format!("{message} ({})", arguments_hint(tool, schema));
53 SurfaceError::with_data(
54 "filter_invalid",
55 full.clone(),
56 json!({ "reason": full, "hint": "arguments" }),
57 )
58}
59
60pub fn validate_arguments(tool: &str, schema: &Json, args: &Json) -> Result<()> {
64 let Some(obj) = args.as_object() else {
65 if args.is_null() {
66 return Ok(());
67 }
68 return Err(argument_error(tool, "arguments must be an object", schema));
69 };
70 let props = properties(schema);
71 let mut given: Vec<&String> = obj.keys().collect();
72 given.sort_unstable();
73 for key in given {
74 if !props.contains_key(key) {
75 return Err(argument_error(
76 tool,
77 &unknown_argument_message(key, props),
78 schema,
79 ));
80 }
81 }
82 let required = required(schema);
83 let mut declared: Vec<(&String, &Json)> = props.iter().collect();
84 declared.sort_unstable_by(|a, b| a.0.cmp(b.0));
85 for (key, prop) in declared {
86 match obj.get(key) {
87 None => {
88 if required.contains(&key.as_str()) {
89 return Err(argument_error(
90 tool,
91 &format!("`{key}` is required"),
92 schema,
93 ));
94 }
95 }
96 Some(value) => {
97 if !matches_schema(prop, value) {
98 return Err(argument_error(
99 tool,
100 &format!("`{key}` must be {}", phrase_for(prop)),
101 schema,
102 ));
103 }
104 }
105 }
106 }
107 Ok(())
108}
109
110#[must_use]
116pub fn unknown_argument_message(key: &str, props: &Map<String, Json>) -> String {
117 let has = |n: &str| props.contains_key(n);
118 let retired =
119 |replacement: &str| format!("`{key}` is no longer an argument — pass {replacement}");
120 if key == "doc" && has("id") && has("path") {
121 return retired("`id` or `path`");
122 }
123 if (key == "block" || key == "node") && has("id") {
124 return retired("`id`");
125 }
126 if key == "heading" && has("section") {
127 return retired("`section` (the heading's text), or blocks_append with the heading's `id`");
128 }
129 if key == "markdown" && has("text") {
130 return retired("`text`");
131 }
132 let candidates: Vec<&str> = props.keys().map(String::as_str).collect();
133 match nearest(key, &candidates) {
134 None => format!("unknown argument `{key}`"),
135 Some(n) => format!("unknown argument `{key}`; did you mean `{n}`?"),
136 }
137}
138
139#[must_use]
142pub fn nearest<'a>(name: &str, candidates: &[&'a str]) -> Option<&'a str> {
143 let mut sorted: Vec<&'a str> = candidates.to_vec();
144 sorted.sort_unstable();
145 let mut best: Option<(&'a str, usize)> = None;
146 for c in sorted {
147 let d = levenshtein(name, c);
148 let prefix =
149 !name.is_empty() && !c.is_empty() && (c.starts_with(name) || name.starts_with(c));
150 if d > 2 && !prefix {
151 continue;
152 }
153 let score = if prefix && d > 2 { 3 } else { d };
154 if best.is_none_or(|(_, s)| score < s) {
155 best = Some((c, score));
156 }
157 }
158 best.map(|(c, _)| c)
159}
160
161#[must_use]
162pub fn levenshtein(a: &str, b: &str) -> usize {
163 let a: Vec<char> = a.chars().collect();
164 let b: Vec<char> = b.chars().collect();
165 if a.is_empty() {
166 return b.len();
167 }
168 if b.is_empty() {
169 return a.len();
170 }
171 let mut prev: Vec<usize> = (0..=b.len()).collect();
172 for (i, ca) in a.iter().enumerate() {
173 let mut cur = vec![i + 1];
174 for (j, cb) in b.iter().enumerate() {
175 let cost = usize::from(ca != cb);
176 cur.push((prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost));
177 }
178 prev = cur;
179 }
180 prev[b.len()]
181}
182
183fn properties(schema: &Json) -> &Map<String, Json> {
186 static EMPTY: std::sync::LazyLock<Map<String, Json>> = std::sync::LazyLock::new(Map::new);
187 schema
188 .get("properties")
189 .and_then(Json::as_object)
190 .unwrap_or(&EMPTY)
191}
192
193fn required(schema: &Json) -> Vec<&str> {
194 schema
195 .get("required")
196 .and_then(Json::as_array)
197 .map(|a| a.iter().filter_map(Json::as_str).collect())
198 .unwrap_or_default()
199}
200
201fn type_of(schema: &Json) -> Option<&str> {
203 match schema.get("type") {
204 Some(Json::String(t)) => Some(t.as_str()),
205 Some(Json::Array(ts)) => ts.iter().filter_map(Json::as_str).find(|t| *t != "null"),
206 _ => None,
207 }
208}
209
210fn nullable(schema: &Json) -> bool {
211 matches!(schema.get("type"), Some(Json::Array(ts)) if ts.iter().any(|t| t == "null"))
212}
213
214fn matches_schema(schema: &Json, value: &Json) -> bool {
217 if value.is_null() {
218 return nullable(schema);
219 }
220 if let Some(options) = schema.get("enum").and_then(Json::as_array) {
221 return options.contains(value);
222 }
223 if schema.get("oneOf").is_some() {
224 return match value {
226 Json::String(s) => s == "start" || s == "end",
227 Json::Object(o) => {
228 o.get("before").is_some_and(Json::is_string)
229 || o.get("after").is_some_and(Json::is_string)
230 }
231 _ => false,
232 };
233 }
234 match type_of(schema) {
235 Some("string") => value.is_string(),
236 Some("integer") => value.as_f64().is_some_and(|n| n.fract() == 0.0),
237 Some("boolean") => value.is_boolean(),
238 Some("object") => value.is_object(),
239 Some("array") => value.as_array().is_some_and(|items| {
240 let item_type = schema.get("items").and_then(type_of);
241 items.iter().all(|v| match item_type {
242 Some("string") => v.is_string(),
243 Some("integer") => v.as_f64().is_some_and(|n| n.fract() == 0.0),
244 Some("object") => v.is_object(),
245 _ => true,
246 })
247 }),
248 _ => true,
249 }
250}
251
252#[must_use]
255pub fn phrase_for(schema: &Json) -> String {
256 if let Some(options) = schema.get("enum").and_then(Json::as_array) {
257 let names: Vec<&str> = options.iter().filter_map(Json::as_str).collect();
258 return format!("one of {}", names.join(", "));
259 }
260 if schema.get("oneOf").is_some() {
261 return "\"start\", \"end\", {before} or {after}".to_owned();
262 }
263 match type_of(schema) {
264 Some("string") => "a string".to_owned(),
265 Some("integer") => "an integer".to_owned(),
266 Some("boolean") => "a boolean".to_owned(),
267 Some("object") => "an object".to_owned(),
268 Some("array") => {
269 let inner = match schema.get("items").and_then(type_of) {
270 Some("string") => "strings",
271 Some("integer") => "integers",
272 _ => "objects",
273 };
274 format!("an array of {inner}")
275 }
276 _ => "valid".to_owned(),
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 fn schema() -> Json {
285 json!({ "type": "object", "properties": {
286 "id": { "type": "string" }, "path": { "type": "string" }, "include_ids": { "type": "boolean" }, "repo": { "type": "string" }
287 }, "required": [] })
288 }
289
290 #[test]
291 fn hints_list_the_shape_in_bytewise_order() {
292 assert_eq!(
293 arguments_hint("docs_read", &schema()),
294 "docs_read takes id?, include_ids?, path?, repo?"
295 );
296 assert_eq!(
297 arguments_hint(
298 "repos",
299 &json!({ "type": "object", "properties": {}, "required": [] })
300 ),
301 "repos takes no arguments"
302 );
303 }
304
305 #[test]
306 fn unknown_names_are_corrected() {
307 let s = schema();
308 let e = validate_arguments("docs_read", &s, &json!({ "doc": "a.md" })).unwrap_err();
309 assert_eq!(
310 e.message,
311 "`doc` is no longer an argument — pass `id` or `path` (docs_read takes id?, include_ids?, path?, repo?)"
312 );
313 assert_eq!(e.data.unwrap()["hint"], "arguments");
314 let e = validate_arguments("docs_read", &s, &json!({ "include_id": true })).unwrap_err();
315 assert!(
316 e.message
317 .starts_with("unknown argument `include_id`; did you mean `include_ids`?")
318 );
319 let e = validate_arguments("docs_read", &s, &json!({ "zzz": true })).unwrap_err();
320 assert!(e.message.starts_with("unknown argument `zzz` ("));
321 let q = json!({ "type": "object", "properties": { "q": { "type": "string" }, "limit": { "type": "integer" } }, "required": ["q"] });
322 let e = validate_arguments("blocks_search", &q, &json!({ "query": "x" })).unwrap_err();
323 assert!(
324 e.message
325 .starts_with("unknown argument `query`; did you mean `q`?")
326 );
327 let e = validate_arguments("blocks_search", &q, &json!({})).unwrap_err();
328 assert_eq!(e.message, "`q` is required (blocks_search takes limit?, q)");
329 let e = validate_arguments("blocks_search", &q, &json!({ "q": "x", "limit": "5" }))
330 .unwrap_err();
331 assert_eq!(
332 e.message,
333 "`limit` must be an integer (blocks_search takes limit?, q)"
334 );
335 assert!(validate_arguments("blocks_search", &q, &json!({ "q": "x", "limit": 5 })).is_ok());
336 }
337
338 #[test]
339 fn distance_and_nearest() {
340 assert_eq!(levenshtein("kitten", "sitting"), 3);
341 assert_eq!(levenshtein("", "ab"), 2);
342 assert_eq!(nearest("q", &["query", "limit"]), Some("query"));
343 assert_eq!(nearest("id", &["id", "path"]), Some("id"));
344 assert_eq!(nearest("zzzz", &["id", "path"]), None);
345 }
346}