1use serde_json::{Map, Value as Json, json};
25
26use crate::error::{Result, SurfaceError};
27
28pub 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)";
31pub const DRY_RUN_RETIRED: &str =
33 "`dry_run` is no longer an argument — preview with `plan { effects: [...] }`";
34
35#[must_use]
38pub fn arguments_hint(tool: &str, schema: &Json) -> String {
39 let mut names: Vec<&str> = properties(schema).keys().map(String::as_str).collect();
40 names.sort_unstable();
41 if names.is_empty() {
42 return format!("{tool} takes no arguments");
43 }
44 let required = required(schema);
45 let list: Vec<String> = names
46 .iter()
47 .map(|n| {
48 if required.contains(n) {
49 (*n).to_owned()
50 } else {
51 format!("{n}?")
52 }
53 })
54 .collect();
55 format!("{tool} takes {}", list.join(", "))
56}
57
58#[must_use]
60pub fn argument_error(tool: &str, message: &str, schema: &Json) -> SurfaceError {
61 argument_error_fix(tool, message, schema, None)
62}
63
64#[must_use]
67pub fn argument_error_fix(
68 tool: &str,
69 message: &str,
70 schema: &Json,
71 fix: Option<String>,
72) -> SurfaceError {
73 let full = format!("{message} ({})", arguments_hint(tool, schema));
74 let mut data = json!({ "reason": full, "hint": "arguments" });
75 if let Some(fix) = fix
76 && let Some(d) = data.as_object_mut()
77 {
78 d.insert("fix".to_owned(), json!(fix));
79 }
80 SurfaceError::with_data("filter_invalid", full, data)
81}
82
83fn is_string_list(schema: &Json) -> bool {
99 type_of(schema) == Some("array") && schema.get("items").and_then(type_of) == Some("string")
100}
101
102#[must_use]
104pub fn comma_split(s: &str) -> Vec<String> {
105 s.split(',')
106 .map(str::trim)
107 .filter(|p| !p.is_empty())
108 .map(str::to_owned)
109 .collect()
110}
111
112#[derive(Clone, Debug, PartialEq)]
115pub enum JsonArrayString {
116 List(Vec<String>),
118 Fix(Vec<Json>),
122}
123
124#[must_use]
127pub fn json_array_string(s: &str) -> Option<JsonArrayString> {
128 let trimmed = s.trim();
129 if !trimmed.starts_with('[') {
130 return None;
131 }
132 let comma = || JsonArrayString::Fix(comma_split(s).into_iter().map(Json::String).collect());
133 let Ok(parsed) = serde_json::from_str::<Json>(trimmed) else {
134 return Some(comma());
135 };
136 let Json::Array(items) = parsed else {
137 return Some(comma());
138 };
139 if items.iter().all(Json::is_string) {
140 return Some(JsonArrayString::List(
141 items
142 .into_iter()
143 .filter_map(|v| v.as_str().map(str::to_owned))
144 .collect(),
145 ));
146 }
147 Some(JsonArrayString::Fix(items))
148}
149
150pub fn validate_arguments(tool: &str, schema: &Json, args: &Json) -> Result<Option<Json>> {
156 let Some(obj) = args.as_object() else {
157 if args.is_null() {
158 return Ok(None);
159 }
160 return Err(argument_error(tool, "arguments must be an object", schema));
161 };
162 let mut normalized: Option<Map<String, Json>> = None;
163 let props = properties(schema);
164 let mut given: Vec<&String> = obj.keys().collect();
165 given.sort_unstable();
166 for key in given {
167 if !props.contains_key(key) {
168 let fix = bare_engine_argument(key, props).then(|| {
173 omgbase_mutate::canonical_json(&Json::Object(respell_engine_arguments(obj, props)))
174 });
175 return Err(argument_error_fix(
176 tool,
177 &unknown_argument_message(key, props),
178 schema,
179 fix,
180 ));
181 }
182 }
183 let required = required(schema);
184 let mut declared: Vec<(&String, &Json)> = props.iter().collect();
185 declared.sort_unstable_by(|a, b| a.0.cmp(b.0));
186 for (key, prop) in declared {
187 match obj.get(key) {
188 None => {
189 if required.contains(&key.as_str()) {
190 return Err(argument_error(
191 tool,
192 &format!("`{key}` is required"),
193 schema,
194 ));
195 }
196 }
197 Some(value) => {
198 if is_string_list(prop)
199 && let Some(s) = value.as_str()
200 {
201 if let Some(json) = json_array_string(s) {
207 match json {
208 JsonArrayString::Fix(list) => {
209 let mut fixed = obj.clone();
210 fixed.insert(key.clone(), Json::Array(list));
211 return Err(argument_error_fix(
212 tool,
213 &format!("`{key}` must be an array of strings"),
214 schema,
215 Some(omgbase_mutate::canonical_json(&Json::Object(fixed))),
216 ));
217 }
218 JsonArrayString::List(list) => {
219 let list = json!(list);
220 if !matches_schema(prop, &list) {
221 return Err(argument_error(
222 tool,
223 &format!("`{key}` must be {}", phrase_for(prop)),
224 schema,
225 ));
226 }
227 normalized
228 .get_or_insert_with(|| obj.clone())
229 .insert(key.clone(), list);
230 continue;
231 }
232 }
233 }
234 if s.contains(',') {
238 let mut fixed = obj.clone();
239 fixed.insert(key.clone(), json!(comma_split(s)));
240 return Err(argument_error_fix(
241 tool,
242 &format!("`{key}` must be an array of strings"),
243 schema,
244 Some(omgbase_mutate::canonical_json(&Json::Object(fixed))),
245 ));
246 }
247 normalized
248 .get_or_insert_with(|| obj.clone())
249 .insert(key.clone(), json!([s]));
250 continue;
251 }
252 if !matches_schema(prop, value) {
253 return Err(argument_error(
254 tool,
255 &format!("`{key}` must be {}", phrase_for(prop)),
256 schema,
257 ));
258 }
259 }
260 }
261 }
262 Ok(normalized.map(Json::Object))
263}
264
265const BARE_ENGINE_ARGUMENTS: [&str; 4] = ["id", "path", "ids", "paths"];
268
269#[must_use]
271pub fn bare_engine_argument(key: &str, props: &Map<String, Json>) -> bool {
272 BARE_ENGINE_ARGUMENTS.contains(&key) && props.contains_key(&format!("_{key}"))
273}
274
275#[must_use]
278pub fn respell_engine_arguments(
279 args: &Map<String, Json>,
280 props: &Map<String, Json>,
281) -> Map<String, Json> {
282 let mut out = Map::new();
283 for (k, v) in args {
284 let key = if bare_engine_argument(k, props) {
285 format!("_{k}")
286 } else {
287 k.clone()
288 };
289 out.insert(key, v.clone());
290 }
291 out
292}
293
294#[must_use]
306pub fn unknown_argument_message(key: &str, props: &Map<String, Json>) -> String {
307 let has = |n: &str| props.contains_key(n);
308 let retired =
309 |replacement: &str| format!("`{key}` is no longer an argument — pass {replacement}");
310 if bare_engine_argument(key, props) {
311 return format!(
312 "`{key}` is not an argument — the engine field is `_{key}` (the bare namespace is the user's); pass `_{key}`"
313 );
314 }
315 if key == "doc" && has("_id") && has("_path") {
316 return retired("`_id` or `_path`");
317 }
318 if (key == "block" || key == "node") && has("_id") {
319 return retired("`_id`");
320 }
321 if key == "heading" && has("section") {
322 return retired("`section` (the heading's text), or blocks_append with the heading's `id`");
323 }
324 if key == "markdown" && has("text") {
325 return retired("`text`");
326 }
327 if key == "ops" && has("effects") {
328 return OPS_RETIRED.to_owned();
329 }
330 if key == "dry_run" {
331 return DRY_RUN_RETIRED.to_owned();
332 }
333 if key == "attrs" && has("checked") {
334 return retired("`checked`");
335 }
336 let candidates: Vec<&str> = props.keys().map(String::as_str).collect();
337 match nearest(key, &candidates) {
338 None => format!("unknown argument `{key}`"),
339 Some(n) => format!("unknown argument `{key}`; did you mean `{n}`?"),
340 }
341}
342
343#[must_use]
346pub fn nearest<'a>(name: &str, candidates: &[&'a str]) -> Option<&'a str> {
347 let mut sorted: Vec<&'a str> = candidates.to_vec();
348 sorted.sort_unstable();
349 let mut best: Option<(&'a str, usize)> = None;
350 for c in sorted {
351 let d = levenshtein(name, c);
352 let prefix =
353 !name.is_empty() && !c.is_empty() && (c.starts_with(name) || name.starts_with(c));
354 if d > 2 && !prefix {
355 continue;
356 }
357 let score = if prefix && d > 2 { 3 } else { d };
358 if best.is_none_or(|(_, s)| score < s) {
359 best = Some((c, score));
360 }
361 }
362 best.map(|(c, _)| c)
363}
364
365#[must_use]
366pub fn levenshtein(a: &str, b: &str) -> usize {
367 let a: Vec<char> = a.chars().collect();
368 let b: Vec<char> = b.chars().collect();
369 if a.is_empty() {
370 return b.len();
371 }
372 if b.is_empty() {
373 return a.len();
374 }
375 let mut prev: Vec<usize> = (0..=b.len()).collect();
376 for (i, ca) in a.iter().enumerate() {
377 let mut cur = vec![i + 1];
378 for (j, cb) in b.iter().enumerate() {
379 let cost = usize::from(ca != cb);
380 cur.push((prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost));
381 }
382 prev = cur;
383 }
384 prev[b.len()]
385}
386
387fn properties(schema: &Json) -> &Map<String, Json> {
390 static EMPTY: std::sync::LazyLock<Map<String, Json>> = std::sync::LazyLock::new(Map::new);
391 schema
392 .get("properties")
393 .and_then(Json::as_object)
394 .unwrap_or(&EMPTY)
395}
396
397fn required(schema: &Json) -> Vec<&str> {
398 schema
399 .get("required")
400 .and_then(Json::as_array)
401 .map(|a| a.iter().filter_map(Json::as_str).collect())
402 .unwrap_or_default()
403}
404
405fn type_of(schema: &Json) -> Option<&str> {
407 match schema.get("type") {
408 Some(Json::String(t)) => Some(t.as_str()),
409 Some(Json::Array(ts)) => ts.iter().filter_map(Json::as_str).find(|t| *t != "null"),
410 _ => None,
411 }
412}
413
414fn nullable(schema: &Json) -> bool {
415 matches!(schema.get("type"), Some(Json::Array(ts)) if ts.iter().any(|t| t == "null"))
416}
417
418fn matches_schema(schema: &Json, value: &Json) -> bool {
421 if value.is_null() {
422 return nullable(schema);
423 }
424 if let Some(options) = schema.get("enum").and_then(Json::as_array) {
425 return options.contains(value);
426 }
427 if schema.get("oneOf").is_some() {
428 return match value {
430 Json::String(s) => s == "start" || s == "end",
431 Json::Object(o) => {
432 o.get("before").is_some_and(Json::is_string)
433 || o.get("after").is_some_and(Json::is_string)
434 }
435 _ => false,
436 };
437 }
438 match type_of(schema) {
439 Some("string") => value.is_string(),
440 Some("integer") => value.as_f64().is_some_and(|n| n.fract() == 0.0),
441 Some("boolean") => value.is_boolean(),
442 Some("object") => value.is_object(),
443 Some("array") => value.as_array().is_some_and(|items| {
444 let item_type = schema.get("items").and_then(type_of);
445 items.iter().all(|v| match item_type {
446 Some("string") => v.is_string(),
447 Some("integer") => v.as_f64().is_some_and(|n| n.fract() == 0.0),
448 Some("object") => v.is_object(),
449 _ => true,
450 })
451 }),
452 _ => true,
453 }
454}
455
456#[must_use]
459pub fn phrase_for(schema: &Json) -> String {
460 if let Some(options) = schema.get("enum").and_then(Json::as_array) {
461 let names: Vec<&str> = options.iter().filter_map(Json::as_str).collect();
462 return format!("one of {}", names.join(", "));
463 }
464 if schema.get("oneOf").is_some() {
465 return "\"start\", \"end\", {before} or {after}".to_owned();
466 }
467 match type_of(schema) {
468 Some("string") => "a string".to_owned(),
469 Some("integer") => "an integer".to_owned(),
470 Some("boolean") => "a boolean".to_owned(),
471 Some("object") => "an object".to_owned(),
472 Some("array") => {
473 let inner = match schema.get("items").and_then(type_of) {
474 Some("string") => "strings",
475 Some("integer") => "integers",
476 _ => "objects",
477 };
478 format!("an array of {inner}")
479 }
480 _ => "valid".to_owned(),
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 fn schema() -> Json {
489 json!({ "type": "object", "properties": {
490 "_id": { "type": "string" }, "_path": { "type": "string" }, "include_ids": { "type": "boolean" }, "repo": { "type": "string" }
491 }, "required": [] })
492 }
493
494 #[test]
495 fn hints_list_the_shape_in_bytewise_order() {
496 assert_eq!(
497 arguments_hint("docs_read", &schema()),
498 "docs_read takes _id?, _path?, include_ids?, repo?"
499 );
500 assert_eq!(
501 arguments_hint(
502 "repos",
503 &json!({ "type": "object", "properties": {}, "required": [] })
504 ),
505 "repos takes no arguments"
506 );
507 }
508
509 #[test]
510 fn unknown_names_are_corrected() {
511 let s = schema();
512 let e = validate_arguments("docs_read", &s, &json!({ "doc": "a.md" })).unwrap_err();
513 assert_eq!(
514 e.message,
515 "`doc` is no longer an argument — pass `_id` or `_path` (docs_read takes _id?, _path?, include_ids?, repo?)"
516 );
517 assert_eq!(e.data.unwrap()["hint"], "arguments");
518 let e = validate_arguments("docs_read", &s, &json!({ "include_id": true })).unwrap_err();
519 assert!(
520 e.message
521 .starts_with("unknown argument `include_id`; did you mean `include_ids`?")
522 );
523 let e = validate_arguments("docs_read", &s, &json!({ "zzz": true })).unwrap_err();
524 assert!(e.message.starts_with("unknown argument `zzz` ("));
525 let q = json!({ "type": "object", "properties": { "q": { "type": "string" }, "limit": { "type": "integer" } }, "required": ["q"] });
526 let e = validate_arguments("blocks_search", &q, &json!({ "query": "x" })).unwrap_err();
527 assert!(
528 e.message
529 .starts_with("unknown argument `query`; did you mean `q`?")
530 );
531 let e = validate_arguments("blocks_search", &q, &json!({})).unwrap_err();
532 assert_eq!(e.message, "`q` is required (blocks_search takes limit?, q)");
533 let e = validate_arguments("blocks_search", &q, &json!({ "q": "x", "limit": "5" }))
534 .unwrap_err();
535 assert_eq!(
536 e.message,
537 "`limit` must be an integer (blocks_search takes limit?, q)"
538 );
539 assert!(validate_arguments("blocks_search", &q, &json!({ "q": "x", "limit": 5 })).is_ok());
540 }
541
542 #[test]
546 fn bare_engine_arguments_are_refused_with_a_fix() {
547 let s = schema();
548 let e = validate_arguments("docs_read", &s, &json!({ "id": "d_1" })).unwrap_err();
549 assert_eq!(
550 e.message,
551 "`id` is not an argument — the engine field is `_id` (the bare namespace is the user's); pass `_id` (docs_read takes _id?, _path?, include_ids?, repo?)"
552 );
553 let d = e.data.unwrap();
554 assert_eq!(d["hint"], "arguments");
555 assert_eq!(d["fix"], r#"{"_id":"d_1"}"#);
556 let e = validate_arguments(
558 "docs_read",
559 &s,
560 &json!({ "path": "a.md", "id": "d_1", "include_ids": true }),
561 )
562 .unwrap_err();
563 assert!(e.message.starts_with("`id` is not an argument"));
564 assert_eq!(
565 e.data.unwrap()["fix"],
566 r#"{"_id":"d_1","_path":"a.md","include_ids":true}"#
567 );
568 let many = json!({ "type": "object", "properties": { "_ids": { "type": "array", "items": { "type": "string" } } }, "required": ["_ids"] });
569 let e =
570 validate_arguments("blocks_read_many", &many, &json!({ "ids": ["b_1"] })).unwrap_err();
571 assert_eq!(
572 e.message,
573 "`ids` is not an argument — the engine field is `_ids` (the bare namespace is the user's); pass `_ids` (blocks_read_many takes _ids)"
574 );
575 assert_eq!(e.data.unwrap()["fix"], r#"{"_ids":["b_1"]}"#);
576 let q = json!({ "type": "object", "properties": { "q": { "type": "string" } }, "required": ["q"] });
578 let e =
579 validate_arguments("blocks_search", &q, &json!({ "q": "x", "id": "b_1" })).unwrap_err();
580 assert!(e.message.starts_with("unknown argument `id`"));
581 assert!(e.data.unwrap().get("fix").is_none());
582 }
583
584 #[test]
586 fn retired_write_inputs_name_their_replacement() {
587 let apply = json!({ "type": "object", "properties": { "effects": { "type": "array" }, "reason": { "type": "string" } }, "required": ["effects"] });
588 let e = validate_arguments("apply", &apply, &json!({ "ops": [] })).unwrap_err();
589 assert_eq!(
590 e.message,
591 "`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?)"
592 );
593 let e = validate_arguments("apply", &apply, &json!({ "effects": [], "dry_run": true }))
594 .unwrap_err();
595 assert_eq!(
596 e.message,
597 "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (apply takes effects, reason?)"
598 );
599 let e = validate_arguments("apply", &apply, &json!({})).unwrap_err();
600 assert_eq!(
601 e.message,
602 "`effects` is required (apply takes effects, reason?)"
603 );
604 let update = json!({ "type": "object", "properties": { "_id": { "type": "string" }, "checked": { "type": "boolean" } }, "required": ["_id"] });
605 let e = validate_arguments(
606 "blocks_update",
607 &update,
608 &json!({ "_id": "b_1", "attrs": {} }),
609 )
610 .unwrap_err();
611 assert_eq!(
612 e.message,
613 "`attrs` is no longer an argument — pass `checked` (blocks_update takes _id, checked?)"
614 );
615 let e = validate_arguments("blocks_update", &update, &json!({ "_id": "b_1", "zzz": 1 }))
617 .unwrap_err();
618 assert!(e.message.starts_with("unknown argument `zzz` ("));
619 }
620
621 #[test]
622 fn distance_and_nearest() {
623 assert_eq!(levenshtein("kitten", "sitting"), 3);
624 assert_eq!(levenshtein("", "ab"), 2);
625 assert_eq!(nearest("q", &["query", "limit"]), Some("query"));
626 assert_eq!(nearest("_id", &["_id", "_path"]), Some("_id"));
627 assert_eq!(nearest("zzzz", &["_id", "_path"]), None);
628 }
629
630 #[test]
634 fn string_lists_take_one_bare_string_and_fix_a_comma_joined_one() {
635 let s = json!({ "type": "object", "properties": {
636 "docs": { "type": "array", "items": { "type": "string" } }, "include_ids": { "type": "boolean" }, "repo": { "type": "string" }
637 }, "required": ["docs"] });
638 assert_eq!(
639 validate_arguments("docs_read_many", &s, &json!({ "docs": "a.md" })).unwrap(),
640 Some(json!({ "docs": ["a.md"] }))
641 );
642 assert_eq!(
643 validate_arguments("docs_read_many", &s, &json!({ "docs": ["a.md"] })).unwrap(),
644 None
645 );
646 let e = validate_arguments(
647 "docs_read_many",
648 &s,
649 &json!({ "docs": "a.md, b.md,", "include_ids": true }),
650 )
651 .unwrap_err();
652 assert_eq!(
653 e.message,
654 "`docs` must be an array of strings (docs_read_many takes docs, include_ids?, repo?)"
655 );
656 let d = e.data.unwrap();
657 assert_eq!(d["hint"], "arguments");
658 assert_eq!(d["fix"], r#"{"docs":["a.md","b.md"],"include_ids":true}"#);
659 let e = validate_arguments("docs_read_many", &s, &json!({ "docs": 7 })).unwrap_err();
660 assert!(
661 e.message
662 .starts_with("`docs` must be an array of strings (")
663 );
664 assert!(e.data.unwrap().get("fix").is_none());
665 assert_eq!(comma_split(" a , ,b,"), ["a", "b"]);
666 }
667
668 #[test]
672 fn string_lists_take_a_stringified_json_array_of_strings() {
673 let s = json!({ "type": "object", "properties": {
674 "docs": { "type": "array", "items": { "type": "string" } }, "include_ids": { "type": "boolean" }, "repo": { "type": "string" }
675 }, "required": ["docs"] });
676 assert_eq!(
677 validate_arguments(
678 "docs_read_many",
679 &s,
680 &json!({ "docs": "[\"a.md\",\"b.md\"]" })
681 )
682 .unwrap(),
683 Some(json!({ "docs": ["a.md", "b.md"] }))
684 );
685 assert_eq!(
687 validate_arguments("docs_read_many", &s, &json!({ "docs": " [\"a.md\"] " })).unwrap(),
688 Some(json!({ "docs": ["a.md"] }))
689 );
690 let e = validate_arguments("docs_read_many", &s, &json!({ "docs": "[1, 2]" })).unwrap_err();
692 assert_eq!(
693 e.message,
694 "`docs` must be an array of strings (docs_read_many takes docs, include_ids?, repo?)"
695 );
696 assert_eq!(e.data.unwrap()["fix"], r#"{"docs":[1,2]}"#);
697 let e = validate_arguments(
699 "docs_read_many",
700 &s,
701 &json!({ "docs": "[\"a.md\", \"b.md\"", "include_ids": true }),
702 )
703 .unwrap_err();
704 assert_eq!(
705 e.data.unwrap()["fix"],
706 r#"{"docs":["[\"a.md\"","\"b.md\""],"include_ids":true}"#
707 );
708 let e = validate_arguments("docs_read_many", &s, &json!({ "docs": "[{}]" })).unwrap_err();
710 assert_eq!(e.data.unwrap()["fix"], r#"{"docs":[{}]}"#);
711 assert_eq!(json_array_string("a.md"), None);
712 assert_eq!(
713 json_array_string("[\"x\"]"),
714 Some(JsonArrayString::List(vec!["x".to_owned()]))
715 );
716 assert_eq!(
717 json_array_string("[x"),
718 Some(JsonArrayString::Fix(vec![json!("[x")]))
719 );
720 }
721}