Skip to main content

brep_mcp_core/
script.rs

1//! The `test-mcp` script format (spec Appendix E): a session description plus
2//! a list of tool calls, each with optional expectations on the tool's JSON
3//! result and optional image capture / comparison.
4//!
5//! The script vocabulary is the tool list itself — a step names a tool and
6//! passes its arguments verbatim — so the format never needs to know which
7//! tools exist. Only the `expect` mini-language is defined here.
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
12#[serde(deny_unknown_fields)]
13pub struct Script {
14    pub name: String,
15    /// What the script checks, for the reader; ignored by the runner.
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub note: Option<String>,
18    #[serde(default = "default_backend")]
19    pub backend: String,
20    #[serde(default = "default_size")]
21    pub size: [f32; 2],
22    #[serde(default = "default_ppp")]
23    pub ppp: f32,
24    /// Start on the seed model instead of an empty document.
25    #[serde(default)]
26    pub seed: bool,
27    /// A `.BREP.json` to open before the first step (repo-relative or absolute).
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub document: Option<String>,
30    #[serde(default = "default_timeout")]
31    pub timeout_ms: u64,
32    pub steps: Vec<Step>,
33}
34
35fn default_backend() -> String {
36    "headless".into()
37}
38fn default_size() -> [f32; 2] {
39    [1400.0, 960.0]
40}
41fn default_ppp() -> f32 {
42    1.0
43}
44fn default_timeout() -> u64 {
45    120_000
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
49#[serde(deny_unknown_fields)]
50pub struct Step {
51    pub tool: String,
52    #[serde(default)]
53    pub args: Value,
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub expect: Vec<Expect>,
56    /// Save the step's image (if the tool produced one) under this name.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub save: Option<String>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub compare: Option<Compare>,
61    /// A note for the reader; ignored by the runner.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub note: Option<String>,
64    /// The step is expected to FAIL: the tool must return an error, and the
65    /// error text must match this glob (`*` = anything).
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub expect_error: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
71#[serde(deny_unknown_fields)]
72pub struct Compare {
73    /// Path under `BREP_mcp/tests/baselines/`.
74    pub baseline: String,
75    #[serde(default = "default_max_diff")]
76    pub max_diff_ratio: f64,
77}
78
79fn default_max_diff() -> f64 {
80    0.002
81}
82
83/// One expectation: a JSON pointer into the tool result plus exactly one
84/// operator. `near` takes a relative tolerance `tol` (default 1e-6).
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
86#[serde(deny_unknown_fields)]
87pub struct Expect {
88    pub path: String,
89    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "present")]
90    pub eq: Option<Value>,
91    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "present")]
92    pub ne: Option<Value>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub gt: Option<f64>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub gte: Option<f64>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub lt: Option<f64>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub lte: Option<f64>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub near: Option<f64>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub tol: Option<f64>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub contains: Option<Value>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub matches: Option<String>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub exists: Option<bool>,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub len: Option<usize>,
113}
114
115/// Deserialize an optional field so that a WRITTEN `null` is `Some(Value::Null)`
116/// and only an ABSENT key is `None`.
117///
118/// `Option<Value>` normally collapses the two, which makes `{"eq": null}` read
119/// as "no operator given" — so the one way to assert that a field IS null (an
120/// inactive PMI view, a solid with no colour override) parsed as a script
121/// error. The distinction is exactly what this expectation format needs.
122fn present<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
123where
124    D: serde::Deserializer<'de>,
125{
126    Value::deserialize(deserializer).map(Some)
127}
128
129/// Exit codes of `brep-mcp test`.
130pub mod exit {
131    pub const PASS: i32 = 0;
132    pub const EXPECTATION_FAILED: i32 = 1;
133    pub const TOOL_ERROR: i32 = 2;
134    pub const HOST_ERROR: i32 = 3;
135    pub const TIMEOUT: i32 = 4;
136}
137
138impl Script {
139    pub fn parse(text: &str) -> Result<Self, String> {
140        let s: Script = serde_json::from_str(text).map_err(|e| format!("script parse: {e}"))?;
141        if s.steps.is_empty() {
142            return Err("script has no steps".into());
143        }
144        for (i, step) in s.steps.iter().enumerate() {
145            for (j, e) in step.expect.iter().enumerate() {
146                if e.operator_count() != 1 {
147                    return Err(format!(
148                        "step {i} (`{}`) expect {j}: exactly one operator is required",
149                        step.tool
150                    ));
151                }
152                if !e.path.starts_with('/') && !e.path.is_empty() {
153                    return Err(format!(
154                        "step {i} expect {j}: `path` must be a JSON pointer starting with `/` (got `{}`)",
155                        e.path
156                    ));
157                }
158            }
159        }
160        Ok(s)
161    }
162
163    /// The JSON Schema of the format (`brep://script/format`), derived from
164    /// these types.
165    pub fn json_schema() -> Value {
166        serde_json::to_value(schemars::schema_for!(Script)).unwrap_or(Value::Null)
167    }
168}
169
170impl Expect {
171    fn operator_count(&self) -> usize {
172        [
173            self.eq.is_some(),
174            self.ne.is_some(),
175            self.gt.is_some(),
176            self.gte.is_some(),
177            self.lt.is_some(),
178            self.lte.is_some(),
179            self.near.is_some(),
180            self.contains.is_some(),
181            self.matches.is_some(),
182            self.exists.is_some(),
183            self.len.is_some(),
184        ]
185        .iter()
186        .filter(|b| **b)
187        .count()
188    }
189
190    /// Evaluate against a tool result. `Ok(())` when satisfied; `Err(why)`
191    /// otherwise, with the actual value in the message.
192    pub fn check(&self, result: &Value) -> Result<(), String> {
193        let actual = result.pointer(&self.path);
194        if let Some(exists) = self.exists {
195            return if actual.is_some() == exists {
196                Ok(())
197            } else {
198                Err(format!("{}: exists == {} expected {exists}", self.path, actual.is_some()))
199            };
200        }
201        let Some(actual) = actual else {
202            return Err(format!("{}: no value at that path", self.path));
203        };
204        let num = |v: &Value| v.as_f64();
205        let shown = {
206            let t = actual.to_string();
207            if t.chars().count() > 160 {
208                format!("{}…", t.chars().take(160).collect::<String>())
209            } else {
210                t
211            }
212        };
213        let fail = |what: &str| Err(format!("{}: {what}; actual {shown}", self.path));
214        if let Some(e) = &self.eq {
215            return if actual == e { Ok(()) } else { fail(&format!("expected {e}")) };
216        }
217        if let Some(e) = &self.ne {
218            return if actual != e { Ok(()) } else { fail(&format!("expected not {e}")) };
219        }
220        if let Some(b) = self.gt {
221            return match num(actual) { Some(a) if a > b => Ok(()), _ => fail(&format!("expected > {b}")) };
222        }
223        if let Some(b) = self.gte {
224            return match num(actual) { Some(a) if a >= b => Ok(()), _ => fail(&format!("expected >= {b}")) };
225        }
226        if let Some(b) = self.lt {
227            return match num(actual) { Some(a) if a < b => Ok(()), _ => fail(&format!("expected < {b}")) };
228        }
229        if let Some(b) = self.lte {
230            return match num(actual) { Some(a) if a <= b => Ok(()), _ => fail(&format!("expected <= {b}")) };
231        }
232        if let Some(b) = self.near {
233            let tol = self.tol.unwrap_or(1e-6);
234            return match num(actual) {
235                Some(a) if (a - b).abs() <= tol * b.abs().max(1.0) => Ok(()),
236                _ => fail(&format!("expected within {tol} (relative) of {b}")),
237            };
238        }
239        if let Some(needle) = &self.contains {
240            let ok = match (actual, needle) {
241                (Value::String(s), Value::String(n)) => s.contains(n.as_str()),
242                (Value::Array(a), n) => a.contains(n),
243                (Value::Object(o), Value::String(n)) => o.contains_key(n),
244                _ => false,
245            };
246            return if ok { Ok(()) } else { fail(&format!("expected to contain {needle}")) };
247        }
248        if let Some(pattern) = &self.matches {
249            let Some(s) = actual.as_str() else { return fail("expected a string to match") };
250            return if glob_match(pattern, s) { Ok(()) } else { fail(&format!("expected to match `{pattern}`")) };
251        }
252        if let Some(n) = self.len {
253            let l = match actual {
254                Value::Array(a) => Some(a.len()),
255                Value::String(s) => Some(s.chars().count()),
256                Value::Object(o) => Some(o.len()),
257                _ => None,
258            };
259            return match l { Some(l) if l == n => Ok(()), _ => fail(&format!("expected length {n}")) };
260        }
261        Err(format!("{}: no operator", self.path))
262    }
263}
264
265/// `matches` uses a small glob (`*` any run, `?` one char) rather than a regex
266/// crate: enough for ids like `E*` and messages like `*cannot determine*`.
267pub fn glob_match(pattern: &str, text: &str) -> bool {
268    fn rec(p: &[char], t: &[char]) -> bool {
269        match (p.first(), t.first()) {
270            (None, None) => true,
271            (Some('*'), _) => rec(&p[1..], t) || (!t.is_empty() && rec(p, &t[1..])),
272            (Some('?'), Some(_)) => rec(&p[1..], &t[1..]),
273            (Some(a), Some(b)) if a == b => rec(&p[1..], &t[1..]),
274            _ => false,
275        }
276    }
277    let p: Vec<char> = pattern.chars().collect();
278    let t: Vec<char> = text.chars().collect();
279    rec(&p, &t)
280}
281
282// BREP private tests: 83aa0801f811b4a8