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 {
97 type_of(schema) == Some("array") && schema.get("items").and_then(type_of) == Some("string")
98}
99
100#[must_use]
102pub fn comma_split(s: &str) -> Vec<String> {
103 s.split(',')
104 .map(str::trim)
105 .filter(|p| !p.is_empty())
106 .map(str::to_owned)
107 .collect()
108}
109
110#[derive(Clone, Debug, PartialEq)]
113pub enum JsonArrayString {
114 List(Vec<String>),
116 Fix(Vec<Json>),
120}
121
122#[must_use]
125pub fn json_array_string(s: &str) -> Option<JsonArrayString> {
126 let trimmed = s.trim();
127 if !trimmed.starts_with('[') {
128 return None;
129 }
130 let comma = || JsonArrayString::Fix(comma_split(s).into_iter().map(Json::String).collect());
131 let Ok(parsed) = serde_json::from_str::<Json>(trimmed) else {
132 return Some(comma());
133 };
134 let Json::Array(items) = parsed else {
135 return Some(comma());
136 };
137 if items.iter().all(Json::is_string) {
138 return Some(JsonArrayString::List(
139 items
140 .into_iter()
141 .filter_map(|v| v.as_str().map(str::to_owned))
142 .collect(),
143 ));
144 }
145 Some(JsonArrayString::Fix(items))
146}
147
148pub fn validate_arguments(tool: &str, schema: &Json, args: &Json) -> Result<Option<Json>> {
154 let Some(obj) = args.as_object() else {
155 if args.is_null() {
156 return Ok(None);
157 }
158 return Err(argument_error(tool, "arguments must be an object", schema));
159 };
160 let mut normalized: Option<Map<String, Json>> = None;
161 let props = properties(schema);
162 let mut given: Vec<&String> = obj.keys().collect();
163 given.sort_unstable();
164 for key in given {
165 if !props.contains_key(key) {
166 return Err(argument_error(
167 tool,
168 &unknown_argument_message(key, props),
169 schema,
170 ));
171 }
172 }
173 let required = required(schema);
174 let mut declared: Vec<(&String, &Json)> = props.iter().collect();
175 declared.sort_unstable_by(|a, b| a.0.cmp(b.0));
176 for (key, prop) in declared {
177 match obj.get(key) {
178 None => {
179 if required.contains(&key.as_str()) {
180 return Err(argument_error(
181 tool,
182 &format!("`{key}` is required"),
183 schema,
184 ));
185 }
186 }
187 Some(value) => {
188 if is_string_list(prop)
189 && let Some(s) = value.as_str()
190 {
191 if let Some(json) = json_array_string(s) {
197 match json {
198 JsonArrayString::Fix(list) => {
199 let mut fixed = obj.clone();
200 fixed.insert(key.clone(), Json::Array(list));
201 return Err(argument_error_fix(
202 tool,
203 &format!("`{key}` must be an array of strings"),
204 schema,
205 Some(omgbase_mutate::canonical_json(&Json::Object(fixed))),
206 ));
207 }
208 JsonArrayString::List(list) => {
209 let list = json!(list);
210 if !matches_schema(prop, &list) {
211 return Err(argument_error(
212 tool,
213 &format!("`{key}` must be {}", phrase_for(prop)),
214 schema,
215 ));
216 }
217 normalized
218 .get_or_insert_with(|| obj.clone())
219 .insert(key.clone(), list);
220 continue;
221 }
222 }
223 }
224 if s.contains(',') {
228 let mut fixed = obj.clone();
229 fixed.insert(key.clone(), json!(comma_split(s)));
230 return Err(argument_error_fix(
231 tool,
232 &format!("`{key}` must be an array of strings"),
233 schema,
234 Some(omgbase_mutate::canonical_json(&Json::Object(fixed))),
235 ));
236 }
237 normalized
238 .get_or_insert_with(|| obj.clone())
239 .insert(key.clone(), json!([s]));
240 continue;
241 }
242 if !matches_schema(prop, value) {
243 return Err(argument_error(
244 tool,
245 &format!("`{key}` must be {}", phrase_for(prop)),
246 schema,
247 ));
248 }
249 }
250 }
251 }
252 Ok(normalized.map(Json::Object))
253}
254
255#[must_use]
264pub fn unknown_argument_message(key: &str, props: &Map<String, Json>) -> String {
265 let has = |n: &str| props.contains_key(n);
266 let retired =
267 |replacement: &str| format!("`{key}` is no longer an argument — pass {replacement}");
268 if key == "doc" && has("id") && has("path") {
269 return retired("`id` or `path`");
270 }
271 if (key == "block" || key == "node") && has("id") {
272 return retired("`id`");
273 }
274 if key == "heading" && has("section") {
275 return retired("`section` (the heading's text), or blocks_append with the heading's `id`");
276 }
277 if key == "markdown" && has("text") {
278 return retired("`text`");
279 }
280 if key == "ops" && has("effects") {
281 return OPS_RETIRED.to_owned();
282 }
283 if key == "dry_run" {
284 return DRY_RUN_RETIRED.to_owned();
285 }
286 if key == "attrs" && has("checked") {
287 return retired("`checked`");
288 }
289 let candidates: Vec<&str> = props.keys().map(String::as_str).collect();
290 match nearest(key, &candidates) {
291 None => format!("unknown argument `{key}`"),
292 Some(n) => format!("unknown argument `{key}`; did you mean `{n}`?"),
293 }
294}
295
296#[must_use]
299pub fn nearest<'a>(name: &str, candidates: &[&'a str]) -> Option<&'a str> {
300 let mut sorted: Vec<&'a str> = candidates.to_vec();
301 sorted.sort_unstable();
302 let mut best: Option<(&'a str, usize)> = None;
303 for c in sorted {
304 let d = levenshtein(name, c);
305 let prefix =
306 !name.is_empty() && !c.is_empty() && (c.starts_with(name) || name.starts_with(c));
307 if d > 2 && !prefix {
308 continue;
309 }
310 let score = if prefix && d > 2 { 3 } else { d };
311 if best.is_none_or(|(_, s)| score < s) {
312 best = Some((c, score));
313 }
314 }
315 best.map(|(c, _)| c)
316}
317
318#[must_use]
319pub fn levenshtein(a: &str, b: &str) -> usize {
320 let a: Vec<char> = a.chars().collect();
321 let b: Vec<char> = b.chars().collect();
322 if a.is_empty() {
323 return b.len();
324 }
325 if b.is_empty() {
326 return a.len();
327 }
328 let mut prev: Vec<usize> = (0..=b.len()).collect();
329 for (i, ca) in a.iter().enumerate() {
330 let mut cur = vec![i + 1];
331 for (j, cb) in b.iter().enumerate() {
332 let cost = usize::from(ca != cb);
333 cur.push((prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost));
334 }
335 prev = cur;
336 }
337 prev[b.len()]
338}
339
340fn properties(schema: &Json) -> &Map<String, Json> {
343 static EMPTY: std::sync::LazyLock<Map<String, Json>> = std::sync::LazyLock::new(Map::new);
344 schema
345 .get("properties")
346 .and_then(Json::as_object)
347 .unwrap_or(&EMPTY)
348}
349
350fn required(schema: &Json) -> Vec<&str> {
351 schema
352 .get("required")
353 .and_then(Json::as_array)
354 .map(|a| a.iter().filter_map(Json::as_str).collect())
355 .unwrap_or_default()
356}
357
358fn type_of(schema: &Json) -> Option<&str> {
360 match schema.get("type") {
361 Some(Json::String(t)) => Some(t.as_str()),
362 Some(Json::Array(ts)) => ts.iter().filter_map(Json::as_str).find(|t| *t != "null"),
363 _ => None,
364 }
365}
366
367fn nullable(schema: &Json) -> bool {
368 matches!(schema.get("type"), Some(Json::Array(ts)) if ts.iter().any(|t| t == "null"))
369}
370
371fn matches_schema(schema: &Json, value: &Json) -> bool {
374 if value.is_null() {
375 return nullable(schema);
376 }
377 if let Some(options) = schema.get("enum").and_then(Json::as_array) {
378 return options.contains(value);
379 }
380 if schema.get("oneOf").is_some() {
381 return match value {
383 Json::String(s) => s == "start" || s == "end",
384 Json::Object(o) => {
385 o.get("before").is_some_and(Json::is_string)
386 || o.get("after").is_some_and(Json::is_string)
387 }
388 _ => false,
389 };
390 }
391 match type_of(schema) {
392 Some("string") => value.is_string(),
393 Some("integer") => value.as_f64().is_some_and(|n| n.fract() == 0.0),
394 Some("boolean") => value.is_boolean(),
395 Some("object") => value.is_object(),
396 Some("array") => value.as_array().is_some_and(|items| {
397 let item_type = schema.get("items").and_then(type_of);
398 items.iter().all(|v| match item_type {
399 Some("string") => v.is_string(),
400 Some("integer") => v.as_f64().is_some_and(|n| n.fract() == 0.0),
401 Some("object") => v.is_object(),
402 _ => true,
403 })
404 }),
405 _ => true,
406 }
407}
408
409#[must_use]
412pub fn phrase_for(schema: &Json) -> String {
413 if let Some(options) = schema.get("enum").and_then(Json::as_array) {
414 let names: Vec<&str> = options.iter().filter_map(Json::as_str).collect();
415 return format!("one of {}", names.join(", "));
416 }
417 if schema.get("oneOf").is_some() {
418 return "\"start\", \"end\", {before} or {after}".to_owned();
419 }
420 match type_of(schema) {
421 Some("string") => "a string".to_owned(),
422 Some("integer") => "an integer".to_owned(),
423 Some("boolean") => "a boolean".to_owned(),
424 Some("object") => "an object".to_owned(),
425 Some("array") => {
426 let inner = match schema.get("items").and_then(type_of) {
427 Some("string") => "strings",
428 Some("integer") => "integers",
429 _ => "objects",
430 };
431 format!("an array of {inner}")
432 }
433 _ => "valid".to_owned(),
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440
441 fn schema() -> Json {
442 json!({ "type": "object", "properties": {
443 "id": { "type": "string" }, "path": { "type": "string" }, "include_ids": { "type": "boolean" }, "repo": { "type": "string" }
444 }, "required": [] })
445 }
446
447 #[test]
448 fn hints_list_the_shape_in_bytewise_order() {
449 assert_eq!(
450 arguments_hint("docs_read", &schema()),
451 "docs_read takes id?, include_ids?, path?, repo?"
452 );
453 assert_eq!(
454 arguments_hint(
455 "repos",
456 &json!({ "type": "object", "properties": {}, "required": [] })
457 ),
458 "repos takes no arguments"
459 );
460 }
461
462 #[test]
463 fn unknown_names_are_corrected() {
464 let s = schema();
465 let e = validate_arguments("docs_read", &s, &json!({ "doc": "a.md" })).unwrap_err();
466 assert_eq!(
467 e.message,
468 "`doc` is no longer an argument — pass `id` or `path` (docs_read takes id?, include_ids?, path?, repo?)"
469 );
470 assert_eq!(e.data.unwrap()["hint"], "arguments");
471 let e = validate_arguments("docs_read", &s, &json!({ "include_id": true })).unwrap_err();
472 assert!(
473 e.message
474 .starts_with("unknown argument `include_id`; did you mean `include_ids`?")
475 );
476 let e = validate_arguments("docs_read", &s, &json!({ "zzz": true })).unwrap_err();
477 assert!(e.message.starts_with("unknown argument `zzz` ("));
478 let q = json!({ "type": "object", "properties": { "q": { "type": "string" }, "limit": { "type": "integer" } }, "required": ["q"] });
479 let e = validate_arguments("blocks_search", &q, &json!({ "query": "x" })).unwrap_err();
480 assert!(
481 e.message
482 .starts_with("unknown argument `query`; did you mean `q`?")
483 );
484 let e = validate_arguments("blocks_search", &q, &json!({})).unwrap_err();
485 assert_eq!(e.message, "`q` is required (blocks_search takes limit?, q)");
486 let e = validate_arguments("blocks_search", &q, &json!({ "q": "x", "limit": "5" }))
487 .unwrap_err();
488 assert_eq!(
489 e.message,
490 "`limit` must be an integer (blocks_search takes limit?, q)"
491 );
492 assert!(validate_arguments("blocks_search", &q, &json!({ "q": "x", "limit": 5 })).is_ok());
493 }
494
495 #[test]
497 fn retired_write_inputs_name_their_replacement() {
498 let apply = json!({ "type": "object", "properties": { "effects": { "type": "array" }, "reason": { "type": "string" } }, "required": ["effects"] });
499 let e = validate_arguments("apply", &apply, &json!({ "ops": [] })).unwrap_err();
500 assert_eq!(
501 e.message,
502 "`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?)"
503 );
504 let e = validate_arguments("apply", &apply, &json!({ "effects": [], "dry_run": true }))
505 .unwrap_err();
506 assert_eq!(
507 e.message,
508 "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (apply takes effects, reason?)"
509 );
510 let e = validate_arguments("apply", &apply, &json!({})).unwrap_err();
511 assert_eq!(
512 e.message,
513 "`effects` is required (apply takes effects, reason?)"
514 );
515 let update = json!({ "type": "object", "properties": { "id": { "type": "string" }, "checked": { "type": "boolean" } }, "required": ["id"] });
516 let e = validate_arguments(
517 "blocks_update",
518 &update,
519 &json!({ "id": "b_1", "attrs": {} }),
520 )
521 .unwrap_err();
522 assert_eq!(
523 e.message,
524 "`attrs` is no longer an argument — pass `checked` (blocks_update takes checked?, id)"
525 );
526 let e = validate_arguments("blocks_update", &update, &json!({ "id": "b_1", "zzz": 1 }))
528 .unwrap_err();
529 assert!(e.message.starts_with("unknown argument `zzz` ("));
530 }
531
532 #[test]
533 fn distance_and_nearest() {
534 assert_eq!(levenshtein("kitten", "sitting"), 3);
535 assert_eq!(levenshtein("", "ab"), 2);
536 assert_eq!(nearest("q", &["query", "limit"]), Some("query"));
537 assert_eq!(nearest("id", &["id", "path"]), Some("id"));
538 assert_eq!(nearest("zzzz", &["id", "path"]), None);
539 }
540
541 #[test]
545 fn string_lists_take_one_bare_string_and_fix_a_comma_joined_one() {
546 let s = json!({ "type": "object", "properties": {
547 "docs": { "type": "array", "items": { "type": "string" } }, "include_ids": { "type": "boolean" }, "repo": { "type": "string" }
548 }, "required": ["docs"] });
549 assert_eq!(
550 validate_arguments("docs_read_many", &s, &json!({ "docs": "a.md" })).unwrap(),
551 Some(json!({ "docs": ["a.md"] }))
552 );
553 assert_eq!(
554 validate_arguments("docs_read_many", &s, &json!({ "docs": ["a.md"] })).unwrap(),
555 None
556 );
557 let e = validate_arguments(
558 "docs_read_many",
559 &s,
560 &json!({ "docs": "a.md, b.md,", "include_ids": true }),
561 )
562 .unwrap_err();
563 assert_eq!(
564 e.message,
565 "`docs` must be an array of strings (docs_read_many takes docs, include_ids?, repo?)"
566 );
567 let d = e.data.unwrap();
568 assert_eq!(d["hint"], "arguments");
569 assert_eq!(d["fix"], r#"{"docs":["a.md","b.md"],"include_ids":true}"#);
570 let e = validate_arguments("docs_read_many", &s, &json!({ "docs": 7 })).unwrap_err();
571 assert!(
572 e.message
573 .starts_with("`docs` must be an array of strings (")
574 );
575 assert!(e.data.unwrap().get("fix").is_none());
576 assert_eq!(comma_split(" a , ,b,"), ["a", "b"]);
577 }
578
579 #[test]
583 fn string_lists_take_a_stringified_json_array_of_strings() {
584 let s = json!({ "type": "object", "properties": {
585 "docs": { "type": "array", "items": { "type": "string" } }, "include_ids": { "type": "boolean" }, "repo": { "type": "string" }
586 }, "required": ["docs"] });
587 assert_eq!(
588 validate_arguments(
589 "docs_read_many",
590 &s,
591 &json!({ "docs": "[\"a.md\",\"b.md\"]" })
592 )
593 .unwrap(),
594 Some(json!({ "docs": ["a.md", "b.md"] }))
595 );
596 assert_eq!(
598 validate_arguments("docs_read_many", &s, &json!({ "docs": " [\"a.md\"] " })).unwrap(),
599 Some(json!({ "docs": ["a.md"] }))
600 );
601 let e = validate_arguments("docs_read_many", &s, &json!({ "docs": "[1, 2]" })).unwrap_err();
603 assert_eq!(
604 e.message,
605 "`docs` must be an array of strings (docs_read_many takes docs, include_ids?, repo?)"
606 );
607 assert_eq!(e.data.unwrap()["fix"], r#"{"docs":[1,2]}"#);
608 let e = validate_arguments(
610 "docs_read_many",
611 &s,
612 &json!({ "docs": "[\"a.md\", \"b.md\"", "include_ids": true }),
613 )
614 .unwrap_err();
615 assert_eq!(
616 e.data.unwrap()["fix"],
617 r#"{"docs":["[\"a.md\"","\"b.md\""],"include_ids":true}"#
618 );
619 let e = validate_arguments("docs_read_many", &s, &json!({ "docs": "[{}]" })).unwrap_err();
621 assert_eq!(e.data.unwrap()["fix"], r#"{"docs":[{}]}"#);
622 assert_eq!(json_array_string("a.md"), None);
623 assert_eq!(
624 json_array_string("[\"x\"]"),
625 Some(JsonArrayString::List(vec!["x".to_owned()]))
626 );
627 assert_eq!(
628 json_array_string("[x"),
629 Some(JsonArrayString::Fix(vec![json!("[x")]))
630 );
631 }
632}