Skip to main content

ashlar_command/
lib.rs

1//! Everything is a command.
2//!
3//! An app declares its commands once ([`CommandSpec`]) in a [`Registry`]. Menus, shortcuts, the
4//! command palette, the CLI, the control port and MCP all reach the app through one chokepoint,
5//! [`Registry::execute`], and discover it through [`Dispatch::commands`].
6//!
7//! **Contract (shared with ashlar-control, ashlar-mcp, ashlar-cli, ashlar-app):** the items marked
8//! `CONTRACT` below are depended on by sibling crates being built in parallel. Change them only by
9//! adding (new fields behind `#[serde(default)]`, new trait methods with defaults).
10#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15/// CONTRACT: why a command failed. Hosts wrap their own errors in [`CommandError::Failed`].
16#[derive(Debug, Clone, PartialEq, thiserror::Error, Serialize, Deserialize)]
17#[serde(tag = "kind", rename_all = "snake_case")]
18pub enum CommandError {
19    #[error("unknown command `{id}`")]
20    Unknown { id: String },
21    #[error("command `{id}` is not available right now: {reason}")]
22    Disabled { id: String, reason: String },
23    #[error("invalid parameters for `{id}`: {msg}")]
24    BadParams { id: String, msg: String },
25    #[error("{msg}")]
26    Failed { id: String, msg: String },
27    #[error("internal error in `{id}` (see the crash log); the command did not complete")]
28    Panicked { id: String },
29}
30
31/// Why a call through [`Registry::execute_host`] failed: the chokepoint refused or lost it
32/// (`Command`: unknown, too deeply nested, [`Host::before`], validation, disabled, panicked), or
33/// the command body returned the host's own error (`Host`, unchanged, as callers match on it).
34#[derive(Debug, Clone, PartialEq, thiserror::Error)]
35pub enum ExecError<E> {
36    #[error(transparent)]
37    Command(CommandError),
38    #[error("{0}")]
39    Host(E),
40}
41
42impl<E> ExecError<E> {
43    /// The host's error, when the command body returned one.
44    pub fn host(&self) -> Option<&E> {
45        match self {
46            Self::Host(e) => Some(e),
47            Self::Command(_) => None,
48        }
49    }
50    /// The chokepoint's error, when the call never ran or panicked.
51    pub fn command(&self) -> Option<&CommandError> {
52        match self {
53            Self::Command(e) => Some(e),
54            Self::Host(_) => None,
55        }
56    }
57}
58
59impl<E: Into<CommandError>> ExecError<E> {
60    /// Everything as a [`CommandError`], as [`Registry::execute`] returns it (`command` fills in an
61    /// empty id).
62    pub fn into_command(self, command: &str) -> CommandError {
63        match self {
64            Self::Command(e) => e,
65            Self::Host(e) => e.into().or_id(command),
66        }
67    }
68}
69
70/// CONTRACT: the kind of one typed parameter. Maps 1:1 onto JSON Schema.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72#[serde(tag = "type", rename_all = "snake_case")]
73pub enum ParamKind {
74    Bool,
75    Int {
76        min: Option<i64>,
77        max: Option<i64>,
78    },
79    Float {
80        min: Option<f64>,
81        max: Option<f64>,
82    },
83    String,
84    /// One of a fixed set of strings.
85    Enum {
86        values: Vec<String>,
87    },
88    /// Free JSON (object, array…) described in `about`.
89    Json,
90}
91
92/// CONTRACT: one typed parameter.
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct ParamSpec {
95    pub name: String,
96    pub kind: ParamKind,
97    pub required: bool,
98    pub about: String,
99}
100
101/// How a command documents its parameters: typed (preferred, gives JSON Schema and validation) or the
102/// legacy free-text description older command tables use (accepted, validated loosely).
103#[derive(Debug, Clone, PartialEq)]
104pub enum Params {
105    None,
106    Typed(Vec<ParamSpec>),
107    Text(&'static str),
108    /// Typed, built in a `const`/`static` table (see [`Param`]).
109    Static(&'static [Param]),
110}
111
112/// CONTRACT: what discovery returns for one command (palette, menus, MCP tools, CLI `commands`).
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct CommandInfo {
115    pub id: String,
116    pub label: String,
117    /// Menu path, e.g. `["File", "Export"]`. Empty = not in menus.
118    #[serde(default)]
119    pub menu: Vec<String>,
120    /// Portable shortcut, `Mod+Shift+S` (`Mod` = ⌘ on macOS, Ctrl elsewhere).
121    #[serde(default)]
122    pub shortcut: Option<String>,
123    /// JSON Schema (`{"type":"object","properties":…}`) for the parameters.
124    #[serde(default)]
125    pub params_schema: Value,
126    /// Free-text parameter description when the command is not typed yet.
127    #[serde(default)]
128    pub params_text: Option<String>,
129    pub enabled: bool,
130    #[serde(default)]
131    pub disabled_reason: Option<String>,
132    /// Changes the document (journaled, undoable) vs a pure query / view command.
133    #[serde(default)]
134    pub mutates: bool,
135}
136
137/// CONTRACT: anything that can list and run commands. Implemented by `Registry`-backed sessions,
138/// by the control-port client (remote app) and by test doubles. Object-safe.
139pub trait Dispatch {
140    fn commands(&self) -> Vec<CommandInfo>;
141    fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError>;
142}
143
144mod fuzzy;
145mod journal;
146pub mod keymap;
147mod legacy;
148mod macros;
149pub mod menu;
150mod params;
151mod registry;
152pub mod shortcut;
153pub mod suggest;
154
155pub use fuzzy::{FuzzyMatch, fuzzy_match, fuzzy_rank};
156pub use journal::{CommandRun, Journal, JournalEntry, RunObserver};
157pub use keymap::Keymap;
158pub use legacy::accepted_params;
159pub use params::{Kind, Param, params_schema, validate_params};
160pub use registry::{Bound, MAX_DEPTH, Registry, Validation};
161pub use suggest::{closest, did_you_mean_command, params_summary};
162
163impl CommandError {
164    /// The command the error is about.
165    pub fn id(&self) -> &str {
166        match self {
167            Self::Unknown { id } | Self::Disabled { id, .. } | Self::BadParams { id, .. } | Self::Failed { id, .. } | Self::Panicked { id } => id,
168        }
169    }
170    pub fn failed(id: impl Into<String>, msg: impl Into<String>) -> Self {
171        Self::Failed { id: id.into(), msg: msg.into() }
172    }
173    pub fn bad_params(id: impl Into<String>, msg: impl Into<String>) -> Self {
174        Self::BadParams { id: id.into(), msg: msg.into() }
175    }
176    /// Fill in the command id when the error was built without one (a host error converted with
177    /// `From`, which does not know which command it came from).
178    pub fn or_id(mut self, command: &str) -> Self {
179        let (Self::Unknown { id } | Self::Disabled { id, .. } | Self::BadParams { id, .. } | Self::Failed { id, .. } | Self::Panicked { id }) =
180            &mut self;
181        if id.is_empty() {
182            command.clone_into(id);
183        }
184        self
185    }
186}
187
188impl ParamSpec {
189    /// An optional parameter; chain [`ParamSpec::required`] and [`ParamSpec::about`].
190    pub fn new(name: impl Into<String>, kind: ParamKind) -> Self {
191        Self { name: name.into(), kind, required: false, about: String::new() }
192    }
193    pub fn required(mut self) -> Self {
194        self.required = true;
195        self
196    }
197    pub fn about(mut self, about: impl Into<String>) -> Self {
198        self.about = about.into();
199        self
200    }
201}
202
203// ---------------------------------------------------------------------------------------------
204// Commands of one host state
205// ---------------------------------------------------------------------------------------------
206
207/// Enabled predicate: `Err(reason)` disables with a human-readable reason.
208pub type Enabled<S> = fn(&S) -> Result<(), String>;
209/// Command body. Returns the host's own error type (see [`Host::Error`]), so command tables written
210/// against an app's `Result` alias port unchanged.
211pub type Run<S> = fn(&mut S, &Value) -> Result<Value, <S as Host>::Error>;
212
213/// The enabled predicate of a command that is always available.
214pub fn always<S>(_: &S) -> Result<(), String> {
215    Ok(())
216}
217
218/// One command of host state `S`. Build it with [`cmd!`]/[`query!`] (the shape of existing
219/// command tables) or with [`CommandSpec::new`] and its `const` setters.
220pub struct CommandSpec<S: Host> {
221    pub id: &'static str,
222    pub label: &'static str,
223    pub menu: &'static [&'static str],
224    /// Portable shortcut(s): `Mod+Shift+S`, alternatives separated by ` / ` (see [`shortcut`]).
225    pub shortcut: Option<&'static str>,
226    pub params: Params,
227    pub enabled: Enabled<S>,
228    pub run: Run<S>,
229    /// Record in the journal / history (false for pure queries and view commands).
230    pub mutates: bool,
231}
232
233impl<S: Host> CommandSpec<S> {
234    /// A mutating command without menu, shortcut or parameters that is always enabled.
235    pub const fn new(id: &'static str, label: &'static str, run: Run<S>) -> Self {
236        Self { id, label, menu: &[], shortcut: None, params: Params::None, enabled: always::<S>, run, mutates: true }
237    }
238    pub const fn menu(mut self, menu: &'static [&'static str]) -> Self {
239        self.menu = menu;
240        self
241    }
242    pub const fn shortcut(mut self, shortcut: &'static str) -> Self {
243        self.shortcut = Some(shortcut);
244        self
245    }
246    pub const fn enabled(mut self, enabled: Enabled<S>) -> Self {
247        self.enabled = enabled;
248        self
249    }
250    /// A pure query / view command: not journaled.
251    pub const fn query(mut self) -> Self {
252        self.mutates = false;
253        self
254    }
255    pub fn params(mut self, params: Params) -> Self {
256        self.params = params;
257        self
258    }
259}
260
261/// The host state commands run on, and the hooks the chokepoint calls around every command.
262///
263/// [`Registry::execute`] runs, in order: lookup → [`Host::before`] (may rewrite params) → parameter
264/// validation (the registry's [`Validation`] policy, then [`Host::validate`]) → [`Host::enabled_for`] → the command under `catch_unwind` → [`Host::after`] →
265/// journal (mutating commands that succeeded, params passed through [`Host::scrub`]). A call that
266/// is turned away before it runs goes to [`Host::rejected`] instead of `after`.
267///
268/// `depth` is 0 for a call from outside (UI, CLI, agent) and grows by one for each command run
269/// from inside another command; hooks use it to log or sync only once per top-level call.
270///
271/// The chokepoint keeps `depth` itself, including for commands a body runs through
272/// `Registry::execute`. A host touches it ([`Journal::set_depth`]) only to run a command from a
273/// hook, where the chokepoint has already put the depth back to the caller's: to make that command
274/// count as nested in the current call (not journaled as top-level, not logged twice), save
275/// [`Journal::depth`], set it one higher, execute, and set the saved value back.
276///
277/// Mutable execution state (nesting depth, the journal) lives in a [`Journal`] the host owns, so
278/// one `Registry` (often a `static`) serves any number of documents/sessions.
279pub trait Host: Sized {
280    /// The error type command bodies return. `CommandError` itself, or the app's own error with
281    /// `From<AppError> for CommandError` (leave the `id` empty: the chokepoint fills it in).
282    type Error: Into<CommandError>;
283    /// The execution state: nesting depth and journal.
284    fn journal(&mut self) -> &mut Journal;
285    /// Rewrite params before validation (resolve names to ids, settle an ongoing gesture…). An
286    /// `Err` refuses the call: [`Host::rejected`] sees it converted, and
287    /// [`Registry::execute_host`] hands it back unchanged as [`ExecError::Host`].
288    fn before(&mut self, _spec: &CommandSpec<Self>, _params: &mut Value, _depth: usize) -> Result<(), Self::Error> {
289        Ok(())
290    }
291    /// Extra parameter checks after the registry's [`Validation`] policy and before
292    /// [`Host::enabled_for`] (an `Err` is refused and goes to [`Host::rejected`]). With
293    /// [`Validation::Off`] this is where a host validates when it wants to, e.g. only at depth 0
294    /// or only for agent calls. Default: nothing.
295    fn validate(&mut self, _spec: &CommandSpec<Self>, _params: &Value, _depth: usize) -> Result<(), CommandError> {
296        Ok(())
297    }
298    /// Enabled check for one call; override when explicit targets in params change availability
299    /// (the state may be adjusted temporarily, e.g. targets swapped in for the selection).
300    fn enabled_for(&mut self, spec: &CommandSpec<Self>, _params: &Value) -> Result<(), String> {
301        (spec.enabled)(self)
302    }
303    /// Called after every run (ok, error or panic), at every nesting depth.
304    fn after(&mut self, _spec: &CommandSpec<Self>, _params: &Value, _result: &Result<Value, CommandError>, _depth: usize) {}
305    /// Called when a call is refused before running: unknown command, bad params, disabled, too
306    /// deeply nested, or an error from [`Host::before`].
307    fn rejected(&mut self, _id: &str, _error: &CommandError, _depth: usize) {}
308    /// Remove secrets from params before they reach the journal.
309    fn scrub(&self, _id: &str, _params: &mut Value) {}
310}