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 argument_error_fix(tool, message, schema, None)
60}
61
62#[must_use]
65pub fn argument_error_fix(
66 tool: &str,
67 message: &str,
68 schema: &Json,
69 fix: Option<String>,
70) -> SurfaceError {
71 let full = format!("{message} ({})", arguments_hint(tool, schema));
72 let mut data = json!({ "reason": full, "hint": "arguments" });
73 if let Some(fix) = fix
74 && let Some(d) = data.as_object_mut()
75 {
76 d.insert("fix".to_owned(), json!(fix));
77 }
78 SurfaceError::with_data("filter_invalid", full, data)
79}
80
81fn is_string_list(schema: &Json) -> bool {
92 type_of(schema) == Some("array") && schema.get("items").and_then(type_of) == Some("string")
93}
94
95#[must_use]
97pub fn comma_split(s: &str) -> Vec<String> {
98 s.split(',')
99 .map(str::trim)
100 .filter(|p| !p.is_empty())
101 .map(str::to_owned)
102 .collect()
103}
104
105pub fn validate_arguments(tool: &str, schema: &Json, args: &Json) -> Result<Option<Json>> {
111 let Some(obj) = args.as_object() else {
112 if args.is_null() {
113 return Ok(None);
114 }
115 return Err(argument_error(tool, "arguments must be an object", schema));
116 };
117 let mut normalized: Option<Map<String, Json>> = None;
118 let props = properties(schema);
119 let mut given: Vec<&String> = obj.keys().collect();
120 given.sort_unstable();
121 for key in given {
122 if !props.contains_key(key) {
123 return Err(argument_error(
124 tool,
125 &unknown_argument_message(key, props),
126 schema,
127 ));
128 }
129 }
130 let required = required(schema);
131 let mut declared: Vec<(&String, &Json)> = props.iter().collect();
132 declared.sort_unstable_by(|a, b| a.0.cmp(b.0));
133 for (key, prop) in declared {
134 match obj.get(key) {
135 None => {
136 if required.contains(&key.as_str()) {
137 return Err(argument_error(
138 tool,
139 &format!("`{key}` is required"),
140 schema,
141 ));
142 }
143 }
144 Some(value) => {
145 if is_string_list(prop)
146 && let Some(s) = value.as_str()
147 {
148 if s.contains(',') {
152 let mut fixed = obj.clone();
153 fixed.insert(key.clone(), json!(comma_split(s)));
154 return Err(argument_error_fix(
155 tool,
156 &format!("`{key}` must be an array of strings"),
157 schema,
158 Some(omgbase_mutate::canonical_json(&Json::Object(fixed))),
159 ));
160 }
161 normalized
162 .get_or_insert_with(|| obj.clone())
163 .insert(key.clone(), json!([s]));
164 continue;
165 }
166 if !matches_schema(prop, value) {
167 return Err(argument_error(
168 tool,
169 &format!("`{key}` must be {}", phrase_for(prop)),
170 schema,
171 ));
172 }
173 }
174 }
175 }
176 Ok(normalized.map(Json::Object))
177}
178
179#[must_use]
188pub fn unknown_argument_message(key: &str, props: &Map<String, Json>) -> String {
189 let has = |n: &str| props.contains_key(n);
190 let retired =
191 |replacement: &str| format!("`{key}` is no longer an argument — pass {replacement}");
192 if key == "doc" && has("id") && has("path") {
193 return retired("`id` or `path`");
194 }
195 if (key == "block" || key == "node") && has("id") {
196 return retired("`id`");
197 }
198 if key == "heading" && has("section") {
199 return retired("`section` (the heading's text), or blocks_append with the heading's `id`");
200 }
201 if key == "markdown" && has("text") {
202 return retired("`text`");
203 }
204 if key == "ops" && has("effects") {
205 return OPS_RETIRED.to_owned();
206 }
207 if key == "dry_run" {
208 return DRY_RUN_RETIRED.to_owned();
209 }
210 if key == "attrs" && has("checked") {
211 return retired("`checked`");
212 }
213 let candidates: Vec<&str> = props.keys().map(String::as_str).collect();
214 match nearest(key, &candidates) {
215 None => format!("unknown argument `{key}`"),
216 Some(n) => format!("unknown argument `{key}`; did you mean `{n}`?"),
217 }
218}
219
220#[must_use]
223pub fn nearest<'a>(name: &str, candidates: &[&'a str]) -> Option<&'a str> {
224 let mut sorted: Vec<&'a str> = candidates.to_vec();
225 sorted.sort_unstable();
226 let mut best: Option<(&'a str, usize)> = None;
227 for c in sorted {
228 let d = levenshtein(name, c);
229 let prefix =
230 !name.is_empty() && !c.is_empty() && (c.starts_with(name) || name.starts_with(c));
231 if d > 2 && !prefix {
232 continue;
233 }
234 let score = if prefix && d > 2 { 3 } else { d };
235 if best.is_none_or(|(_, s)| score < s) {
236 best = Some((c, score));
237 }
238 }
239 best.map(|(c, _)| c)
240}
241
242#[must_use]
243pub fn levenshtein(a: &str, b: &str) -> usize {
244 let a: Vec<char> = a.chars().collect();
245 let b: Vec<char> = b.chars().collect();
246 if a.is_empty() {
247 return b.len();
248 }
249 if b.is_empty() {
250 return a.len();
251 }
252 let mut prev: Vec<usize> = (0..=b.len()).collect();
253 for (i, ca) in a.iter().enumerate() {
254 let mut cur = vec![i + 1];
255 for (j, cb) in b.iter().enumerate() {
256 let cost = usize::from(ca != cb);
257 cur.push((prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost));
258 }
259 prev = cur;
260 }
261 prev[b.len()]
262}
263
264fn properties(schema: &Json) -> &Map<String, Json> {
267 static EMPTY: std::sync::LazyLock<Map<String, Json>> = std::sync::LazyLock::new(Map::new);
268 schema
269 .get("properties")
270 .and_then(Json::as_object)
271 .unwrap_or(&EMPTY)
272}
273
274fn required(schema: &Json) -> Vec<&str> {
275 schema
276 .get("required")
277 .and_then(Json::as_array)
278 .map(|a| a.iter().filter_map(Json::as_str).collect())
279 .unwrap_or_default()
280}
281
282fn type_of(schema: &Json) -> Option<&str> {
284 match schema.get("type") {
285 Some(Json::String(t)) => Some(t.as_str()),
286 Some(Json::Array(ts)) => ts.iter().filter_map(Json::as_str).find(|t| *t != "null"),
287 _ => None,
288 }
289}
290
291fn nullable(schema: &Json) -> bool {
292 matches!(schema.get("type"), Some(Json::Array(ts)) if ts.iter().any(|t| t == "null"))
293}
294
295fn matches_schema(schema: &Json, value: &Json) -> bool {
298 if value.is_null() {
299 return nullable(schema);
300 }
301 if let Some(options) = schema.get("enum").and_then(Json::as_array) {
302 return options.contains(value);
303 }
304 if schema.get("oneOf").is_some() {
305 return match value {
307 Json::String(s) => s == "start" || s == "end",
308 Json::Object(o) => {
309 o.get("before").is_some_and(Json::is_string)
310 || o.get("after").is_some_and(Json::is_string)
311 }
312 _ => false,
313 };
314 }
315 match type_of(schema) {
316 Some("string") => value.is_string(),
317 Some("integer") => value.as_f64().is_some_and(|n| n.fract() == 0.0),
318 Some("boolean") => value.is_boolean(),
319 Some("object") => value.is_object(),
320 Some("array") => value.as_array().is_some_and(|items| {
321 let item_type = schema.get("items").and_then(type_of);
322 items.iter().all(|v| match item_type {
323 Some("string") => v.is_string(),
324 Some("integer") => v.as_f64().is_some_and(|n| n.fract() == 0.0),
325 Some("object") => v.is_object(),
326 _ => true,
327 })
328 }),
329 _ => true,
330 }
331}
332
333#[must_use]
336pub fn phrase_for(schema: &Json) -> String {
337 if let Some(options) = schema.get("enum").and_then(Json::as_array) {
338 let names: Vec<&str> = options.iter().filter_map(Json::as_str).collect();
339 return format!("one of {}", names.join(", "));
340 }
341 if schema.get("oneOf").is_some() {
342 return "\"start\", \"end\", {before} or {after}".to_owned();
343 }
344 match type_of(schema) {
345 Some("string") => "a string".to_owned(),
346 Some("integer") => "an integer".to_owned(),
347 Some("boolean") => "a boolean".to_owned(),
348 Some("object") => "an object".to_owned(),
349 Some("array") => {
350 let inner = match schema.get("items").and_then(type_of) {
351 Some("string") => "strings",
352 Some("integer") => "integers",
353 _ => "objects",
354 };
355 format!("an array of {inner}")
356 }
357 _ => "valid".to_owned(),
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 fn schema() -> Json {
366 json!({ "type": "object", "properties": {
367 "id": { "type": "string" }, "path": { "type": "string" }, "include_ids": { "type": "boolean" }, "repo": { "type": "string" }
368 }, "required": [] })
369 }
370
371 #[test]
372 fn hints_list_the_shape_in_bytewise_order() {
373 assert_eq!(
374 arguments_hint("docs_read", &schema()),
375 "docs_read takes id?, include_ids?, path?, repo?"
376 );
377 assert_eq!(
378 arguments_hint(
379 "repos",
380 &json!({ "type": "object", "properties": {}, "required": [] })
381 ),
382 "repos takes no arguments"
383 );
384 }
385
386 #[test]
387 fn unknown_names_are_corrected() {
388 let s = schema();
389 let e = validate_arguments("docs_read", &s, &json!({ "doc": "a.md" })).unwrap_err();
390 assert_eq!(
391 e.message,
392 "`doc` is no longer an argument — pass `id` or `path` (docs_read takes id?, include_ids?, path?, repo?)"
393 );
394 assert_eq!(e.data.unwrap()["hint"], "arguments");
395 let e = validate_arguments("docs_read", &s, &json!({ "include_id": true })).unwrap_err();
396 assert!(
397 e.message
398 .starts_with("unknown argument `include_id`; did you mean `include_ids`?")
399 );
400 let e = validate_arguments("docs_read", &s, &json!({ "zzz": true })).unwrap_err();
401 assert!(e.message.starts_with("unknown argument `zzz` ("));
402 let q = json!({ "type": "object", "properties": { "q": { "type": "string" }, "limit": { "type": "integer" } }, "required": ["q"] });
403 let e = validate_arguments("blocks_search", &q, &json!({ "query": "x" })).unwrap_err();
404 assert!(
405 e.message
406 .starts_with("unknown argument `query`; did you mean `q`?")
407 );
408 let e = validate_arguments("blocks_search", &q, &json!({})).unwrap_err();
409 assert_eq!(e.message, "`q` is required (blocks_search takes limit?, q)");
410 let e = validate_arguments("blocks_search", &q, &json!({ "q": "x", "limit": "5" }))
411 .unwrap_err();
412 assert_eq!(
413 e.message,
414 "`limit` must be an integer (blocks_search takes limit?, q)"
415 );
416 assert!(validate_arguments("blocks_search", &q, &json!({ "q": "x", "limit": 5 })).is_ok());
417 }
418
419 #[test]
421 fn retired_write_inputs_name_their_replacement() {
422 let apply = json!({ "type": "object", "properties": { "effects": { "type": "array" }, "reason": { "type": "string" } }, "required": ["effects"] });
423 let e = validate_arguments("apply", &apply, &json!({ "ops": [] })).unwrap_err();
424 assert_eq!(
425 e.message,
426 "`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?)"
427 );
428 let e = validate_arguments("apply", &apply, &json!({ "effects": [], "dry_run": true }))
429 .unwrap_err();
430 assert_eq!(
431 e.message,
432 "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (apply takes effects, reason?)"
433 );
434 let e = validate_arguments("apply", &apply, &json!({})).unwrap_err();
435 assert_eq!(
436 e.message,
437 "`effects` is required (apply takes effects, reason?)"
438 );
439 let update = json!({ "type": "object", "properties": { "id": { "type": "string" }, "checked": { "type": "boolean" } }, "required": ["id"] });
440 let e = validate_arguments(
441 "blocks_update",
442 &update,
443 &json!({ "id": "b_1", "attrs": {} }),
444 )
445 .unwrap_err();
446 assert_eq!(
447 e.message,
448 "`attrs` is no longer an argument — pass `checked` (blocks_update takes checked?, id)"
449 );
450 let e = validate_arguments("blocks_update", &update, &json!({ "id": "b_1", "zzz": 1 }))
452 .unwrap_err();
453 assert!(e.message.starts_with("unknown argument `zzz` ("));
454 }
455
456 #[test]
457 fn distance_and_nearest() {
458 assert_eq!(levenshtein("kitten", "sitting"), 3);
459 assert_eq!(levenshtein("", "ab"), 2);
460 assert_eq!(nearest("q", &["query", "limit"]), Some("query"));
461 assert_eq!(nearest("id", &["id", "path"]), Some("id"));
462 assert_eq!(nearest("zzzz", &["id", "path"]), None);
463 }
464
465 #[test]
469 fn string_lists_take_one_bare_string_and_fix_a_comma_joined_one() {
470 let s = json!({ "type": "object", "properties": {
471 "docs": { "type": "array", "items": { "type": "string" } }, "include_ids": { "type": "boolean" }, "repo": { "type": "string" }
472 }, "required": ["docs"] });
473 assert_eq!(
474 validate_arguments("docs_read_many", &s, &json!({ "docs": "a.md" })).unwrap(),
475 Some(json!({ "docs": ["a.md"] }))
476 );
477 assert_eq!(
478 validate_arguments("docs_read_many", &s, &json!({ "docs": ["a.md"] })).unwrap(),
479 None
480 );
481 let e = validate_arguments(
482 "docs_read_many",
483 &s,
484 &json!({ "docs": "a.md, b.md,", "include_ids": true }),
485 )
486 .unwrap_err();
487 assert_eq!(
488 e.message,
489 "`docs` must be an array of strings (docs_read_many takes docs, include_ids?, repo?)"
490 );
491 let d = e.data.unwrap();
492 assert_eq!(d["hint"], "arguments");
493 assert_eq!(d["fix"], r#"{"docs":["a.md","b.md"],"include_ids":true}"#);
494 let e = validate_arguments("docs_read_many", &s, &json!({ "docs": 7 })).unwrap_err();
495 assert!(
496 e.message
497 .starts_with("`docs` must be an array of strings (")
498 );
499 assert!(e.data.unwrap().get("fix").is_none());
500 assert_eq!(comma_split(" a , ,b,"), ["a", "b"]);
501 }
502}