Skip to main content

brep_app/automation/
command.rs

1//! The command registry and the wire envelope (Appendix A).
2//!
3//! A command is a [`CommandSpec`]: name, group, doc, the JSON Schemas of its
4//! argument and result types (derived with `schemars`), the frame phase it
5//! runs in, MCP-style annotations, and a handler. Each `cmd_*` module owns a
6//! `pub static COMMANDS: &[CommandSpec]`; [`registry`] gathers them. A host
7//! generates its tool list from [`describe`]; nothing lists commands by hand.
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use brep_render::engine_state::EngineState;
11
12/// Where in the frame a command runs (§4.4).
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
14#[serde(rename_all = "snake_case")]
15pub enum Phase {
16    /// `raw_input_hook`: the handler emits `egui::Event`s.
17    Input,
18    /// Top of `ui`, before any panel draws.
19    Mutate,
20    /// Bottom of `ui`, after the state registry is rebuilt.
21    Read,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
25pub struct Annotations {
26    pub read_only: bool,
27    pub destructive: bool,
28    pub idempotent: bool,
29    /// The host applies the idle contract (§6) before returning.
30    pub waits: bool,
31}
32
33impl Annotations {
34    pub const READ: Annotations = Annotations { read_only: true, destructive: false, idempotent: true, waits: false };
35    pub const INPUT: Annotations = Annotations { read_only: false, destructive: false, idempotent: false, waits: false };
36    pub const MUTATE: Annotations = Annotations { read_only: false, destructive: false, idempotent: false, waits: true };
37    pub const MUTATE_NOWAIT: Annotations = Annotations { read_only: false, destructive: false, idempotent: true, waits: false };
38    pub const DESTRUCTIVE: Annotations = Annotations { read_only: false, destructive: true, idempotent: false, waits: true };
39}
40
41/// What an app-phase handler sees.
42pub struct Ctx<'a> {
43    pub app: &'a mut crate::app::BrepApp,
44    pub egui: &'a egui::Context,
45}
46
47/// What a handler returns.
48pub enum Outcome {
49    Done(Value),
50    /// A JSON result plus a binary payload (a PNG) carried beside it.
51    Blob { json: Value, bytes: Vec<u8> },
52    /// The reply completes when `Event::Screenshot` with this token arrives.
53    /// `document` is the visible document's handle when the capture was asked
54    /// for; the capture is refused if another one is showing when it lands.
55    AwaitScreenshot { token: u64, region: crate::automation::cmd_capture::Region, document: Option<String> },
56    /// Long work the command has STARTED without waiting for it: the queue
57    /// calls the poller once per frame in the command's phase, from the frame
58    /// thread, until it answers. A handler never waits inside a frame —
59    /// geometry, a PLM answer or a store write lands on later frames, and
60    /// the app keeps drawing and answering other commands meanwhile.
61    Poll(Poller),
62}
63
64/// See [`Outcome::Poll`]: `None` = not yet, `Some(result)` = the reply.
65pub type Poller = Box<dyn FnMut(&mut Ctx<'_>) -> Option<Result<Outcome, String>> + Send>;
66
67pub type InputHandler = fn(&mut crate::automation::pointer::Pointer, Value, &mut Vec<egui::Event>) -> Result<Value, String>;
68pub type AppHandler = fn(&mut Ctx<'_>, Value) -> Result<Outcome, String>;
69
70pub enum Handler {
71    Input(InputHandler),
72    App(AppHandler),
73}
74
75pub struct CommandSpec {
76    pub name: &'static str,
77    pub group: &'static str,
78    pub doc: &'static str,
79    pub phase: Phase,
80    pub annotations: Annotations,
81    pub args_schema: fn() -> Value,
82    pub result_schema: fn() -> Value,
83    pub handler: Handler,
84}
85
86/// One app-handled [`CommandSpec`] on one line:
87/// `app_command!(group, name, doc, Args, handler, Phase, ANNOTATIONS, result_schema)`.
88/// `Args` is the argument type (its schema is `schema_of::<Args>`), `Phase` and
89/// `ANNOTATIONS` name the [`Phase`] and [`Annotations`] constants. Leaving out
90/// `result_schema` declares the exactly-empty result ([`empty_result_schema`]).
91/// Input-phase commands, and any spec whose annotations or argument schema is
92/// not one of those shapes, stay written out as literals.
93macro_rules! app_command {
94    ($group:literal, $name:literal, $doc:expr, $args:ty, $handler:path, $phase:ident, $annotation:ident, $output:expr) => {
95        $crate::automation::command::CommandSpec {
96            name: $name,
97            group: $group,
98            doc: $doc,
99            phase: $crate::automation::command::Phase::$phase,
100            annotations: $crate::automation::command::Annotations::$annotation,
101            args_schema: $crate::automation::command::schema_of::<$args>,
102            result_schema: $output,
103            handler: $crate::automation::command::Handler::App($handler),
104        }
105    };
106    ($group:literal, $name:literal, $doc:expr, $args:ty, $handler:path, $phase:ident, $annotation:ident) => {
107        $crate::automation::command::app_command!($group, $name, $doc, $args, $handler, $phase, $annotation, $crate::automation::command::empty_result_schema)
108    };
109}
110pub(crate) use app_command;
111
112/// A mutate handler that parses its arguments, makes one engine call and
113/// answers the empty result: `engine_call(ctx, args, |e, a: Args| e.m(&a.id))`.
114/// The call's own value is dropped, its error is the command's error.
115pub fn engine_call<A, R, E>(ctx: &mut Ctx<'_>, args: Value, f: impl FnOnce(&mut EngineState, A) -> Result<R, E>) -> Result<Outcome, String>
116where
117    A: serde::de::DeserializeOwned,
118    String: From<E>,
119{
120    let a: A = parse_args(args)?;
121    f(ctx.app.docs.engine_mut(), a)?;
122    Ok(Outcome::Done(serde_json::json!({})))
123}
124
125/// A reader that takes no arguments and answers what `f` reads from the
126/// engine: `read_engine(ctx, args, |e| e.feature_catalogue())`.
127pub fn read_engine(ctx: &mut Ctx<'_>, args: Value, f: impl FnOnce(&EngineState) -> Value) -> Result<Outcome, String> {
128    let _: NoArgs = parse_args(args)?;
129    Ok(Outcome::Done(f(ctx.app.docs.engine())))
130}
131
132/// The JSON Schema of a type, as a `serde_json::Value`.
133///
134/// `schemars` renders an unconstrained value (e.g. a `serde_json::Value` field)
135/// as the boolean schema `true`. Strict MCP tool-schema validators (such as
136/// Claude Code's) reject a bare boolean where a schema object is expected, and
137/// that rejects the *whole* `tools/list` — one such field zeroes out every tool.
138/// So we normalize boolean subschemas to their object form before publishing.
139pub fn schema_of<T: schemars::JsonSchema>() -> Value {
140    let mut schema = serde_json::to_value(schemars::schema_for!(T)).unwrap_or(Value::Null);
141    normalize_bool_subschemas(&mut schema);
142    schema
143}
144
145/// A source-reviewed command whose successful native result is exactly `{}`.
146/// Use this only for genuinely empty payloads, never as a placeholder for an
147/// undeclared result. The MCP adapter adds its own envelope fields separately.
148pub fn empty_result_schema() -> Value {
149    serde_json::json!({
150        "type": "object",
151        "properties": {},
152        "additionalProperties": false,
153        "description": "No native application payload. The MCP adapter adds frame, optional notices and settlement information."
154    })
155}
156
157/// Replace boolean subschemas (`true`/`false` used where a schema *object* is
158/// expected — a `properties` member or `items`) with their object equivalents
159/// (`true` → `{}`, `false` → `{"not": {}}`). Legitimate booleans such as
160/// `additionalProperties` and `default` are left untouched.
161pub(super) fn normalize_bool_subschemas(node: &mut Value) {
162    match node {
163        Value::Object(map) => {
164            if let Some(Value::Object(props)) = map.get_mut("properties") {
165                for value in props.values_mut() {
166                    if let Some(b) = value.as_bool() {
167                        *value = if b { serde_json::json!({}) } else { serde_json::json!({"not": {}}) };
168                    }
169                }
170            }
171            if let Some(items) = map.get_mut("items") {
172                if let Some(b) = items.as_bool() {
173                    *items = if b { serde_json::json!({}) } else { serde_json::json!({"not": {}}) };
174                }
175            }
176            for value in map.values_mut() {
177                normalize_bool_subschemas(value);
178            }
179        }
180        Value::Array(arr) => {
181            for value in arr.iter_mut() {
182                normalize_bool_subschemas(value);
183            }
184        }
185        _ => {}
186    }
187}
188
189pub fn parse_args<T: serde::de::DeserializeOwned>(args: Value) -> Result<T, String> {
190    // A call with no arguments arrives as `null` or `{}`; both mean "none".
191    let args = if args.is_null() { Value::Object(Default::default()) } else { args };
192    serde_json::from_value(args).map_err(|e| format!("arguments: {e}"))
193}
194
195/// A command that takes nothing.
196#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
197#[serde(deny_unknown_fields)]
198pub struct NoArgs {}
199
200/// A command that returns nothing beyond the envelope.
201#[derive(Debug, Default, Serialize, schemars::JsonSchema)]
202pub struct Empty {}
203
204/// Every registered command, in module order. Each `cmd_*` module contributes
205/// its `COMMANDS` static; `tests/automation_registry.rs` walks the source tree
206/// for `static COMMANDS` declarations and fails if one is missing here, so this
207/// function cannot silently become a hand-kept subset.
208pub fn registry() -> Vec<&'static CommandSpec> {
209    use crate::automation::*;
210    let sets: &[&[CommandSpec]] = &[
211        cmd_frame::COMMANDS,
212        cmd_files::COMMANDS,
213        cmd_perf::COMMANDS,
214        cmd_input::COMMANDS,
215        cmd_capture::COMMANDS,
216        cmd_state::COMMANDS,
217        cmd_document::COMMANDS,
218        cmd_classes::COMMANDS,
219        cmd_history::COMMANDS,
220        cmd_scene::COMMANDS,
221        cmd_camera::COMMANDS,
222        cmd_settings::COMMANDS,
223        cmd_shell::COMMANDS,
224        cmd_plugins::COMMANDS,
225        cmd_metadata::COMMANDS,
226        cmd_assembly::COMMANDS,
227        cmd_pmi::COMMANDS,
228        cmd_sheets::COMMANDS,
229        cmd_wire_harness::COMMANDS,
230        cmd_cam::COMMANDS,
231        cmd_exploded::COMMANDS,
232        cmd_instructions::COMMANDS,
233        cmd_instructions_motion::COMMANDS,
234        cmd_simulation::COMMANDS,
235        cmd_simulation_play::COMMANDS,
236        cmd_cam_sim::COMMANDS,
237        cmd_ecad::COMMANDS,
238        #[cfg(not(target_arch = "wasm32"))]
239        cmd_plm::COMMANDS,
240        #[cfg(not(target_arch = "wasm32"))]
241        cmd_kicad::COMMANDS,
242    ];
243    sets.iter().flat_map(|s| s.iter()).collect()
244}
245
246pub fn lookup(name: &str) -> Option<&'static CommandSpec> {
247    registry().into_iter().find(|c| c.name == name)
248}
249
250/// The registry as JSON — what a host turns into `tools/list` and what the
251/// generated docs render: `[{name, group, doc, phase, annotations, argsSchema, resultSchema}]`.
252pub fn describe() -> Value {
253    Value::Array(
254        registry()
255            .into_iter()
256            .map(|c| {
257                serde_json::json!({
258                    "name": c.name,
259                    "group": c.group,
260                    "doc": c.doc,
261                    "phase": c.phase,
262                    "annotations": c.annotations,
263                    "argsSchema": (c.args_schema)(),
264                    "resultSchema": (c.result_schema)(),
265                })
266            })
267            .collect(),
268    )
269}
270
271// --- wire -------------------------------------------------------------------
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct Envelope {
275    pub id: u64,
276    pub cmd: String,
277    #[serde(default)]
278    pub args: Value,
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
282#[serde(rename_all = "snake_case")]
283pub enum NoticeKind {
284    Panic,
285    LogError,
286    RunnerRefusal,
287    Toast,
288    Console,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
292pub struct Notice {
293    pub kind: NoticeKind,
294    pub frame: u64,
295    pub text: String,
296}
297
298#[derive(Debug, Clone, Default, Serialize, Deserialize)]
299pub struct Reply {
300    pub id: u64,
301    pub frame: u64,
302    pub ok: bool,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub result: Option<Value>,
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub error: Option<String>,
307    #[serde(default, skip_serializing_if = "Vec::is_empty")]
308    pub notices: Vec<Notice>,
309    /// A binary payload beside the JSON (a PNG). In-process hosts pass it
310    /// through; the dial-in adapter sends it as a binary frame.
311    #[serde(skip)]
312    pub blob: Option<Vec<u8>>,
313}
314
315impl Reply {
316    pub fn ok(id: u64, frame: u64, result: Value, notices: Vec<Notice>) -> Self {
317        Self { id, frame, ok: true, result: Some(result), error: None, notices, blob: None }
318    }
319    pub fn err(id: u64, frame: u64, error: impl Into<String>, notices: Vec<Notice>) -> Self {
320        Self { id, frame, ok: false, result: None, error: Some(error.into()), notices, blob: None }
321    }
322
323    /// This reply in the MCP host protocol's shape: the same JSON, the blob
324    /// beside it. The one conversion the window embed (`mcp.rs`), the WebMCP
325    /// host (`webmcp.rs`) and BREP_mcp's headless host all make; the two
326    /// `Reply` types stay mirrored because `brep_mcp_core` must not depend on
327    /// the app (`BREP_mcp_core/src/host.rs`).
328    pub fn into_host(mut self) -> brep_mcp_core::host::Reply {
329        use brep_mcp_core::host::Reply as HostReply;
330        let blob = self.blob.take();
331        let (id, frame) = (self.id, self.frame);
332        match serde_json::to_value(&self).map_err(|e| e.to_string()).and_then(|v| HostReply::from_app(v, blob)) {
333            Ok(reply) => reply,
334            Err(e) => HostReply::err(id, frame, e),
335        }
336    }
337}
338