Skip to main content

omgbase_surface/
arguments.rs

1//! Argument-shape errors (`spec/surface/README.md` §4 "Arguments", 3.0): every
2//! tool call is checked against its [`ToolSpec`](crate::catalog::ToolSpec)'s
3//! JSON schema ahead of dispatch, and a malformed call is one `filter_invalid`
4//! whose message names what was expected — and, for an unknown argument name,
5//! the nearest valid one. The reference (`packages/core/src/mcp/arguments.ts`)
6//! composes the same messages byte for byte and the surface fixtures pin them
7//! (every argument-shape error carries `data.hint = "arguments"`).
8//!
9//! Message forms (the `(… takes …)` tail lists the tool's arguments in
10//! bytewise order, optional ones marked `?`; a tool without arguments "takes
11//! no arguments"):
12//!
13//! ```text
14//! unknown argument `q`; did you mean `query`? (blocks_search takes limit?, q, repo?)
15//! `doc` is no longer an argument — pass `id` or `path` (docs_read takes …)
16//! `query` is required (query takes …)
17//! `limit` must be an integer (query takes …)
18//! pass id or path (docs_read takes …)                      — a locator tool called with neither
19//! id "d_x" and path "/a.md" name different documents (…)    — both given, disagreeing
20//! ```
21
22use serde_json::{Map, Value as Json, json};
23
24use crate::error::{Result, SurfaceError};
25
26/// `apply { ops }` (4.0, `spec/surface` §4): the kernel ops `apply` took
27/// through 3.x are a library/fixture contract (`spec/mutate` §4).
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)";
29/// `dry_run` on `apply` and every sugar tool (4.0): the preview is `plan`.
30pub const DRY_RUN_RETIRED: &str =
31    "`dry_run` is no longer an argument — preview with `plan { effects: [...] }`";
32
33/// The `(tool takes …)` tail: property names in bytewise order, `?` on the
34/// ones not in `required`.
35#[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/// A `filter_invalid` for an argument-shape failure: `message` plus the tool's shape.
57#[must_use]
58pub fn argument_error(tool: &str, message: &str, schema: &Json) -> SurfaceError {
59    argument_error_fix(tool, message, schema, None)
60}
61
62/// [`argument_error`] with a `fix` (4.5, `spec/surface` §4 "Arguments")
63/// when the corrected call can be spelled.
64#[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
81// ---- string lists (`spec/surface` §4 "Arguments", 4.5 / 4.11) ----------------------------
82//
83// Every list-of-strings argument (`docs`, `ids`, `roots`, `blocks`, `select`,
84// `unset`) also takes ONE bare string as a one-element list — the first guess
85// of a caller who has one ref — and (4.11) a string that IS a JSON array of
86// strings (`"[\"b_1\",\"b_2\"]"`, the list stringified by a client that
87// serializes every argument) as that array: the intent is unambiguous, the
88// same tolerance class as the bare string. A comma-joined string is still
89// "must be an array of strings", but that error carries a `fix`: the same
90// call with the string split on commas (each part trimmed, empty parts
91// dropped), canonical JSON. A string opening `[` that is not a JSON array of
92// strings is the same error, its `fix` the parsed array when it parsed (a
93// JSON array of numbers) and the comma split otherwise (a malformed `[`).
94
95/// Is this argument's schema a string list (`{ type: array, items: { type: string } }`)?
96fn is_string_list(schema: &Json) -> bool {
97    type_of(schema) == Some("array") && schema.get("items").and_then(type_of) == Some("string")
98}
99
100/// The parts of a comma-joined string: split on `,`, trimmed, empty parts dropped.
101#[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/// What a string opening `[` in a string-list slot reads as (4.11, the
111/// reference's `jsonArrayString`).
112#[derive(Clone, Debug, PartialEq)]
113pub enum JsonArrayString {
114    /// A JSON array of strings: accepted as that list.
115    List(Vec<String>),
116    /// Anything else: the list the error's corrected call carries — the
117    /// parsed array when the text parsed (a JSON array that is not all
118    /// strings), else the comma split.
119    Fix(Vec<Json>),
120}
121
122/// A string whose trimmed form opens `[`, read as JSON; `None` when it does
123/// not open `[` (the 4.5 rules apply unchanged).
124#[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
148/// Check `args` against the tool's schema: unknown names first (bytewise
149/// order), then each declared argument (bytewise order) for presence and
150/// type. Nested shapes (`ops`, `expect`, `at`) are checked by their handlers.
151/// Returns the arguments as the handler sees them when the check normalized
152/// one (4.5: a bare string in a string-list slot becomes `[s]`), else `None`.
153pub 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                    // (4.11) a stringified JSON array of strings is that list
192                    // — checked against the list schema itself (the `roots`
193                    // minimum holds for it too); any other `[…` is the array
194                    // error with the parsed array (or the comma split) as its
195                    // `fix`.
196                    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                    // (4.5) one bare string is a one-element list; a
225                    // comma-joined one is the array error with the split
226                    // list as its `fix`.
227                    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/// What to say about an argument name the tool does not take: the names 3.0
256/// retired are called out with their replacement (`doc` → `id`/`path`,
257/// `block`/`node` → `id`, `heading` → `section`, `markdown` → `text`) when
258/// the tool has the replacement, and so are the inputs 4.0 retired — `ops`
259/// (where the tool takes `effects`: the kernel ops are a library/fixture
260/// contract), `dry_run` (every preview is `plan`), `attrs` (where the tool
261/// takes `checked`); otherwise the nearest valid name (Levenshtein ≤ 2, or
262/// one a prefix of the other).
263#[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/// The nearest candidate: Levenshtein distance ≤ 2, or a prefix relation
297/// either way; ties → the bytewise-first.
298#[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
340// ---- JSON-schema introspection ---------------------------------------------------------
341
342fn 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
358/// The schema's `type` (the first non-null entry of a type list).
359fn 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
371/// Does `value` fit the (shallow) schema — type, enum, `oneOf` for the `at`
372/// spec; array items by their declared type?
373fn 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        // The `at` spec: "start" | "end" | { before } | { after }.
382        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/// The expected-type phrase of an argument, as the reference spells it from
410/// its zod schema.
411#[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    /// The inputs 4.0 retired name their replacement (`spec/surface` §4).
496    #[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        // Unknown names are never stripped: the check runs before the handler.
527        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    /// `spec/surface` §4 "Arguments" (4.5): a string list takes one bare
542    /// string; a comma-joined one is the array error with the split call as
543    /// its `fix`; anything else is the array error alone.
544    #[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    /// (4.11) a stringified JSON array of strings is that list; any other
580    /// `[…` is the array error with the parsed array (or the comma split) as
581    /// its `fix` (`reads::arguments` steps 42–46).
582    #[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        // surrounding whitespace is trimmed before the `[` is seen
597        assert_eq!(
598            validate_arguments("docs_read_many", &s, &json!({ "docs": " [\"a.md\"] " })).unwrap(),
599            Some(json!({ "docs": ["a.md"] }))
600        );
601        // a JSON array that is not all strings: the parsed array is the fix
602        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        // a malformed `[`: the comma split is the fix
609        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        // a JSON object is not a list either
620        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}