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) -----------------------------------
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. A comma-joined string is still "must be an
86// array of strings", but that error carries a `fix`: the same call with the
87// string split on commas (each part trimmed, empty parts dropped), canonical
88// JSON.
89
90/// Is this argument's schema a string list (`{ type: array, items: { type: string } }`)?
91fn is_string_list(schema: &Json) -> bool {
92    type_of(schema) == Some("array") && schema.get("items").and_then(type_of) == Some("string")
93}
94
95/// The parts of a comma-joined string: split on `,`, trimmed, empty parts dropped.
96#[must_use]
97pub fn comma_split(s: &str) -> Vec<String> {
98    s.split(',')
99        .map(str::trim)
100        .filter(|p| !p.is_empty())
101        .map(str::to_owned)
102        .collect()
103}
104
105/// Check `args` against the tool's schema: unknown names first (bytewise
106/// order), then each declared argument (bytewise order) for presence and
107/// type. Nested shapes (`ops`, `expect`, `at`) are checked by their handlers.
108/// Returns the arguments as the handler sees them when the check normalized
109/// one (4.5: a bare string in a string-list slot becomes `[s]`), else `None`.
110pub fn validate_arguments(tool: &str, schema: &Json, args: &Json) -> Result<Option<Json>> {
111    let Some(obj) = args.as_object() else {
112        if args.is_null() {
113            return Ok(None);
114        }
115        return Err(argument_error(tool, "arguments must be an object", schema));
116    };
117    let mut normalized: Option<Map<String, Json>> = None;
118    let props = properties(schema);
119    let mut given: Vec<&String> = obj.keys().collect();
120    given.sort_unstable();
121    for key in given {
122        if !props.contains_key(key) {
123            return Err(argument_error(
124                tool,
125                &unknown_argument_message(key, props),
126                schema,
127            ));
128        }
129    }
130    let required = required(schema);
131    let mut declared: Vec<(&String, &Json)> = props.iter().collect();
132    declared.sort_unstable_by(|a, b| a.0.cmp(b.0));
133    for (key, prop) in declared {
134        match obj.get(key) {
135            None => {
136                if required.contains(&key.as_str()) {
137                    return Err(argument_error(
138                        tool,
139                        &format!("`{key}` is required"),
140                        schema,
141                    ));
142                }
143            }
144            Some(value) => {
145                if is_string_list(prop)
146                    && let Some(s) = value.as_str()
147                {
148                    // (4.5) one bare string is a one-element list; a
149                    // comma-joined one is the array error with the split
150                    // list as its `fix`.
151                    if s.contains(',') {
152                        let mut fixed = obj.clone();
153                        fixed.insert(key.clone(), json!(comma_split(s)));
154                        return Err(argument_error_fix(
155                            tool,
156                            &format!("`{key}` must be an array of strings"),
157                            schema,
158                            Some(omgbase_mutate::canonical_json(&Json::Object(fixed))),
159                        ));
160                    }
161                    normalized
162                        .get_or_insert_with(|| obj.clone())
163                        .insert(key.clone(), json!([s]));
164                    continue;
165                }
166                if !matches_schema(prop, value) {
167                    return Err(argument_error(
168                        tool,
169                        &format!("`{key}` must be {}", phrase_for(prop)),
170                        schema,
171                    ));
172                }
173            }
174        }
175    }
176    Ok(normalized.map(Json::Object))
177}
178
179/// What to say about an argument name the tool does not take: the names 3.0
180/// retired are called out with their replacement (`doc` → `id`/`path`,
181/// `block`/`node` → `id`, `heading` → `section`, `markdown` → `text`) when
182/// the tool has the replacement, and so are the inputs 4.0 retired — `ops`
183/// (where the tool takes `effects`: the kernel ops are a library/fixture
184/// contract), `dry_run` (every preview is `plan`), `attrs` (where the tool
185/// takes `checked`); otherwise the nearest valid name (Levenshtein ≤ 2, or
186/// one a prefix of the other).
187#[must_use]
188pub fn unknown_argument_message(key: &str, props: &Map<String, Json>) -> String {
189    let has = |n: &str| props.contains_key(n);
190    let retired =
191        |replacement: &str| format!("`{key}` is no longer an argument — pass {replacement}");
192    if key == "doc" && has("id") && has("path") {
193        return retired("`id` or `path`");
194    }
195    if (key == "block" || key == "node") && has("id") {
196        return retired("`id`");
197    }
198    if key == "heading" && has("section") {
199        return retired("`section` (the heading's text), or blocks_append with the heading's `id`");
200    }
201    if key == "markdown" && has("text") {
202        return retired("`text`");
203    }
204    if key == "ops" && has("effects") {
205        return OPS_RETIRED.to_owned();
206    }
207    if key == "dry_run" {
208        return DRY_RUN_RETIRED.to_owned();
209    }
210    if key == "attrs" && has("checked") {
211        return retired("`checked`");
212    }
213    let candidates: Vec<&str> = props.keys().map(String::as_str).collect();
214    match nearest(key, &candidates) {
215        None => format!("unknown argument `{key}`"),
216        Some(n) => format!("unknown argument `{key}`; did you mean `{n}`?"),
217    }
218}
219
220/// The nearest candidate: Levenshtein distance ≤ 2, or a prefix relation
221/// either way; ties → the bytewise-first.
222#[must_use]
223pub fn nearest<'a>(name: &str, candidates: &[&'a str]) -> Option<&'a str> {
224    let mut sorted: Vec<&'a str> = candidates.to_vec();
225    sorted.sort_unstable();
226    let mut best: Option<(&'a str, usize)> = None;
227    for c in sorted {
228        let d = levenshtein(name, c);
229        let prefix =
230            !name.is_empty() && !c.is_empty() && (c.starts_with(name) || name.starts_with(c));
231        if d > 2 && !prefix {
232            continue;
233        }
234        let score = if prefix && d > 2 { 3 } else { d };
235        if best.is_none_or(|(_, s)| score < s) {
236            best = Some((c, score));
237        }
238    }
239    best.map(|(c, _)| c)
240}
241
242#[must_use]
243pub fn levenshtein(a: &str, b: &str) -> usize {
244    let a: Vec<char> = a.chars().collect();
245    let b: Vec<char> = b.chars().collect();
246    if a.is_empty() {
247        return b.len();
248    }
249    if b.is_empty() {
250        return a.len();
251    }
252    let mut prev: Vec<usize> = (0..=b.len()).collect();
253    for (i, ca) in a.iter().enumerate() {
254        let mut cur = vec![i + 1];
255        for (j, cb) in b.iter().enumerate() {
256            let cost = usize::from(ca != cb);
257            cur.push((prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost));
258        }
259        prev = cur;
260    }
261    prev[b.len()]
262}
263
264// ---- JSON-schema introspection ---------------------------------------------------------
265
266fn properties(schema: &Json) -> &Map<String, Json> {
267    static EMPTY: std::sync::LazyLock<Map<String, Json>> = std::sync::LazyLock::new(Map::new);
268    schema
269        .get("properties")
270        .and_then(Json::as_object)
271        .unwrap_or(&EMPTY)
272}
273
274fn required(schema: &Json) -> Vec<&str> {
275    schema
276        .get("required")
277        .and_then(Json::as_array)
278        .map(|a| a.iter().filter_map(Json::as_str).collect())
279        .unwrap_or_default()
280}
281
282/// The schema's `type` (the first non-null entry of a type list).
283fn type_of(schema: &Json) -> Option<&str> {
284    match schema.get("type") {
285        Some(Json::String(t)) => Some(t.as_str()),
286        Some(Json::Array(ts)) => ts.iter().filter_map(Json::as_str).find(|t| *t != "null"),
287        _ => None,
288    }
289}
290
291fn nullable(schema: &Json) -> bool {
292    matches!(schema.get("type"), Some(Json::Array(ts)) if ts.iter().any(|t| t == "null"))
293}
294
295/// Does `value` fit the (shallow) schema — type, enum, `oneOf` for the `at`
296/// spec; array items by their declared type?
297fn matches_schema(schema: &Json, value: &Json) -> bool {
298    if value.is_null() {
299        return nullable(schema);
300    }
301    if let Some(options) = schema.get("enum").and_then(Json::as_array) {
302        return options.contains(value);
303    }
304    if schema.get("oneOf").is_some() {
305        // The `at` spec: "start" | "end" | { before } | { after }.
306        return match value {
307            Json::String(s) => s == "start" || s == "end",
308            Json::Object(o) => {
309                o.get("before").is_some_and(Json::is_string)
310                    || o.get("after").is_some_and(Json::is_string)
311            }
312            _ => false,
313        };
314    }
315    match type_of(schema) {
316        Some("string") => value.is_string(),
317        Some("integer") => value.as_f64().is_some_and(|n| n.fract() == 0.0),
318        Some("boolean") => value.is_boolean(),
319        Some("object") => value.is_object(),
320        Some("array") => value.as_array().is_some_and(|items| {
321            let item_type = schema.get("items").and_then(type_of);
322            items.iter().all(|v| match item_type {
323                Some("string") => v.is_string(),
324                Some("integer") => v.as_f64().is_some_and(|n| n.fract() == 0.0),
325                Some("object") => v.is_object(),
326                _ => true,
327            })
328        }),
329        _ => true,
330    }
331}
332
333/// The expected-type phrase of an argument, as the reference spells it from
334/// its zod schema.
335#[must_use]
336pub fn phrase_for(schema: &Json) -> String {
337    if let Some(options) = schema.get("enum").and_then(Json::as_array) {
338        let names: Vec<&str> = options.iter().filter_map(Json::as_str).collect();
339        return format!("one of {}", names.join(", "));
340    }
341    if schema.get("oneOf").is_some() {
342        return "\"start\", \"end\", {before} or {after}".to_owned();
343    }
344    match type_of(schema) {
345        Some("string") => "a string".to_owned(),
346        Some("integer") => "an integer".to_owned(),
347        Some("boolean") => "a boolean".to_owned(),
348        Some("object") => "an object".to_owned(),
349        Some("array") => {
350            let inner = match schema.get("items").and_then(type_of) {
351                Some("string") => "strings",
352                Some("integer") => "integers",
353                _ => "objects",
354            };
355            format!("an array of {inner}")
356        }
357        _ => "valid".to_owned(),
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    fn schema() -> Json {
366        json!({ "type": "object", "properties": {
367            "id": { "type": "string" }, "path": { "type": "string" }, "include_ids": { "type": "boolean" }, "repo": { "type": "string" }
368        }, "required": [] })
369    }
370
371    #[test]
372    fn hints_list_the_shape_in_bytewise_order() {
373        assert_eq!(
374            arguments_hint("docs_read", &schema()),
375            "docs_read takes id?, include_ids?, path?, repo?"
376        );
377        assert_eq!(
378            arguments_hint(
379                "repos",
380                &json!({ "type": "object", "properties": {}, "required": [] })
381            ),
382            "repos takes no arguments"
383        );
384    }
385
386    #[test]
387    fn unknown_names_are_corrected() {
388        let s = schema();
389        let e = validate_arguments("docs_read", &s, &json!({ "doc": "a.md" })).unwrap_err();
390        assert_eq!(
391            e.message,
392            "`doc` is no longer an argument — pass `id` or `path` (docs_read takes id?, include_ids?, path?, repo?)"
393        );
394        assert_eq!(e.data.unwrap()["hint"], "arguments");
395        let e = validate_arguments("docs_read", &s, &json!({ "include_id": true })).unwrap_err();
396        assert!(
397            e.message
398                .starts_with("unknown argument `include_id`; did you mean `include_ids`?")
399        );
400        let e = validate_arguments("docs_read", &s, &json!({ "zzz": true })).unwrap_err();
401        assert!(e.message.starts_with("unknown argument `zzz` ("));
402        let q = json!({ "type": "object", "properties": { "q": { "type": "string" }, "limit": { "type": "integer" } }, "required": ["q"] });
403        let e = validate_arguments("blocks_search", &q, &json!({ "query": "x" })).unwrap_err();
404        assert!(
405            e.message
406                .starts_with("unknown argument `query`; did you mean `q`?")
407        );
408        let e = validate_arguments("blocks_search", &q, &json!({})).unwrap_err();
409        assert_eq!(e.message, "`q` is required (blocks_search takes limit?, q)");
410        let e = validate_arguments("blocks_search", &q, &json!({ "q": "x", "limit": "5" }))
411            .unwrap_err();
412        assert_eq!(
413            e.message,
414            "`limit` must be an integer (blocks_search takes limit?, q)"
415        );
416        assert!(validate_arguments("blocks_search", &q, &json!({ "q": "x", "limit": 5 })).is_ok());
417    }
418
419    /// The inputs 4.0 retired name their replacement (`spec/surface` §4).
420    #[test]
421    fn retired_write_inputs_name_their_replacement() {
422        let apply = json!({ "type": "object", "properties": { "effects": { "type": "array" }, "reason": { "type": "string" } }, "required": ["effects"] });
423        let e = validate_arguments("apply", &apply, &json!({ "ops": [] })).unwrap_err();
424        assert_eq!(
425            e.message,
426            "`ops` is no longer an argument — `apply` takes `effects` (the kernel ops are a library/fixture contract since spec/mutate 3.0) (apply takes effects, reason?)"
427        );
428        let e = validate_arguments("apply", &apply, &json!({ "effects": [], "dry_run": true }))
429            .unwrap_err();
430        assert_eq!(
431            e.message,
432            "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (apply takes effects, reason?)"
433        );
434        let e = validate_arguments("apply", &apply, &json!({})).unwrap_err();
435        assert_eq!(
436            e.message,
437            "`effects` is required (apply takes effects, reason?)"
438        );
439        let update = json!({ "type": "object", "properties": { "id": { "type": "string" }, "checked": { "type": "boolean" } }, "required": ["id"] });
440        let e = validate_arguments(
441            "blocks_update",
442            &update,
443            &json!({ "id": "b_1", "attrs": {} }),
444        )
445        .unwrap_err();
446        assert_eq!(
447            e.message,
448            "`attrs` is no longer an argument — pass `checked` (blocks_update takes checked?, id)"
449        );
450        // Unknown names are never stripped: the check runs before the handler.
451        let e = validate_arguments("blocks_update", &update, &json!({ "id": "b_1", "zzz": 1 }))
452            .unwrap_err();
453        assert!(e.message.starts_with("unknown argument `zzz` ("));
454    }
455
456    #[test]
457    fn distance_and_nearest() {
458        assert_eq!(levenshtein("kitten", "sitting"), 3);
459        assert_eq!(levenshtein("", "ab"), 2);
460        assert_eq!(nearest("q", &["query", "limit"]), Some("query"));
461        assert_eq!(nearest("id", &["id", "path"]), Some("id"));
462        assert_eq!(nearest("zzzz", &["id", "path"]), None);
463    }
464
465    /// `spec/surface` §4 "Arguments" (4.5): a string list takes one bare
466    /// string; a comma-joined one is the array error with the split call as
467    /// its `fix`; anything else is the array error alone.
468    #[test]
469    fn string_lists_take_one_bare_string_and_fix_a_comma_joined_one() {
470        let s = json!({ "type": "object", "properties": {
471            "docs": { "type": "array", "items": { "type": "string" } }, "include_ids": { "type": "boolean" }, "repo": { "type": "string" }
472        }, "required": ["docs"] });
473        assert_eq!(
474            validate_arguments("docs_read_many", &s, &json!({ "docs": "a.md" })).unwrap(),
475            Some(json!({ "docs": ["a.md"] }))
476        );
477        assert_eq!(
478            validate_arguments("docs_read_many", &s, &json!({ "docs": ["a.md"] })).unwrap(),
479            None
480        );
481        let e = validate_arguments(
482            "docs_read_many",
483            &s,
484            &json!({ "docs": "a.md, b.md,", "include_ids": true }),
485        )
486        .unwrap_err();
487        assert_eq!(
488            e.message,
489            "`docs` must be an array of strings (docs_read_many takes docs, include_ids?, repo?)"
490        );
491        let d = e.data.unwrap();
492        assert_eq!(d["hint"], "arguments");
493        assert_eq!(d["fix"], r#"{"docs":["a.md","b.md"],"include_ids":true}"#);
494        let e = validate_arguments("docs_read_many", &s, &json!({ "docs": 7 })).unwrap_err();
495        assert!(
496            e.message
497                .starts_with("`docs` must be an array of strings (")
498        );
499        assert!(e.data.unwrap().get("fix").is_none());
500        assert_eq!(comma_split(" a , ,b,"), ["a", "b"]);
501    }
502}