Skip to main content

ashlar_command/
legacy.rs

1//! Legacy free-text params docs (`{"name":str,"width":u32=1920,"mix":"Stereo|Mono"?}`,
2//! `{layers: [id|name|#n], add?}`, `{time? (s) | frame?}`): enough parsing to refuse keys a doc
3//! clearly does not mention and to give MCP a usable schema, so existing command tables port
4//! without typing every parameter first.
5
6use serde_json::{Map, Value, json};
7
8use crate::params::{json_type, unknown_keys_error};
9
10/// Docs longer than this are not parsed (they are `'static` text, but stay bounded).
11const MAX_DOC: usize = 8192;
12
13/// Scan state shared by the splitters: bracket depth and whether we are inside a `"string"`.
14#[derive(Default)]
15struct Scan {
16    depth: i32,
17    in_str: bool,
18    escaped: bool,
19}
20
21impl Scan {
22    /// Feed one char; returns whether it is structural (outside any string).
23    fn feed(&mut self, c: char) -> bool {
24        if self.in_str {
25            if self.escaped {
26                self.escaped = false;
27            } else if c == '\\' {
28                self.escaped = true;
29            } else if c == '"' {
30                self.in_str = false;
31            }
32            return false;
33        }
34        if c == '"' {
35            self.in_str = true;
36            return false;
37        }
38        true
39    }
40}
41
42/// The top-level pieces of a `{…}` doc, split at depth-0 commas; `None` when the doc is not a
43/// `{…}` key list.
44fn pieces(doc: &str) -> Option<Vec<&str>> {
45    if doc.len() > MAX_DOC {
46        return None;
47    }
48    let body = doc.trim().strip_prefix('{')?;
49    let mut out = Vec::new();
50    let mut scan = Scan::default();
51    let mut start = 0;
52    fn push<'a>(s: Option<&'a str>, out: &mut Vec<&'a str>) {
53        if let Some(s) = s.map(str::trim).filter(|s| !s.is_empty()) {
54            out.push(s);
55        }
56    }
57    for (i, c) in body.char_indices() {
58        if !scan.feed(c) {
59            continue;
60        }
61        match c {
62            '{' | '[' | '(' => scan.depth += 1,
63            ']' | ')' => scan.depth -= 1,
64            '}' if scan.depth == 0 => {
65                push(body.get(start..i), &mut out);
66                return Some(out);
67            }
68            '}' => scan.depth -= 1,
69            ',' if scan.depth == 0 => {
70                push(body.get(start..i), &mut out);
71                start = i + c.len_utf8();
72            }
73            _ => {}
74        }
75    }
76    None
77}
78
79/// Split a piece into its key part and value part at the first top-level `:`.
80fn key_and_value(piece: &str) -> (&str, Option<&str>) {
81    let mut scan = Scan::default();
82    for (i, c) in piece.char_indices() {
83        if !scan.feed(c) {
84            continue;
85        }
86        match c {
87            '{' | '[' | '(' => scan.depth += 1,
88            '}' | ']' | ')' => scan.depth -= 1,
89            ':' if scan.depth == 0 => return (piece.get(..i).unwrap_or_default(), piece.get(i + 1..)),
90            _ => {}
91        }
92    }
93    (piece, None)
94}
95
96/// The keys named by a piece's key part: `"name"`, `layers`, `path|prop`, `time? (s) | frame?`.
97fn piece_keys(key_part: &str) -> Vec<String> {
98    let mut keys = Vec::new();
99    let mut depth = 0i32;
100    let mut start = 0;
101    let take = |s: &str, keys: &mut Vec<String>| {
102        let k: String = s.trim().trim_start_matches('"').chars().take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '-').collect();
103        if !k.is_empty() && !keys.contains(&k) {
104            keys.push(k);
105        }
106    };
107    for (i, c) in key_part.char_indices() {
108        match c {
109            '{' | '[' | '(' => depth += 1,
110            '}' | ']' | ')' => depth -= 1,
111            '|' if depth == 0 => {
112                take(key_part.get(start..i).unwrap_or_default(), &mut keys);
113                start = i + 1;
114            }
115            _ => {}
116        }
117    }
118    take(key_part.get(start..).unwrap_or_default(), &mut keys);
119    keys
120}
121
122/// Top-level keys accepted by a params doc; `None` when the doc isn't a `{…}` key list (then
123/// nothing is refused).
124pub fn accepted_params(doc: &str) -> Option<Vec<String>> {
125    let mut keys: Vec<String> = Vec::new();
126    for piece in pieces(doc)? {
127        for k in piece_keys(key_and_value(piece).0) {
128            if !keys.contains(&k) {
129                keys.push(k);
130            }
131        }
132    }
133    Some(keys)
134}
135
136/// The piece's text with `"strings"` removed (for spotting `?` and `=` markers).
137fn outside_strings(piece: &str) -> String {
138    let mut scan = Scan::default();
139    piece.chars().filter(|c| scan.feed(*c) && *c != '"').collect()
140}
141
142/// A JSON Schema type guessed from a value doc (`str`, `u32=1920`, `"A|B"?`, `[id]`), only when
143/// the doc says so plainly.
144fn value_schema(value: &str) -> Map<String, Value> {
145    let mut m = Map::new();
146    let v = value.trim().trim_end_matches('?').trim();
147    let v = v.split('=').next().unwrap_or_default().trim();
148    if v.starts_with('[') {
149        m.insert("type".into(), "array".into());
150    } else if v.starts_with('{') {
151        m.insert("type".into(), "object".into());
152    } else if let Some(inner) = v.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
153        m.insert("type".into(), "string".into());
154        if inner.contains('|') && !inner.contains('…') && !inner.contains("...") {
155            let values: Vec<&str> = inner.split('|').map(str::trim).filter(|s| !s.is_empty()).collect();
156            m.insert("enum".into(), values.into());
157        }
158    } else {
159        let t = match v.to_ascii_lowercase().as_str() {
160            "str" | "string" | "text" | "name" | "path" => Some("string"),
161            "bool" | "boolean" => Some("boolean"),
162            "u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" | "int" | "integer" | "n" => Some("integer"),
163            "f32" | "f64" | "float" | "number" | "num" => Some("number"),
164            _ => None,
165        };
166        if let Some(t) = t {
167            m.insert("type".into(), t.into());
168        }
169    }
170    m
171}
172
173/// The JSON Schema of a legacy params doc: one property per accepted key (typed when the doc says
174/// so plainly, described by its piece of the doc), no others.
175pub(crate) fn schema(doc: &str) -> Value {
176    let Some(pieces) = pieces(doc) else {
177        return json!({"type": "object", "description": doc});
178    };
179    let mut props = Map::new();
180    let mut required: Vec<String> = Vec::new();
181    for piece in pieces {
182        let (key_part, value) = key_and_value(piece);
183        let keys = piece_keys(key_part);
184        let markers = outside_strings(piece);
185        // `key?`, `key: t?`, `key: t=default` are optional; so is any key with alternatives
186        let optional = keys.len() > 1 || markers.contains('?') || markers.contains('=');
187        for k in keys {
188            if !optional && !required.contains(&k) {
189                required.push(k.clone());
190            }
191            let mut p = value.map(value_schema).unwrap_or_default();
192            p.insert("description".into(), piece.into());
193            props.insert(k, Value::Object(p));
194        }
195    }
196    json!({"type": "object", "description": doc, "properties": props, "required": required, "additionalProperties": false})
197}
198
199/// Loose validation: refuse keys the doc clearly does not mention.
200pub(crate) fn check(doc: &str, value: &Value, common: &[&str], aliases: &[(&str, &str)]) -> Result<(), String> {
201    let Some(accepted) = accepted_params(doc) else { return Ok(()) };
202    let obj = match value {
203        Value::Null => return Ok(()),
204        Value::Object(m) => m,
205        other => return Err(format!("expected an object of parameters, got {}", json_type(other))),
206    };
207    let ok = |k: &str| common.contains(&k) || accepted.iter().any(|a| a == k || aliases.iter().any(|(doc, alias)| doc == a && *alias == k));
208    let unknown: Vec<&str> = obj.keys().map(String::as_str).filter(|k| !ok(k)).collect();
209    if unknown.is_empty() {
210        return Ok(());
211    }
212    let accepted: Vec<&str> = accepted.iter().map(String::as_str).collect();
213    Err(format!("{} (doc: {doc})", unknown_keys_error(&unknown, &accepted)))
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn keys_of_both_doc_styles() {
222        let film = r#"{"name":str,"width":u32=1920,"fps":f64=23.976,"mix":"Stereo|Mono|5.1|Adaptive"?,"fromItem":itemId?}"#;
223        assert_eq!(accepted_params(film).unwrap(), ["name", "width", "fps", "mix", "fromItem"]);
224        assert_eq!(accepted_params("{layers: [id|name|#n], add?, toggle?}").unwrap(), ["layers", "add", "toggle"]);
225        assert_eq!(accepted_params("{time? (s) | frame? | timecode?}").unwrap(), ["time", "frame", "timecode"]);
226        assert_eq!(accepted_params("{}").unwrap(), Vec::<String>::new());
227        assert_eq!(accepted_params(r#"{"label":"Violet|Iris|…"}"#).unwrap(), ["label"]);
228        // a comma or brace inside a quoted value does not split
229        assert_eq!(accepted_params(r#"{"sep":", or }","x":n}"#).unwrap(), ["sep", "x"]);
230        assert_eq!(accepted_params("free text, no braces"), None);
231        assert_eq!(accepted_params("{unterminated"), None);
232    }
233
234    #[test]
235    fn schema_types_and_required() {
236        let s = schema(r#"{"name":str,"width":u32=1920,"mix":"Stereo|Mono"?,"clips":[id]?}"#);
237        assert_eq!(s["required"], json!(["name"]));
238        assert_eq!(s["properties"]["name"]["type"], "string");
239        assert_eq!(s["properties"]["width"]["type"], "integer");
240        assert_eq!(s["properties"]["mix"]["enum"], json!(["Stereo", "Mono"]));
241        assert_eq!(s["properties"]["clips"]["type"], "array");
242        assert_eq!(s["additionalProperties"], false);
243    }
244}