Skip to main content

ashlar_command/
registry.rs

1//! The command table of one app and its execute chokepoint.
2
3use std::collections::HashMap;
4use std::panic::{AssertUnwindSafe, catch_unwind};
5use std::sync::OnceLock;
6
7use serde_json::Value;
8
9use crate::{CommandError, CommandInfo, CommandSpec, Dispatch, ExecError, Host, Params, params, shortcut};
10
11/// Commands may run other commands up to this depth; deeper calls are refused (a command that
12/// calls itself, directly or through others, cannot overflow the stack).
13pub const MAX_DEPTH: usize = 64;
14
15/// Which declarations [`Registry::execute`] checks params against before [`Host::enabled_for`]
16/// (set with [`Registry::with_validation`]). Whatever the policy, [`Host::validate`] runs at the
17/// same step, and [`Registry::validate`] stays available for checking on demand.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum Validation {
20    /// Every declaration: typed params fully, legacy text docs loosely (keys the doc clearly does
21    /// not mention, non-object params). The default.
22    #[default]
23    Strict,
24    /// Only typed declarations ([`Params::Typed`], [`Params::Static`]); legacy text docs and
25    /// [`Params::None`] accept anything. For a table whose text docs are descriptions, not contracts.
26    TypedOnly,
27    /// None at the chokepoint: a call goes straight to [`Host::enabled_for`], so a disabled
28    /// command reports `Disabled` whatever its params. The host validates where it wants to
29    /// ([`Host::validate`], or [`Registry::validate`] from its own code).
30    Off,
31}
32
33/// The command table of one app. Immutable once built (a `static` is typical); the execution state
34/// lives in the host's [`crate::Journal`].
35pub struct Registry<S: Host> {
36    specs: Vec<CommandSpec<S>>,
37    index: HashMap<&'static str, usize>,
38    common: &'static [&'static str],
39    aliases: &'static [(&'static str, &'static str)],
40    schemas: OnceLock<Vec<Value>>,
41    validation: Validation,
42}
43
44impl<S: Host> Registry<S> {
45    /// Index the table. When an id appears twice the first spec wins (see [`Registry::duplicates`]).
46    pub fn new(specs: Vec<CommandSpec<S>>) -> Self {
47        let mut index = HashMap::with_capacity(specs.len());
48        for (i, c) in specs.iter().enumerate() {
49            index.entry(c.id).or_insert(i);
50        }
51        Self { specs, index, common: &[], aliases: &[], schemas: OnceLock::new(), validation: Validation::Strict }
52    }
53
54    /// Keys every command accepts on top of its own (document targeting, gesture merging…).
55    pub fn with_common_params(mut self, keys: &'static [&'static str]) -> Self {
56        self.common = keys;
57        self
58    }
59
60    /// Alternative spellings legacy text-documented commands accept: `(documented, alias)`.
61    pub fn with_param_aliases(mut self, aliases: &'static [(&'static str, &'static str)]) -> Self {
62        self.aliases = aliases;
63        self
64    }
65
66    /// Where the chokepoint validates params (default [`Validation::Strict`]).
67    pub fn with_validation(mut self, validation: Validation) -> Self {
68        self.validation = validation;
69        self
70    }
71
72    pub fn validation(&self) -> Validation {
73        self.validation
74    }
75
76    pub fn find(&self, id: &str) -> Option<&CommandSpec<S>> {
77        self.index.get(id).and_then(|&i| self.specs.get(i))
78    }
79
80    pub fn specs(&self) -> &[CommandSpec<S>] {
81        &self.specs
82    }
83
84    pub fn len(&self) -> usize {
85        self.specs.len()
86    }
87
88    pub fn is_empty(&self) -> bool {
89        self.specs.is_empty()
90    }
91
92    /// Ids declared more than once (a test should assert this is empty).
93    pub fn duplicates(&self) -> Vec<&'static str> {
94        self.specs.iter().enumerate().filter(|(i, c)| self.index.get(c.id) != Some(i)).map(|(_, c)| c.id).collect()
95    }
96
97    /// Check params against a command's declaration without running it.
98    pub fn validate(&self, spec: &CommandSpec<S>, params: &Value) -> Result<(), CommandError> {
99        params::validate_params(spec.id, &spec.params, params, self.common, self.aliases)
100    }
101
102    /// The JSON Schema of a command's parameters (computed once for the whole table, on first use).
103    pub fn schema(&self, id: &str) -> Option<&Value> {
104        let i = *self.index.get(id)?;
105        self.schemas().get(i)
106    }
107
108    fn schemas(&self) -> &[Value] {
109        self.schemas.get_or_init(|| self.specs.iter().map(|c| params::params_schema(&c.params)).collect())
110    }
111
112    /// Discovery info of one command against the current state (enabled by its own predicate).
113    pub fn info(&self, spec: &CommandSpec<S>, state: &S) -> CommandInfo {
114        let enabled = (spec.enabled)(state);
115        let schema = self.schema(spec.id).cloned().unwrap_or(Value::Null);
116        CommandInfo {
117            id: spec.id.to_string(),
118            label: spec.label.to_string(),
119            menu: spec.menu.iter().map(|m| (*m).to_string()).collect(),
120            shortcut: spec.shortcut.map(|s| shortcut::normalize(s).unwrap_or_else(|| s.to_string())),
121            params_schema: schema,
122            params_text: match spec.params {
123                crate::Params::Text(t) => Some(t.to_string()),
124                _ => None,
125            },
126            enabled: enabled.is_ok(),
127            disabled_reason: enabled.err(),
128            mutates: spec.mutates,
129        }
130    }
131
132    /// Discovery info of every command.
133    pub fn infos(&self, state: &S) -> Vec<CommandInfo> {
134        self.specs.iter().map(|c| self.info(c, state)).collect()
135    }
136
137    /// The one chokepoint every caller goes through (UI, menus, palette, shortcuts, CLI, control
138    /// port, MCP, and commands running other commands). See [`Host`] for the order of steps.
139    /// Host errors come back converted into [`CommandError`]; [`Registry::execute_host`] keeps them.
140    pub fn execute(&self, state: &mut S, id: &str, params: Value) -> Result<Value, CommandError> {
141        let converted = |e: S::Error| {
142            let e = e.into().or_id(id);
143            (e.clone(), e)
144        };
145        self.chokepoint(state, id, params, converted).map_err(|e| match e {
146            ExecError::Command(e) | ExecError::Host(e) => e,
147        })
148    }
149
150    /// [`Registry::execute`] for a host whose callers match on its own error type: an error a
151    /// command body returned comes back unchanged as [`ExecError::Host`]; a refusal or failure of
152    /// the chokepoint itself (unknown, too deep, validation, disabled, panic) is
153    /// [`ExecError::Command`]. An error from [`Host::before`] is the host's too: `Host`. Same steps and hooks; [`Host::after`] still sees the error converted
154    /// into a `CommandError` (hence `Clone`: the original is kept for the caller).
155    pub fn execute_host(&self, state: &mut S, id: &str, params: Value) -> Result<Value, ExecError<S::Error>>
156    where
157        S::Error: Clone,
158    {
159        self.chokepoint(state, id, params, |e: S::Error| (e.clone().into().or_id(id), e))
160    }
161
162    /// The steps of [`Host`]. `host_error` turns an error a body returned into what `after` sees
163    /// and what the caller gets.
164    fn chokepoint<E>(
165        &self,
166        state: &mut S,
167        id: &str,
168        params: Value,
169        host_error: impl FnOnce(S::Error) -> (CommandError, E),
170    ) -> Result<Value, ExecError<E>> {
171        let depth = state.journal().depth();
172        let Some(spec) = self.find(id) else {
173            return reject(state, id, CommandError::Unknown { id: id.to_string() }, depth);
174        };
175        if depth >= MAX_DEPTH {
176            let e = CommandError::failed(id, format!("commands are nested more than {MAX_DEPTH} deep (a command that runs itself?)"));
177            return reject(state, id, e, depth);
178        }
179        let mut params = params;
180        if let Err(e) = state.before(spec, &mut params, depth) {
181            let (seen, host) = host_error(e);
182            state.rejected(id, &seen, depth);
183            return Err(ExecError::Host(host));
184        }
185        if let Err(e) = self.validate_by_policy(spec, &params) {
186            return reject(state, id, e, depth);
187        }
188        if let Err(e) = state.validate(spec, &params, depth) {
189            return reject(state, id, e.or_id(id), depth);
190        }
191        if let Err(reason) = state.enabled_for(spec, &params) {
192            return reject(state, id, CommandError::Disabled { id: id.to_string(), reason }, depth);
193        }
194        state.journal().set_depth(depth + 1);
195        // timed only when someone listens, and only at the top level
196        let started = (depth == 0 && state.journal().observer().is_some()).then(web_time::Instant::now);
197        // Last-resort guard: a panic in a command (a bug that escaped the never-crash rules)
198        // becomes an error for the caller instead of taking the app and its unsaved work down.
199        let run = catch_unwind(AssertUnwindSafe(|| (spec.run)(state, &params)));
200        // set, not decrement: a nested call that went wrong cannot leave the depth off by one
201        state.journal().set_depth(depth);
202        // `after` sees a `CommandError`; the caller gets the host's own error when there is one
203        let mut kept = None;
204        let result = match run {
205            Ok(Ok(v)) => Ok(v),
206            Ok(Err(e)) => {
207                let (seen, host) = host_error(e);
208                kept = Some(host);
209                Err(seen)
210            }
211            Err(_) => Err(CommandError::Panicked { id: id.to_string() }),
212        };
213        state.after(spec, &params, &result, depth);
214        if let Some(started) = started
215            && let Some(observe) = state.journal().observer().cloned()
216        {
217            observe(crate::CommandRun { id: spec.id, ok: result.is_ok(), took: started.elapsed() });
218        }
219        if result.is_ok() && spec.mutates && state.journal().records(depth) {
220            // a secret must never reach the journal (and with it session logs)
221            state.scrub(id, &mut params);
222            state.journal().push(id, params);
223        }
224        result.map_err(|e| kept.map_or(ExecError::Command(e), ExecError::Host))
225    }
226
227    fn validate_by_policy(&self, spec: &CommandSpec<S>, params: &Value) -> Result<(), CommandError> {
228        match (self.validation, &spec.params) {
229            (Validation::Strict, _) | (Validation::TypedOnly, Params::Typed(_) | Params::Static(_)) => self.validate(spec, params),
230            (Validation::TypedOnly | Validation::Off, _) => Ok(()),
231        }
232    }
233
234    /// Bind the table to a state: a [`Dispatch`] for discovery and execution.
235    pub fn bind<'a>(&'a self, state: &'a mut S) -> Bound<'a, S> {
236        Bound { registry: self, state }
237    }
238}
239
240fn reject<S: Host, E>(state: &mut S, id: &str, e: CommandError, depth: usize) -> Result<Value, ExecError<E>> {
241    state.rejected(id, &e, depth);
242    Err(ExecError::Command(e))
243}
244
245/// A registry bound to one state: what control port, MCP and CLI drive through [`Dispatch`].
246pub struct Bound<'a, S: Host> {
247    pub registry: &'a Registry<S>,
248    pub state: &'a mut S,
249}
250
251impl<S: Host> Dispatch for Bound<'_, S> {
252    fn commands(&self) -> Vec<CommandInfo> {
253        self.registry.infos(self.state)
254    }
255    fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
256        self.registry.execute(self.state, id, params)
257    }
258}