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    let full = format!("{message} ({})", arguments_hint(tool, schema));
60    SurfaceError::with_data(
61        "filter_invalid",
62        full.clone(),
63        json!({ "reason": full, "hint": "arguments" }),
64    )
65}
66
67/// Check `args` against the tool's schema: unknown names first (bytewise
68/// order), then each declared argument (bytewise order) for presence and
69/// type. Nested shapes (`ops`, `expect`, `at`) are checked by their handlers.
70pub fn validate_arguments(tool: &str, schema: &Json, args: &Json) -> Result<()> {
71    let Some(obj) = args.as_object() else {
72        if args.is_null() {
73            return Ok(());
74        }
75        return Err(argument_error(tool, "arguments must be an object", schema));
76    };
77    let props = properties(schema);
78    let mut given: Vec<&String> = obj.keys().collect();
79    given.sort_unstable();
80    for key in given {
81        if !props.contains_key(key) {
82            return Err(argument_error(
83                tool,
84                &unknown_argument_message(key, props),
85                schema,
86            ));
87        }
88    }
89    let required = required(schema);
90    let mut declared: Vec<(&String, &Json)> = props.iter().collect();
91    declared.sort_unstable_by(|a, b| a.0.cmp(b.0));
92    for (key, prop) in declared {
93        match obj.get(key) {
94            None => {
95                if required.contains(&key.as_str()) {
96                    return Err(argument_error(
97                        tool,
98                        &format!("`{key}` is required"),
99                        schema,
100                    ));
101                }
102            }
103            Some(value) => {
104                if !matches_schema(prop, value) {
105                    return Err(argument_error(
106                        tool,
107                        &format!("`{key}` must be {}", phrase_for(prop)),
108                        schema,
109                    ));
110                }
111            }
112        }
113    }
114    Ok(())
115}
116
117/// What to say about an argument name the tool does not take: the names 3.0
118/// retired are called out with their replacement (`doc` → `id`/`path`,
119/// `block`/`node` → `id`, `heading` → `section`, `markdown` → `text`) when
120/// the tool has the replacement, and so are the inputs 4.0 retired — `ops`
121/// (where the tool takes `effects`: the kernel ops are a library/fixture
122/// contract), `dry_run` (every preview is `plan`), `attrs` (where the tool
123/// takes `checked`); otherwise the nearest valid name (Levenshtein ≤ 2, or
124/// one a prefix of the other).
125#[must_use]
126pub fn unknown_argument_message(key: &str, props: &Map<String, Json>) -> String {
127    let has = |n: &str| props.contains_key(n);
128    let retired =
129        |replacement: &str| format!("`{key}` is no longer an argument — pass {replacement}");
130    if key == "doc" && has("id") && has("path") {
131        return retired("`id` or `path`");
132    }
133    if (key == "block" || key == "node") && has("id") {
134        return retired("`id`");
135    }
136    if key == "heading" && has("section") {
137        return retired("`section` (the heading's text), or blocks_append with the heading's `id`");
138    }
139    if key == "markdown" && has("text") {
140        return retired("`text`");
141    }
142    if key == "ops" && has("effects") {
143        return OPS_RETIRED.to_owned();
144    }
145    if key == "dry_run" {
146        return DRY_RUN_RETIRED.to_owned();
147    }
148    if key == "attrs" && has("checked") {
149        return retired("`checked`");
150    }
151    let candidates: Vec<&str> = props.keys().map(String::as_str).collect();
152    match nearest(key, &candidates) {
153        None => format!("unknown argument `{key}`"),
154        Some(n) => format!("unknown argument `{key}`; did you mean `{n}`?"),
155    }
156}
157
158/// The nearest candidate: Levenshtein distance ≤ 2, or a prefix relation
159/// either way; ties → the bytewise-first.
160#[must_use]
161pub fn nearest<'a>(name: &str, candidates: &[&'a str]) -> Option<&'a str> {
162    let mut sorted: Vec<&'a str> = candidates.to_vec();
163    sorted.sort_unstable();
164    let mut best: Option<(&'a str, usize)> = None;
165    for c in sorted {
166        let d = levenshtein(name, c);
167        let prefix =
168            !name.is_empty() && !c.is_empty() && (c.starts_with(name) || name.starts_with(c));
169        if d > 2 && !prefix {
170            continue;
171        }
172        let score = if prefix && d > 2 { 3 } else { d };
173        if best.is_none_or(|(_, s)| score < s) {
174            best = Some((c, score));
175        }
176    }
177    best.map(|(c, _)| c)
178}
179
180#[must_use]
181pub fn levenshtein(a: &str, b: &str) -> usize {
182    let a: Vec<char> = a.chars().collect();
183    let b: Vec<char> = b.chars().collect();
184    if a.is_empty() {
185        return b.len();
186    }
187    if b.is_empty() {
188        return a.len();
189    }
190    let mut prev: Vec<usize> = (0..=b.len()).collect();
191    for (i, ca) in a.iter().enumerate() {
192        let mut cur = vec![i + 1];
193        for (j, cb) in b.iter().enumerate() {
194            let cost = usize::from(ca != cb);
195            cur.push((prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost));
196        }
197        prev = cur;
198    }
199    prev[b.len()]
200}
201
202// ---- JSON-schema introspection ---------------------------------------------------------
203
204fn properties(schema: &Json) -> &Map<String, Json> {
205    static EMPTY: std::sync::LazyLock<Map<String, Json>> = std::sync::LazyLock::new(Map::new);
206    schema
207        .get("properties")
208        .and_then(Json::as_object)
209        .unwrap_or(&EMPTY)
210}
211
212fn required(schema: &Json) -> Vec<&str> {
213    schema
214        .get("required")
215        .and_then(Json::as_array)
216        .map(|a| a.iter().filter_map(Json::as_str).collect())
217        .unwrap_or_default()
218}
219
220/// The schema's `type` (the first non-null entry of a type list).
221fn type_of(schema: &Json) -> Option<&str> {
222    match schema.get("type") {
223        Some(Json::String(t)) => Some(t.as_str()),
224        Some(Json::Array(ts)) => ts.iter().filter_map(Json::as_str).find(|t| *t != "null"),
225        _ => None,
226    }
227}
228
229fn nullable(schema: &Json) -> bool {
230    matches!(schema.get("type"), Some(Json::Array(ts)) if ts.iter().any(|t| t == "null"))
231}
232
233/// Does `value` fit the (shallow) schema — type, enum, `oneOf` for the `at`
234/// spec; array items by their declared type?
235fn matches_schema(schema: &Json, value: &Json) -> bool {
236    if value.is_null() {
237        return nullable(schema);
238    }
239    if let Some(options) = schema.get("enum").and_then(Json::as_array) {
240        return options.contains(value);
241    }
242    if schema.get("oneOf").is_some() {
243        // The `at` spec: "start" | "end" | { before } | { after }.
244        return match value {
245            Json::String(s) => s == "start" || s == "end",
246            Json::Object(o) => {
247                o.get("before").is_some_and(Json::is_string)
248                    || o.get("after").is_some_and(Json::is_string)
249            }
250            _ => false,
251        };
252    }
253    match type_of(schema) {
254        Some("string") => value.is_string(),
255        Some("integer") => value.as_f64().is_some_and(|n| n.fract() == 0.0),
256        Some("boolean") => value.is_boolean(),
257        Some("object") => value.is_object(),
258        Some("array") => value.as_array().is_some_and(|items| {
259            let item_type = schema.get("items").and_then(type_of);
260            items.iter().all(|v| match item_type {
261                Some("string") => v.is_string(),
262                Some("integer") => v.as_f64().is_some_and(|n| n.fract() == 0.0),
263                Some("object") => v.is_object(),
264                _ => true,
265            })
266        }),
267        _ => true,
268    }
269}
270
271/// The expected-type phrase of an argument, as the reference spells it from
272/// its zod schema.
273#[must_use]
274pub fn phrase_for(schema: &Json) -> String {
275    if let Some(options) = schema.get("enum").and_then(Json::as_array) {
276        let names: Vec<&str> = options.iter().filter_map(Json::as_str).collect();
277        return format!("one of {}", names.join(", "));
278    }
279    if schema.get("oneOf").is_some() {
280        return "\"start\", \"end\", {before} or {after}".to_owned();
281    }
282    match type_of(schema) {
283        Some("string") => "a string".to_owned(),
284        Some("integer") => "an integer".to_owned(),
285        Some("boolean") => "a boolean".to_owned(),
286        Some("object") => "an object".to_owned(),
287        Some("array") => {
288            let inner = match schema.get("items").and_then(type_of) {
289                Some("string") => "strings",
290                Some("integer") => "integers",
291                _ => "objects",
292            };
293            format!("an array of {inner}")
294        }
295        _ => "valid".to_owned(),
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn schema() -> Json {
304        json!({ "type": "object", "properties": {
305            "id": { "type": "string" }, "path": { "type": "string" }, "include_ids": { "type": "boolean" }, "repo": { "type": "string" }
306        }, "required": [] })
307    }
308
309    #[test]
310    fn hints_list_the_shape_in_bytewise_order() {
311        assert_eq!(
312            arguments_hint("docs_read", &schema()),
313            "docs_read takes id?, include_ids?, path?, repo?"
314        );
315        assert_eq!(
316            arguments_hint(
317                "repos",
318                &json!({ "type": "object", "properties": {}, "required": [] })
319            ),
320            "repos takes no arguments"
321        );
322    }
323
324    #[test]
325    fn unknown_names_are_corrected() {
326        let s = schema();
327        let e = validate_arguments("docs_read", &s, &json!({ "doc": "a.md" })).unwrap_err();
328        assert_eq!(
329            e.message,
330            "`doc` is no longer an argument — pass `id` or `path` (docs_read takes id?, include_ids?, path?, repo?)"
331        );
332        assert_eq!(e.data.unwrap()["hint"], "arguments");
333        let e = validate_arguments("docs_read", &s, &json!({ "include_id": true })).unwrap_err();
334        assert!(
335            e.message
336                .starts_with("unknown argument `include_id`; did you mean `include_ids`?")
337        );
338        let e = validate_arguments("docs_read", &s, &json!({ "zzz": true })).unwrap_err();
339        assert!(e.message.starts_with("unknown argument `zzz` ("));
340        let q = json!({ "type": "object", "properties": { "q": { "type": "string" }, "limit": { "type": "integer" } }, "required": ["q"] });
341        let e = validate_arguments("blocks_search", &q, &json!({ "query": "x" })).unwrap_err();
342        assert!(
343            e.message
344                .starts_with("unknown argument `query`; did you mean `q`?")
345        );
346        let e = validate_arguments("blocks_search", &q, &json!({})).unwrap_err();
347        assert_eq!(e.message, "`q` is required (blocks_search takes limit?, q)");
348        let e = validate_arguments("blocks_search", &q, &json!({ "q": "x", "limit": "5" }))
349            .unwrap_err();
350        assert_eq!(
351            e.message,
352            "`limit` must be an integer (blocks_search takes limit?, q)"
353        );
354        assert!(validate_arguments("blocks_search", &q, &json!({ "q": "x", "limit": 5 })).is_ok());
355    }
356
357    /// The inputs 4.0 retired name their replacement (`spec/surface` §4).
358    #[test]
359    fn retired_write_inputs_name_their_replacement() {
360        let apply = json!({ "type": "object", "properties": { "effects": { "type": "array" }, "reason": { "type": "string" } }, "required": ["effects"] });
361        let e = validate_arguments("apply", &apply, &json!({ "ops": [] })).unwrap_err();
362        assert_eq!(
363            e.message,
364            "`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?)"
365        );
366        let e = validate_arguments("apply", &apply, &json!({ "effects": [], "dry_run": true }))
367            .unwrap_err();
368        assert_eq!(
369            e.message,
370            "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (apply takes effects, reason?)"
371        );
372        let e = validate_arguments("apply", &apply, &json!({})).unwrap_err();
373        assert_eq!(
374            e.message,
375            "`effects` is required (apply takes effects, reason?)"
376        );
377        let update = json!({ "type": "object", "properties": { "id": { "type": "string" }, "checked": { "type": "boolean" } }, "required": ["id"] });
378        let e = validate_arguments(
379            "blocks_update",
380            &update,
381            &json!({ "id": "b_1", "attrs": {} }),
382        )
383        .unwrap_err();
384        assert_eq!(
385            e.message,
386            "`attrs` is no longer an argument — pass `checked` (blocks_update takes checked?, id)"
387        );
388        // Unknown names are never stripped: the check runs before the handler.
389        let e = validate_arguments("blocks_update", &update, &json!({ "id": "b_1", "zzz": 1 }))
390            .unwrap_err();
391        assert!(e.message.starts_with("unknown argument `zzz` ("));
392    }
393
394    #[test]
395    fn distance_and_nearest() {
396        assert_eq!(levenshtein("kitten", "sitting"), 3);
397        assert_eq!(levenshtein("", "ab"), 2);
398        assert_eq!(nearest("q", &["query", "limit"]), Some("query"));
399        assert_eq!(nearest("id", &["id", "path"]), Some("id"));
400        assert_eq!(nearest("zzzz", &["id", "path"]), None);
401    }
402}