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}