Skip to main content

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.