Skip to main content

rskit_agent/command/
registry.rs

1use std::collections::HashMap;
2
3use rskit_errors::{AppError, ErrorCode};
4
5// ── CommandHandler trait ────────────────────────────────────────────────────
6
7/// Handler for a single slash command.
8pub trait CommandHandler: Send + Sync {
9    /// Execute the command with the given arguments string.
10    fn execute(&self, args: &str) -> Result<String, AppError>;
11}
12
13/// Blanket implementation: any `Fn(&str) -> Result<String, AppError>` can be used as a handler.
14impl<F> CommandHandler for F
15where
16    F: Fn(&str) -> Result<String, AppError> + Send + Sync,
17{
18    fn execute(&self, args: &str) -> Result<String, AppError> {
19        (self)(args)
20    }
21}
22
23// ── Command ─────────────────────────────────────────────────────────────────
24
25/// A registered slash command.
26pub struct Command {
27    /// Command name without the leading slash.
28    pub name: String,
29    /// Short human-readable description shown in command listings.
30    pub description: String,
31    /// Usage string showing accepted arguments.
32    pub usage: String,
33    /// Handler invoked when the command is executed.
34    pub handler: Box<dyn CommandHandler>,
35}
36
37// ── CommandRegistry ─────────────────────────────────────────────────────────
38
39/// Registry of slash commands.
40pub struct CommandRegistry {
41    commands: HashMap<String, Command>,
42}
43
44impl Default for CommandRegistry {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl CommandRegistry {
51    /// Create an empty registry.
52    pub fn new() -> Self {
53        Self {
54            commands: HashMap::new(),
55        }
56    }
57
58    /// Register a command. Returns an error if the name is empty.
59    /// Overwrites any existing command with the same name.
60    pub fn register(&mut self, cmd: Command) -> Result<(), AppError> {
61        if cmd.name.trim().is_empty() {
62            return Err(AppError::new(
63                ErrorCode::InvalidInput,
64                "command name must not be empty",
65            ));
66        }
67        self.commands.insert(cmd.name.clone(), cmd);
68        Ok(())
69    }
70
71    /// Look up a command by name (without the leading `/`).
72    pub fn get(&self, name: &str) -> Option<&Command> {
73        self.commands.get(name)
74    }
75
76    /// Return all registered commands (sorted by name for deterministic output).
77    pub fn list(&self) -> Vec<&Command> {
78        let mut cmds: Vec<&Command> = self.commands.values().collect();
79        cmds.sort_by(|a, b| a.name.cmp(&b.name));
80        cmds
81    }
82
83    /// Check whether the input looks like a slash command.
84    pub fn is_command(input: &str) -> bool {
85        let trimmed = input.trim();
86        trimmed.starts_with('/')
87            && trimmed
88                .chars()
89                .nth(1)
90                .is_some_and(|c| c.is_ascii_alphabetic())
91    }
92
93    /// Parse input into `(command_name, args)`.  Returns `None` if the input is not a slash command.
94    pub fn parse_command(input: &str) -> Option<(&str, &str)> {
95        let trimmed = input.trim();
96        if !Self::is_command(trimmed) {
97            return None;
98        }
99
100        let without_slash = &trimmed[1..];
101        let Some(pos) = without_slash.find(|c: char| c.is_whitespace()) else {
102            return Some((without_slash, ""));
103        };
104        let name = &without_slash[..pos];
105        let args = without_slash[pos..].trim_start();
106        Some((name, args))
107    }
108
109    /// Execute a slash-command input string.
110    pub fn execute(&self, input: &str) -> Result<String, AppError> {
111        let (name, args) = Self::parse_command(input).ok_or_else(|| {
112            AppError::new(ErrorCode::InvalidInput, "input is not a slash command")
113        })?;
114
115        let cmd = self.get(name).ok_or_else(|| {
116            AppError::new(ErrorCode::InvalidInput, format!("unknown command: /{name}"))
117        })?;
118
119        cmd.handler.execute(args)
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    fn command(name: &str) -> Command {
128        Command {
129            name: name.to_string(),
130            description: format!("{name} command"),
131            usage: format!("/{name}"),
132            handler: Box::new(|args: &str| Ok(format!("ran {args}"))),
133        }
134    }
135
136    #[test]
137    fn registry_registers_lists_parses_and_executes_commands() {
138        let mut registry = CommandRegistry::default();
139        registry.register(command("zeta")).unwrap();
140        registry.register(command("alpha")).unwrap();
141
142        let names = registry
143            .list()
144            .into_iter()
145            .map(|cmd| cmd.name.as_str())
146            .collect::<Vec<_>>();
147        assert_eq!(names, vec!["alpha", "zeta"]);
148        assert_eq!(
149            CommandRegistry::parse_command(" /alpha  beta "),
150            Some(("alpha", "beta"))
151        );
152        assert_eq!(registry.execute("/alpha payload").unwrap(), "ran payload");
153    }
154
155    #[test]
156    fn registry_rejects_invalid_or_unknown_commands() {
157        let mut registry = CommandRegistry::new();
158        assert_eq!(
159            registry.register(command(" ")).unwrap_err().code(),
160            ErrorCode::InvalidInput
161        );
162
163        assert!(!CommandRegistry::is_command("hello"));
164        assert!(!CommandRegistry::is_command("/1"));
165        assert_eq!(
166            registry.execute("hello").unwrap_err().code(),
167            ErrorCode::InvalidInput
168        );
169        assert_eq!(
170            registry.execute("/missing").unwrap_err().code(),
171            ErrorCode::InvalidInput
172        );
173    }
174}