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