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//! `id` is not an argument — the engine field is `_id` (the bare namespace is the user's); pass `_id` (docs_read takes …)
16//!   — with `data.fix` the same call respelled (`spec/surface` §4, 5.0: no tolerance for a bare `id`/`path`/`ids`)
17//! `doc` is no longer an argument — pass `_id` or `_path` (docs_read takes …)
18//! `query` is required (query takes …)
19//! `limit` must be an integer (query takes …)
20//! pass _id or _path (docs_read takes …)                      — a locator tool called with neither
21//! _id "d_x" and _path "/a.md" name different documents (…)    — both given, disagreeing
22//! ```
23
24use serde_json::{Map, Value as Json, json};
25
26use crate::error::{Result, SurfaceError};
27
28/// `apply { ops }` (4.0, `spec/surface` §4): the kernel ops `apply` took
29/// through 3.x are a library/fixture contract (`spec/mutate` §4).
30pub 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)";
31/// `dry_run` on `apply` and every sugar tool (4.0): the preview is `plan`.
32pub const DRY_RUN_RETIRED: &str =
33    "`dry_run` is no longer an argument — preview with `plan { effects: [...] }`";
34
35/// The `(tool takes …)` tail: property names in bytewise order, `?` on the
36/// ones not in `required`.
37#[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/// A `filter_invalid` for an argument-shape failure: `message` plus the tool's shape.
59#[must_use]
60pub fn argument_error(tool: &str, message: &str, schema: &Json) -> SurfaceError {
61    argument_error_fix(tool, message, schema, None)
62}
63
64/// [`argument_error`] with a `fix` (4.5, `spec/surface` §4 "Arguments")
65/// when the corrected call can be spelled.
66#[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
83// ---- string lists (`spec/surface` §4 "Arguments", 4.5 / 4.11) ----------------------------
84//
85// Every list-of-strings argument (`docs`, `ids`, `roots`, `blocks`, `select`,
86// `unset`) also takes ONE bare string as a one-element list — the first guess
87// of a caller who has one ref — and (4.11) a string that IS a JSON array of
88// strings (`"[\"b_1\",\"b_2\"]"`, the list stringified by a client that
89// serializes every argument) as that array: the intent is unambiguous, the
90// same tolerance class as the bare string. A comma-joined string is still
91// "must be an array of strings", but that error carries a `fix`: the same
92// call with the string split on commas (each part trimmed, empty parts
93// dropped), canonical JSON. A string opening `[` that is not a JSON array of
94// strings is the same error, its `fix` the parsed array when it parsed (a
95// JSON array of numbers) and the comma split otherwise (a malformed `[`).
96
97/// Is this argument's schema a string list (`{ type: array, items: { type: string } }`)?
98fn is_string_list(schema: &Json) -> bool {
99    type_of(schema) == Some("array") && schema.get("items").and_then(type_of) == Some("string")
100}
101
102/// The parts of a comma-joined string: split on `,`, trimmed, empty parts dropped.
103#[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/// What a string opening `[` in a string-list slot reads as (4.11, the
113/// reference's `jsonArrayString`).
114#[derive(Clone, Debug, PartialEq)]
115pub enum JsonArrayString {
116    /// A JSON array of strings: accepted as that list.
117    List(Vec<String>),
118    /// Anything else: the list the error's corrected call carries — the
119    /// parsed array when the text parsed (a JSON array that is not all
120    /// strings), else the comma split.
121    Fix(Vec<Json>),
122}
123
124/// A string whose trimmed form opens `[`, read as JSON; `None` when it does
125/// not open `[` (the 4.5 rules apply unchanged).
126#[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
150/// Check `args` against the tool's schema: unknown names first (bytewise
151/// order), then each declared argument (bytewise order) for presence and
152/// type. Nested shapes (`ops`, `expect`, `at`) are checked by their handlers.
153/// Returns the arguments as the handler sees them when the check normalized
154/// one (4.5: a bare string in a string-list slot becomes `[s]`), else `None`.
155pub 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            // (5.0) A bare engine-field name where the tool takes its `_`
169            // spelling: the error carries the corrected call (every bare
170            // `id`/`path`/`ids` the call holds respelled at once), never a
171            // silent acceptance.
172            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                    // (4.11) a stringified JSON array of strings is that list
202                    // — checked against the list schema itself (the `roots`
203                    // minimum holds for it too); any other `[…` is the array
204                    // error with the parsed array (or the comma split) as its
205                    // `fix`.
206                    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                    // (4.5) one bare string is a one-element list; a
235                    // comma-joined one is the array error with the split
236                    // list as its `fix`.
237                    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
265/// The bare engine-field names a tool may be called with by mistake
266/// (`spec/surface` §4, 5.0): each has its `_` spelling in the shape.
267const BARE_ENGINE_ARGUMENTS: [&str; 4] = ["id", "path", "ids", "paths"];
268
269/// Whether `key` is a bare engine-field name whose `_` spelling the tool takes.
270#[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/// The call with every bare engine-field argument respelled to its `_` form
276/// (the `fix` of the no-tolerance error).
277#[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/// What to say about an argument name the tool does not take. A bare engine
295/// field (`id`, `path`, `ids`) where the tool takes its `_` spelling is the
296/// no-tolerance error of 5.0 (`spec/surface` §4 "Arguments": the bare
297/// namespace is the user's, an engine field is matched by its exact name).
298/// The names 3.0 retired are called out with their replacement (`doc` →
299/// `_id`/`_path`, `block`/`node` → `_id`, `heading` → `section`, `markdown`
300/// → `text`) when the tool has the replacement, and so are the inputs 4.0
301/// retired — `ops` (where the tool takes `effects`: the kernel ops are a
302/// library/fixture contract), `dry_run` (every preview is `plan`), `attrs`
303/// (where the tool takes `checked`); otherwise the nearest valid name
304/// (Levenshtein ≤ 2, or one a prefix of the other).
305#[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/// The nearest candidate: Levenshtein distance ≤ 2, or a prefix relation
344/// either way; ties → the bytewise-first.
345#[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
387// ---- JSON-schema introspection ---------------------------------------------------------
388
389fn 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
405/// The schema's `type` (the first non-null entry of a type list).
406fn 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
418/// Does `value` fit the (shallow) schema — type, enum, `oneOf` for the `at`
419/// spec; array items by their declared type?
420fn 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        // The `at` spec: "start" | "end" | { before } | { after }.
429        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/// The expected-type phrase of an argument, as the reference spells it from
457/// its zod schema.
458#[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    /// (5.0, `spec/surface` §4 "Arguments") a bare `id`/`path`/`ids` where
543    /// the tool takes the `_` spelling is refused with the corrected call as
544    /// its `fix`; the bare name is never an alias.
545    #[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        // every bare engine field of the call is respelled at once
557        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        // a tool without the `_` spelling: the plain unknown-argument message, no fix
577        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    /// The inputs 4.0 retired name their replacement (`spec/surface` §4).
585    #[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        // Unknown names are never stripped: the check runs before the handler.
616        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    /// `spec/surface` §4 "Arguments" (4.5): a string list takes one bare
631    /// string; a comma-joined one is the array error with the split call as
632    /// its `fix`; anything else is the array error alone.
633    #[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    /// (4.11) a stringified JSON array of strings is that list; any other
669    /// `[…` is the array error with the parsed array (or the comma split) as
670    /// its `fix` (`reads::arguments` steps 42–46).
671    #[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        // surrounding whitespace is trimmed before the `[` is seen
686        assert_eq!(
687            validate_arguments("docs_read_many", &s, &json!({ "docs": " [\"a.md\"] " })).unwrap(),
688            Some(json!({ "docs": ["a.md"] }))
689        );
690        // a JSON array that is not all strings: the parsed array is the fix
691        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        // a malformed `[`: the comma split is the fix
698        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        // a JSON object is not a list either
709        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}