Skip to main content

ashlar_command/
params.rs

1//! Typed parameters: `const`-friendly declarations, validation and JSON Schema.
2
3use serde_json::{Map, Value, json};
4
5use crate::{CommandError, ParamKind, ParamSpec, Params, legacy};
6
7/// Longest key quoted back in an error message (hostile params can carry huge keys).
8const MAX_KEY_SHOWN: usize = 64;
9/// At most this many unknown keys are named in one error.
10const MAX_UNKNOWN_SHOWN: usize = 8;
11
12/// The kind of a [`Param`]; the `const` counterpart of [`ParamKind`].
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub enum Kind {
15    Bool,
16    Int { min: Option<i64>, max: Option<i64> },
17    Float { min: Option<f64>, max: Option<f64> },
18    String,
19    Enum(&'static [&'static str]),
20    Json,
21}
22
23/// A typed parameter declared in a `const`/`static` command table:
24/// `Param::int_in("width", 1, 16384).required().about("pixels")`.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct Param {
27    pub name: &'static str,
28    pub kind: Kind,
29    pub required: bool,
30    pub about: &'static str,
31}
32
33impl Param {
34    pub const fn new(name: &'static str, kind: Kind) -> Self {
35        Self { name, kind, required: false, about: "" }
36    }
37    pub const fn bool(name: &'static str) -> Self {
38        Self::new(name, Kind::Bool)
39    }
40    pub const fn int(name: &'static str) -> Self {
41        Self::new(name, Kind::Int { min: None, max: None })
42    }
43    pub const fn int_in(name: &'static str, min: i64, max: i64) -> Self {
44        Self::new(name, Kind::Int { min: Some(min), max: Some(max) })
45    }
46    pub const fn float(name: &'static str) -> Self {
47        Self::new(name, Kind::Float { min: None, max: None })
48    }
49    pub const fn float_in(name: &'static str, min: f64, max: f64) -> Self {
50        Self::new(name, Kind::Float { min: Some(min), max: Some(max) })
51    }
52    pub const fn string(name: &'static str) -> Self {
53        Self::new(name, Kind::String)
54    }
55    pub const fn one_of(name: &'static str, values: &'static [&'static str]) -> Self {
56        Self::new(name, Kind::Enum(values))
57    }
58    pub const fn json(name: &'static str) -> Self {
59        Self::new(name, Kind::Json)
60    }
61    pub const fn required(mut self) -> Self {
62        self.required = true;
63        self
64    }
65    pub const fn about(mut self, about: &'static str) -> Self {
66        self.about = about;
67        self
68    }
69
70    /// The owned, serializable form.
71    pub fn to_spec(&self) -> ParamSpec {
72        let kind = match self.kind {
73            Kind::Bool => ParamKind::Bool,
74            Kind::Int { min, max } => ParamKind::Int { min, max },
75            Kind::Float { min, max } => ParamKind::Float { min, max },
76            Kind::String => ParamKind::String,
77            Kind::Enum(values) => ParamKind::Enum { values: values.iter().map(|v| (*v).to_string()).collect() },
78            Kind::Json => ParamKind::Json,
79        };
80        ParamSpec { name: self.name.to_string(), kind, required: self.required, about: self.about.to_string() }
81    }
82}
83
84/// Enum values of either declaration form.
85#[derive(Clone, Copy)]
86enum Values<'a> {
87    Owned(&'a [String]),
88    Static(&'a [&'static str]),
89}
90
91impl Values<'_> {
92    fn contains(self, s: &str) -> bool {
93        match self {
94            Values::Owned(v) => v.iter().any(|x| x == s),
95            Values::Static(v) => v.contains(&s),
96        }
97    }
98    fn strings(self) -> Vec<String> {
99        match self {
100            Values::Owned(v) => v.to_vec(),
101            Values::Static(v) => v.iter().map(|s| (*s).to_string()).collect(),
102        }
103    }
104}
105
106#[derive(Clone, Copy)]
107enum KindView<'a> {
108    Bool,
109    Int(Option<i64>, Option<i64>),
110    Float(Option<f64>, Option<f64>),
111    Str,
112    Enum(Values<'a>),
113    Json,
114}
115
116/// One typed parameter, borrowed from either declaration form.
117#[derive(Clone, Copy)]
118struct View<'a> {
119    name: &'a str,
120    kind: KindView<'a>,
121    required: bool,
122    about: &'a str,
123}
124
125fn views(params: &Params) -> Option<Vec<View<'_>>> {
126    match params {
127        Params::None => Some(vec![]),
128        Params::Text(_) => None,
129        Params::Typed(specs) => Some(
130            specs
131                .iter()
132                .map(|p| View {
133                    name: &p.name,
134                    kind: match &p.kind {
135                        ParamKind::Bool => KindView::Bool,
136                        ParamKind::Int { min, max } => KindView::Int(*min, *max),
137                        ParamKind::Float { min, max } => KindView::Float(*min, *max),
138                        ParamKind::String => KindView::Str,
139                        ParamKind::Enum { values } => KindView::Enum(Values::Owned(values)),
140                        ParamKind::Json => KindView::Json,
141                    },
142                    required: p.required,
143                    about: &p.about,
144                })
145                .collect(),
146        ),
147        Params::Static(specs) => Some(
148            specs
149                .iter()
150                .map(|p| View {
151                    name: p.name,
152                    kind: match p.kind {
153                        Kind::Bool => KindView::Bool,
154                        Kind::Int { min, max } => KindView::Int(min, max),
155                        Kind::Float { min, max } => KindView::Float(min, max),
156                        Kind::String => KindView::Str,
157                        Kind::Enum(values) => KindView::Enum(Values::Static(values)),
158                        Kind::Json => KindView::Json,
159                    },
160                    required: p.required,
161                    about: p.about,
162                })
163                .collect(),
164        ),
165    }
166}
167
168pub(crate) fn shown(key: &str) -> String {
169    match key.char_indices().nth(MAX_KEY_SHOWN) {
170        Some((i, _)) => format!("`{}…`", key.get(..i).unwrap_or_default()),
171        None => format!("`{key}`"),
172    }
173}
174
175pub(crate) fn json_type(v: &Value) -> &'static str {
176    match v {
177        Value::Null => "null",
178        Value::Bool(_) => "a boolean",
179        Value::Number(_) => "a number",
180        Value::String(_) => "a string",
181        Value::Array(_) => "an array",
182        Value::Object(_) => "an object",
183    }
184}
185
186/// `unknown parameter(s) `a` (did you mean `x`?), `b`; accepted: x, y`.
187pub(crate) fn unknown_keys_error(unknown: &[&str], accepted: &[&str]) -> String {
188    let mut names: Vec<String> = unknown
189        .iter()
190        .take(MAX_UNKNOWN_SHOWN)
191        .map(|k| match crate::suggest::closest(k, accepted.iter().copied(), 1).first() {
192            Some(meant) => format!("{} (did you mean `{meant}`?)", shown(k)),
193            None => shown(k),
194        })
195        .collect();
196    if unknown.len() > MAX_UNKNOWN_SHOWN {
197        names.push(format!("and {} more", unknown.len() - MAX_UNKNOWN_SHOWN));
198    }
199    let list = if accepted.is_empty() { "none".to_string() } else { accepted.join(", ") };
200    format!("unknown parameter(s) {}; accepted: {list}", names.join(", "))
201}
202
203/// An integer value (JSON integer, or a float with no fractional part in the exact range).
204fn as_int(v: &Value) -> Option<i64> {
205    if let Some(i) = v.as_i64() {
206        return Some(i);
207    }
208    let f = v.as_f64()?;
209    // 2^53: past this a float no longer names one integer
210    const EXACT: f64 = 9_007_199_254_740_992.0;
211    (f.is_finite() && f.fract() == 0.0 && f.abs() <= EXACT).then_some(f as i64)
212}
213
214fn check_value(p: &View<'_>, v: &Value) -> Result<(), String> {
215    let name = shown(p.name);
216    match p.kind {
217        KindView::Json => Ok(()),
218        KindView::Bool => v.is_boolean().then_some(()).ok_or_else(|| format!("{name} must be true or false, got {}", json_type(v))),
219        KindView::Str => v.is_string().then_some(()).ok_or_else(|| format!("{name} must be a string, got {}", json_type(v))),
220        KindView::Enum(values) => {
221            let all = values.strings().join(", ");
222            match v.as_str() {
223                Some(s) if values.contains(s) => Ok(()),
224                Some(s) => Err(format!("{name} must be one of {all}, got {}", shown(s))),
225                None => Err(format!("{name} must be one of {all}, got {}", json_type(v))),
226            }
227        }
228        KindView::Int(min, max) => {
229            let Some(i) = as_int(v) else {
230                return Err(if v.is_number() {
231                    format!("{name} must be a whole number, got {v}")
232                } else {
233                    format!("{name} must be a whole number, got {}", json_type(v))
234                });
235            };
236            if let Some(m) = min.filter(|m| i < *m) {
237                return Err(format!("{name} must be at least {m}, got {i}"));
238            }
239            if let Some(m) = max.filter(|m| i > *m) {
240                return Err(format!("{name} must be at most {m}, got {i}"));
241            }
242            Ok(())
243        }
244        KindView::Float(min, max) => {
245            let Some(f) = v.as_f64().filter(|f| f.is_finite()) else {
246                return Err(format!("{name} must be a number, got {}", json_type(v)));
247            };
248            if let Some(m) = min.filter(|m| f < *m) {
249                return Err(format!("{name} must be at least {m}, got {f}"));
250            }
251            if let Some(m) = max.filter(|m| f > *m) {
252                return Err(format!("{name} must be at most {m}, got {f}"));
253            }
254            Ok(())
255        }
256    }
257}
258
259/// Check `value` against a command's declared params. Typed params: unknown keys, missing required
260/// keys, types, ranges and enum values are all checked. Legacy text params: only keys the text
261/// clearly does not mention are refused. `common` keys are accepted by every command; `aliases`
262/// (`(documented, alias)`) let a legacy command accept another spelling.
263pub fn validate_params(id: &str, params: &Params, value: &Value, common: &[&str], aliases: &[(&str, &str)]) -> Result<(), CommandError> {
264    let Some(views) = views(params) else {
265        return match params {
266            Params::Text(doc) => legacy::check(doc, value, common, aliases).map_err(|msg| CommandError::bad_params(id, msg)),
267            _ => Ok(()),
268        };
269    };
270    let empty = Map::new();
271    let obj = match value {
272        Value::Null => &empty,
273        Value::Object(m) => m,
274        other => return Err(CommandError::bad_params(id, format!("expected an object of parameters, got {}", json_type(other)))),
275    };
276    let mut errors: Vec<String> = Vec::new();
277    let unknown: Vec<&str> = obj.keys().map(String::as_str).filter(|k| !common.contains(k) && !views.iter().any(|p| p.name == *k)).collect();
278    if !unknown.is_empty() {
279        let accepted: Vec<&str> = views.iter().map(|p| p.name).collect();
280        errors.push(unknown_keys_error(&unknown, &accepted));
281    }
282    for p in &views {
283        match obj.get(p.name) {
284            None | Some(Value::Null) if p.required => errors.push(format!("missing required parameter {}", shown(p.name))),
285            None | Some(Value::Null) => {}
286            Some(v) => {
287                if let Err(e) = check_value(p, v) {
288                    errors.push(e);
289                }
290            }
291        }
292    }
293    if errors.is_empty() { Ok(()) } else { Err(CommandError::bad_params(id, errors.join("; "))) }
294}
295
296fn property(p: &View<'_>) -> Value {
297    let mut m = Map::new();
298    match p.kind {
299        KindView::Bool => {
300            m.insert("type".into(), "boolean".into());
301        }
302        KindView::Str => {
303            m.insert("type".into(), "string".into());
304        }
305        KindView::Json => {}
306        KindView::Enum(values) => {
307            m.insert("type".into(), "string".into());
308            m.insert("enum".into(), values.strings().into());
309        }
310        KindView::Int(min, max) => {
311            m.insert("type".into(), "integer".into());
312            if let Some(v) = min {
313                m.insert("minimum".into(), v.into());
314            }
315            if let Some(v) = max {
316                m.insert("maximum".into(), v.into());
317            }
318        }
319        KindView::Float(min, max) => {
320            m.insert("type".into(), "number".into());
321            if let Some(v) = min {
322                m.insert("minimum".into(), v.into());
323            }
324            if let Some(v) = max {
325                m.insert("maximum".into(), v.into());
326            }
327        }
328    }
329    if !p.about.is_empty() {
330        m.insert("description".into(), p.about.into());
331    }
332    Value::Object(m)
333}
334
335/// The JSON Schema (`{"type":"object",…}`) of a command's parameters (MCP tool schemas, CLI help,
336/// inspector forms).
337pub fn params_schema(params: &Params) -> Value {
338    match views(params) {
339        Some(views) => {
340            let props: Map<String, Value> = views.iter().map(|p| (p.name.to_string(), property(p))).collect();
341            let required: Vec<&str> = views.iter().filter(|p| p.required).map(|p| p.name).collect();
342            json!({"type": "object", "properties": props, "required": required, "additionalProperties": false})
343        }
344        None => match params {
345            Params::Text(doc) => legacy::schema(doc),
346            _ => json!({"type": "object"}),
347        },
348    }
349}