coralstack_cmd_ipc/command.rs
1//! The [`Command`] trait and the [`DynCommand`] helper.
2//!
3//! A `Command` pairs a string identifier with a typed request/response
4//! pair and an async handler. For compile-time commands, the
5//! `#[command]` attribute macro generates the trait impl; for
6//! runtime-constructed commands (plugin runtimes, FFI, scripting
7//! hosts), [`DynCommand`] lets you build a `Command` instance whose
8//! id / description / schema are owned at runtime.
9
10use std::future::Future;
11use std::marker::PhantomData;
12use std::pin::Pin;
13
14use serde::{de::DeserializeOwned, Serialize};
15use serde_json::Value;
16
17use crate::error::CommandError;
18use crate::message::CommandSchema;
19
20/// A typed command handler registered with a
21/// [`CommandRegistry`](crate::registry::CommandRegistry).
22///
23/// Implementations are zero-cost at registration time: the registry
24/// wraps the typed handler in a dynamically-dispatched closure that
25/// decodes the incoming `request` JSON into [`Request`](Self::Request),
26/// runs [`handle`](Self::handle), and re-encodes the result.
27///
28/// # Compile-time vs runtime commands
29///
30/// - **Compile-time**: `const ID` / `const DESCRIPTION` and the
31/// `#[command]` macro. The defaults for [`id`](Self::id) and
32/// [`description`](Self::description) read these constants.
33/// - **Runtime**: use [`DynCommand`] to supply an owned `String` id,
34/// description, and schema. `DynCommand` implements `Command` by
35/// overriding the instance-level methods.
36///
37/// Both paths register through the same
38/// [`register_command`](crate::registry::CommandRegistry::register_command)
39/// entry point.
40pub trait Command: Send + Sync + 'static {
41 /// Compile-time identifier for typed commands. Ignored when a
42 /// command overrides [`id`](Self::id) to return a runtime string
43 /// (as [`DynCommand`] does).
44 ///
45 /// Identifiers prefixed with `_` are treated as private: they are
46 /// never escalated to a router channel and never advertised to
47 /// peers via `list.commands.response`.
48 const ID: &'static str;
49
50 /// Optional compile-time description. Ignored when
51 /// [`description`](Self::description) is overridden.
52 const DESCRIPTION: Option<&'static str> = None;
53
54 type Request: DeserializeOwned + Send + 'static;
55 type Response: Serialize + Send + 'static;
56
57 /// Instance-level identifier. Defaults to [`ID`](Self::ID).
58 /// [`DynCommand`] overrides this to return a runtime-owned id.
59 fn id(&self) -> &str {
60 Self::ID
61 }
62
63 /// Instance-level description. Defaults to
64 /// [`DESCRIPTION`](Self::DESCRIPTION).
65 fn description(&self) -> Option<&str> {
66 Self::DESCRIPTION
67 }
68
69 /// Wire-level JSON Schema for this command. Defaults to `None`.
70 /// The `#[command]` macro overrides this with a schema generated
71 /// from [`Request`](Self::Request) and [`Response`](Self::Response)
72 /// via `schemars`.
73 fn schema(&self) -> Option<CommandSchema> {
74 None
75 }
76
77 /// Handles a single invocation.
78 fn handle(
79 &self,
80 request: Self::Request,
81 ) -> impl Future<Output = Result<Self::Response, CommandError>> + Send;
82}
83
84/// A runtime-constructed [`Command`]. Use this when the command id
85/// or schema is only known at runtime (plugin runtimes, FFI,
86/// scripting hosts).
87///
88/// # Example — dynamic id with `Value` payloads
89///
90/// ```ignore
91/// use coralstack_cmd_ipc::prelude::*;
92/// use serde_json::{json, Value};
93///
94/// let cmd = DynCommand::new("plugin.say_hi", |_req: Value| async move {
95/// Ok(json!({ "greeting": "hello" }))
96/// });
97/// registry.register_command(cmd).await?;
98/// ```
99///
100/// # Example — dynamic id with typed payloads
101///
102/// ```ignore
103/// #[derive(serde::Deserialize)]
104/// struct AddReq { a: i64, b: i64 }
105///
106/// let cmd = DynCommand::new(runtime_id, |req: AddReq| async move {
107/// Ok(req.a + req.b)
108/// })
109/// .description("Runtime-registered adder");
110/// registry.register_command(cmd).await?;
111/// ```
112pub struct DynCommand<Req, Res, F> {
113 id: String,
114 description: Option<String>,
115 schema: Option<CommandSchema>,
116 handler: F,
117 _pd: PhantomData<fn(Req) -> Res>,
118}
119
120impl<Req, Res, F, Fut> DynCommand<Req, Res, F>
121where
122 Req: DeserializeOwned + Send + 'static,
123 Res: Serialize + Send + 'static,
124 F: Fn(Req) -> Fut + Send + Sync + 'static,
125 Fut: Future<Output = Result<Res, CommandError>> + Send + 'static,
126{
127 /// Build a new dynamic command. The request/response types are
128 /// inferred from the handler's signature; annotate them if type
129 /// inference needs help (commonly `|req: Value|` for fully
130 /// dynamic payloads).
131 pub fn new(id: impl Into<String>, handler: F) -> Self {
132 Self {
133 id: id.into(),
134 description: None,
135 schema: None,
136 handler,
137 _pd: PhantomData,
138 }
139 }
140
141 /// Attach a human-readable description, surfaced via
142 /// [`Command::description`] and forwarded to MCP/tooling consumers.
143 pub fn description(mut self, description: impl Into<String>) -> Self {
144 self.description = Some(description.into());
145 self
146 }
147
148 /// Attach a full [`CommandSchema`] (both request + response slots)
149 /// advertised on the wire via `register.command.request`. Omit to
150 /// register without a schema (peers will fall back to permissive
151 /// validation).
152 pub fn schema(mut self, schema: CommandSchema) -> Self {
153 self.schema = Some(schema);
154 self
155 }
156
157 /// Attach only the request schema. Convenient when your runtime
158 /// introspection knows the argument shape but not the return.
159 pub fn request_schema(mut self, schema: Value) -> Self {
160 let mut s = self.schema.take().unwrap_or(CommandSchema {
161 request: None,
162 response: None,
163 });
164 s.request = Some(schema);
165 self.schema = Some(s);
166 self
167 }
168
169 /// Attach only the response schema.
170 pub fn response_schema(mut self, schema: Value) -> Self {
171 let mut s = self.schema.take().unwrap_or(CommandSchema {
172 request: None,
173 response: None,
174 });
175 s.response = Some(schema);
176 self.schema = Some(s);
177 self
178 }
179}
180
181impl<Req, Res, F, Fut> Command for DynCommand<Req, Res, F>
182where
183 Req: DeserializeOwned + Send + Sync + 'static,
184 Res: Serialize + Send + Sync + 'static,
185 F: Fn(Req) -> Fut + Send + Sync + 'static,
186 Fut: Future<Output = Result<Res, CommandError>> + Send + 'static,
187{
188 // Sentinel — registry always uses `id(&self)` for DynCommand.
189 const ID: &'static str = "";
190 type Request = Req;
191 type Response = Res;
192
193 fn id(&self) -> &str {
194 &self.id
195 }
196
197 fn description(&self) -> Option<&str> {
198 self.description.as_deref()
199 }
200
201 fn schema(&self) -> Option<CommandSchema> {
202 self.schema.clone()
203 }
204
205 fn handle(&self, request: Req) -> impl Future<Output = Result<Res, CommandError>> + Send {
206 (self.handler)(request)
207 }
208}
209
210// -----------------------------------------------------------------------------
211// Boxed dynamic-command form — the canonical "store heterogeneous dynamic
212// commands in a Vec" shape. Used by Flow's `SourceChannel` and any plugin
213// host that holds a runtime table of `Value → Value` commands.
214// -----------------------------------------------------------------------------
215
216/// Type-erased async handler for a [`BoxedDynCommand`]: takes and
217/// returns raw JSON `Value`s.
218pub type BoxedHandler = Box<
219 dyn Fn(Value) -> Pin<Box<dyn Future<Output = Result<Value, CommandError>> + Send>>
220 + Send
221 + Sync,
222>;
223
224/// A [`DynCommand`] with fully erased handler types — request and
225/// response are `serde_json::Value`, the handler is a boxed async
226/// closure. Use when you need to store a heterogeneous collection of
227/// runtime commands (e.g. `Vec<BoxedDynCommand>` inside a plugin host).
228pub type BoxedDynCommand = DynCommand<Value, Value, BoxedHandler>;
229
230impl DynCommand<Value, Value, BoxedHandler> {
231 /// Construct a [`BoxedDynCommand`] from any async closure producing
232 /// a `Result<Value, CommandError>`. The handler's future is boxed so
233 /// the resulting command has a single concrete type, making it
234 /// suitable for heterogeneous collections.
235 ///
236 /// ```ignore
237 /// use coralstack_cmd_ipc::prelude::*;
238 /// use coralstack_cmd_ipc::BoxedDynCommand;
239 /// use serde_json::{json, Value};
240 ///
241 /// let cmd: BoxedDynCommand = DynCommand::boxed("plugin.hello", |req| async move {
242 /// Ok(json!({ "you_sent": req }))
243 /// });
244 /// registry.register_command(cmd).await?;
245 /// ```
246 pub fn boxed<F, Fut>(id: impl Into<String>, handler: F) -> BoxedDynCommand
247 where
248 F: Fn(Value) -> Fut + Send + Sync + 'static,
249 Fut: Future<Output = Result<Value, CommandError>> + Send + 'static,
250 {
251 let handler: BoxedHandler = Box::new(move |v: Value| Box::pin(handler(v)));
252 DynCommand {
253 id: id.into(),
254 description: None,
255 schema: None,
256 handler,
257 _pd: PhantomData,
258 }
259 }
260}
261
262// The `Fn(Value) -> BoxFuture` closure type satisfies the `Command` blanket
263// impl above because `BoxedHandler` is `Fn(Value) -> Pin<Box<dyn Future<...>>>`
264// and `Pin<Box<dyn Future>>` impls `Future`. No extra impl needed.