Skip to main content

zeph_commands/
lib.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Slash command registry, handler trait, and channel sink abstraction for Zeph.
5//!
6//! This crate provides the non-generic infrastructure for slash command dispatch:
7//! - [`ChannelSink`] — minimal async I/O trait replacing the `C: Channel` generic in handlers
8//! - [`CommandOutput`] — exhaustive result type for command execution
9//! - [`SlashCategory`] — grouping enum for `/help` output
10//! - [`CommandInfo`] — static metadata for a registered command
11//! - [`CommandHandler`] — object-safe handler trait (no `C` generic)
12//! - [`CommandRegistry`] — registry with longest-word-boundary dispatch
13//! - [`CommandContext`] — non-generic dispatch context with trait-object fields
14//! - [`traits`] — sub-trait definitions for subsystem access
15//! - [`handlers`] — concrete handler implementations (session, debug)
16//!
17//! # Design
18//!
19//! `CommandRegistry` and `CommandHandler` are non-generic: they operate on [`CommandContext`],
20//! a concrete struct whose fields are trait objects (`&mut dyn DebugAccess`, etc.). `zeph-core`
21//! implements these traits on its internal state types and constructs `CommandContext` at dispatch
22//! time from `Agent<C>` fields.
23//!
24//! This crate does NOT depend on `zeph-core`. A change in `zeph-core`'s agent loop does
25//! not recompile `zeph-commands`.
26
27pub mod commands;
28pub mod context;
29pub mod handlers;
30pub mod sink;
31pub mod traits;
32
33pub use commands::COMMANDS;
34
35pub use context::CommandContext;
36pub use sink::{ChannelSink, NullSink};
37pub use traits::agent::{AgentAccess, NullAgent};
38
39use std::future::Future;
40use std::pin::Pin;
41
42/// Result of executing a slash command.
43///
44/// Replaces the heterogeneous return types of earlier command dispatch with a unified,
45/// exhaustive enum.
46#[derive(Debug)]
47pub enum CommandOutput {
48    /// Send a message to the user via the channel.
49    Message(String),
50    /// Command handled silently; no output (e.g., `/clear`).
51    Silent,
52    /// Exit the agent loop immediately.
53    Exit,
54    /// Continue to the next loop iteration.
55    Continue,
56}
57
58/// Category for grouping commands in `/help` output.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum SlashCategory {
61    /// Session management: `/clear`, `/reset`, `/exit`, etc.
62    Session,
63    /// Model and provider configuration: `/model`, `/provider`, `/guardrail`, etc.
64    Configuration,
65    /// Memory and knowledge: `/memory`, `/graph`, `/compact`, etc.
66    Memory,
67    /// Skill management: `/skill`, `/skills`, etc.
68    Skills,
69    /// Planning and focus: `/plan`, `/focus`, `/sidequest`, etc.
70    Planning,
71    /// Debugging and diagnostics: `/debug-dump`, `/log`, `/lsp`, etc.
72    Debugging,
73    /// External integrations: `/mcp`, `/image`, `/agent`, etc.
74    Integration,
75    /// Advanced and experimental: `/experiment`, `/policy`, `/scheduler`, etc.
76    Advanced,
77}
78
79impl SlashCategory {
80    /// Return the display label for this category in `/help` output.
81    #[must_use]
82    pub fn as_str(self) -> &'static str {
83        match self {
84            Self::Session => "Session",
85            Self::Configuration => "Configuration",
86            Self::Memory => "Memory",
87            Self::Skills => "Skills",
88            Self::Planning => "Planning",
89            Self::Debugging => "Debugging",
90            Self::Integration => "Integration",
91            Self::Advanced => "Advanced",
92        }
93    }
94}
95
96/// Static metadata about a registered command, used for `/help` output generation.
97pub struct CommandInfo {
98    /// Command name including the leading slash, e.g. `"/help"`.
99    pub name: &'static str,
100    /// Argument hint shown after the command name in help, e.g. `"[path]"`.
101    pub args: &'static str,
102    /// One-line description shown in `/help` output.
103    pub description: &'static str,
104    /// Category for grouping in `/help`.
105    pub category: SlashCategory,
106    /// Feature gate label, if this command is conditionally compiled.
107    pub feature_gate: Option<&'static str>,
108}
109
110/// Error type returned by command handlers.
111///
112/// Wraps agent-level errors as a string to avoid depending on `zeph-core`'s `AgentError`.
113/// `zeph-core` converts between `AgentError` and `CommandError` at the dispatch boundary.
114#[derive(Debug, thiserror::Error)]
115#[error("{0}")]
116pub struct CommandError(pub String);
117
118impl CommandError {
119    /// Create a `CommandError` from any displayable value.
120    pub fn new(msg: impl std::fmt::Display) -> Self {
121        Self(msg.to_string())
122    }
123}
124
125/// A slash command handler that can be registered with [`CommandRegistry`].
126///
127/// Implementors must be `Send + Sync` because the registry is constructed at agent
128/// initialization time and handlers may be invoked from async contexts.
129///
130/// # Object safety
131///
132/// The `handle` method uses `Pin<Box<dyn Future>>` instead of `async fn` to remain
133/// object-safe, enabling the registry to store `Box<dyn CommandHandler<Ctx>>`. Slash
134/// commands are user-initiated so the box allocation is negligible.
135pub trait CommandHandler<Ctx: ?Sized>: Send + Sync {
136    /// Command name including the leading slash, e.g. `"/help"`.
137    ///
138    /// Must be unique per registry. Used as the dispatch key.
139    fn name(&self) -> &'static str;
140
141    /// One-line description shown in `/help` output.
142    fn description(&self) -> &'static str;
143
144    /// Argument hint shown after the command name in help, e.g. `"[path]"`.
145    ///
146    /// Return an empty string if the command takes no arguments.
147    fn args_hint(&self) -> &'static str {
148        ""
149    }
150
151    /// Category for grouping in `/help`.
152    fn category(&self) -> SlashCategory;
153
154    /// Feature gate label, if this command is conditionally compiled.
155    fn feature_gate(&self) -> Option<&'static str> {
156        None
157    }
158
159    /// Execute the command.
160    ///
161    /// # Arguments
162    ///
163    /// - `ctx`: Typed access to agent subsystems.
164    /// - `args`: Trimmed text after the command name. Empty string when no args given.
165    ///
166    /// # Errors
167    ///
168    /// Returns `Err(CommandError)` when the command fails. The dispatch site logs and
169    /// reports the error to the user.
170    fn handle<'a>(
171        &'a self,
172        ctx: &'a mut Ctx,
173        args: &'a str,
174    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>;
175}
176
177/// Registry of slash command handlers.
178///
179/// Handlers are stored in a `Vec`, not a `HashMap`, because command count is small (< 40)
180/// and registration happens once at agent initialization. Dispatch performs a linear scan
181/// with longest-word-boundary match to support subcommands.
182///
183/// # Dispatch
184///
185/// See [`CommandRegistry::dispatch`] for the full dispatch algorithm.
186///
187/// # Borrow splitting
188///
189/// When stored as an `Agent<C>` field, the dispatch call site uses `std::mem::take` to
190/// temporarily move the registry out of the agent, construct a context, dispatch, and
191/// restore the registry. This avoids borrow-checker conflicts.
192pub struct CommandRegistry<Ctx: ?Sized> {
193    handlers: Vec<Box<dyn CommandHandler<Ctx>>>,
194}
195
196impl<Ctx: ?Sized> CommandRegistry<Ctx> {
197    /// Create an empty registry.
198    #[must_use]
199    pub fn new() -> Self {
200        Self {
201            handlers: Vec::new(),
202        }
203    }
204
205    /// Register a command handler.
206    ///
207    /// # Panics
208    ///
209    /// Panics if a handler with the same name is already registered.
210    pub fn register(&mut self, handler: impl CommandHandler<Ctx> + 'static) {
211        let name = handler.name();
212        assert!(
213            !self.handlers.iter().any(|h| h.name() == name),
214            "duplicate command name: {name}"
215        );
216        self.handlers.push(Box::new(handler));
217    }
218
219    /// Dispatch a command string to the matching handler.
220    ///
221    /// Returns `None` if the input does not start with `/` or no handler matches.
222    ///
223    /// # Algorithm
224    ///
225    /// 1. Return `None` if `input` does not start with `/`.
226    /// 2. Find all handlers where `input == name` or `input.starts_with(name + " ")`.
227    /// 3. Pick the handler with the longest matching name (subcommand resolution).
228    /// 4. Extract `args = input[name.len()..].trim()`.
229    /// 5. Call `handler.handle(ctx, args)` and return the result.
230    ///
231    /// # Errors
232    ///
233    /// Returns `Some(Err(_))` when the matched handler returns an error.
234    pub async fn dispatch(
235        &self,
236        ctx: &mut Ctx,
237        input: &str,
238    ) -> Option<Result<CommandOutput, CommandError>> {
239        let trimmed = input.trim();
240        if !trimmed.starts_with('/') {
241            return None;
242        }
243
244        let mut best_len: usize = 0;
245        let mut best_idx: Option<usize> = None;
246        for (idx, handler) in self.handlers.iter().enumerate() {
247            let name = handler.name();
248            let matched = trimmed == name
249                || trimmed
250                    .strip_prefix(name)
251                    .is_some_and(|rest| rest.starts_with(' '));
252            if matched && name.len() >= best_len {
253                best_len = name.len();
254                best_idx = Some(idx);
255            }
256        }
257
258        let handler = &self.handlers[best_idx?];
259        let name = handler.name();
260        let args = trimmed[name.len()..].trim();
261        Some(handler.handle(ctx, args).await)
262    }
263
264    /// Find the handler that would be selected for the given input, without dispatching.
265    ///
266    /// Returns `Some((idx, name))` or `None` if no handler matches.
267    /// Primarily used in tests to verify routing.
268    #[must_use]
269    pub fn find_handler(&self, input: &str) -> Option<(usize, &'static str)> {
270        let trimmed = input.trim();
271        if !trimmed.starts_with('/') {
272            return None;
273        }
274        let mut best_len: usize = 0;
275        let mut best: Option<(usize, &'static str)> = None;
276        for (idx, handler) in self.handlers.iter().enumerate() {
277            let name = handler.name();
278            let matched = trimmed == name
279                || trimmed
280                    .strip_prefix(name)
281                    .is_some_and(|rest| rest.starts_with(' '));
282            if matched && name.len() >= best_len {
283                best_len = name.len();
284                best = Some((idx, name));
285            }
286        }
287        best
288    }
289
290    /// List all registered commands for `/help` generation.
291    ///
292    /// Returns metadata in registration order.
293    #[must_use]
294    pub fn list(&self) -> Vec<CommandInfo> {
295        self.handlers
296            .iter()
297            .map(|h| CommandInfo {
298                name: h.name(),
299                args: h.args_hint(),
300                description: h.description(),
301                category: h.category(),
302                feature_gate: h.feature_gate(),
303            })
304            .collect()
305    }
306}
307
308impl<Ctx: ?Sized> Default for CommandRegistry<Ctx> {
309    fn default() -> Self {
310        Self::new()
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use std::future::Future;
318    use std::pin::Pin;
319
320    struct MockCtx;
321
322    struct FixedHandler {
323        name: &'static str,
324        category: SlashCategory,
325    }
326
327    impl CommandHandler<MockCtx> for FixedHandler {
328        fn name(&self) -> &'static str {
329            self.name
330        }
331
332        fn description(&self) -> &'static str {
333            "test handler"
334        }
335
336        fn category(&self) -> SlashCategory {
337            self.category
338        }
339
340        fn handle<'a>(
341            &'a self,
342            _ctx: &'a mut MockCtx,
343            args: &'a str,
344        ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>>
345        {
346            let name = self.name;
347            Box::pin(async move { Ok(CommandOutput::Message(format!("{name}:{args}"))) })
348        }
349    }
350
351    fn make_handler(name: &'static str) -> FixedHandler {
352        FixedHandler {
353            name,
354            category: SlashCategory::Session,
355        }
356    }
357
358    #[tokio::test]
359    async fn dispatch_routes_longest_match() {
360        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
361        reg.register(make_handler("/plan"));
362        reg.register(make_handler("/plan confirm"));
363
364        let mut ctx = MockCtx;
365        let out = reg
366            .dispatch(&mut ctx, "/plan confirm foo")
367            .await
368            .unwrap()
369            .unwrap();
370        let CommandOutput::Message(msg) = out else {
371            panic!("expected Message");
372        };
373        assert_eq!(msg, "/plan confirm:foo");
374    }
375
376    #[tokio::test]
377    async fn dispatch_returns_none_for_non_slash() {
378        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
379        reg.register(make_handler("/help"));
380        let mut ctx = MockCtx;
381        assert!(reg.dispatch(&mut ctx, "hello").await.is_none());
382    }
383
384    #[tokio::test]
385    async fn dispatch_returns_none_for_unregistered() {
386        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
387        reg.register(make_handler("/help"));
388        let mut ctx = MockCtx;
389        assert!(reg.dispatch(&mut ctx, "/unknown").await.is_none());
390    }
391
392    #[test]
393    #[should_panic(expected = "duplicate command name")]
394    fn register_panics_on_duplicate() {
395        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
396        reg.register(make_handler("/plan"));
397        reg.register(make_handler("/plan"));
398    }
399
400    #[test]
401    fn list_returns_metadata_in_order() {
402        let mut reg: CommandRegistry<MockCtx> = CommandRegistry::new();
403        reg.register(make_handler("/alpha"));
404        reg.register(make_handler("/beta"));
405        let list = reg.list();
406        assert_eq!(list.len(), 2);
407        assert_eq!(list[0].name, "/alpha");
408        assert_eq!(list[1].name, "/beta");
409    }
410
411    #[test]
412    fn slash_category_as_str_all_variants() {
413        let variants = [
414            (SlashCategory::Session, "Session"),
415            (SlashCategory::Configuration, "Configuration"),
416            (SlashCategory::Memory, "Memory"),
417            (SlashCategory::Skills, "Skills"),
418            (SlashCategory::Planning, "Planning"),
419            (SlashCategory::Debugging, "Debugging"),
420            (SlashCategory::Integration, "Integration"),
421            (SlashCategory::Advanced, "Advanced"),
422        ];
423        for (variant, expected) in variants {
424            assert_eq!(variant.as_str(), expected);
425        }
426    }
427}