modcli/loader.rs
1// pub mod custom; // feature = "custom-commands"
2// use crate::custom::CustomCommand; // feature = "custom-commands"
3
4#[cfg(feature = "internal-commands")]
5use crate::commands::{FrameworkCommand, HelloCommand, HelpCommand, PingCommand};
6use crate::output::hook;
7
8use crate::command::Command;
9use crate::error::ModCliError;
10use std::collections::HashMap;
11
12/// Registry for commands and optional alias/prefix routing.
13///
14/// # Example
15/// ```no_run
16/// use modcli::loader::CommandRegistry;
17/// use modcli::command::Command;
18///
19/// struct Echo;
20/// impl Command for Echo {
21/// fn name(&self) -> &str { "echo" }
22/// fn execute(&self, args: &[String]) { println!("{}", args.join(" ")) }
23/// }
24///
25/// let mut reg = CommandRegistry::new();
26/// reg.register(Box::new(Echo));
27/// reg.execute("echo", &["hi".into()]);
28/// ```
29pub struct CommandRegistry {
30 prefix: String,
31 commands: HashMap<String, Box<dyn Command>>,
32 aliases: HashMap<String, String>,
33 #[cfg(feature = "dispatch-cache")]
34 cache: std::sync::Mutex<Option<(String, String)>>,
35}
36
37impl Default for CommandRegistry {
38 fn default() -> Self {
39 Self::new()
40 }
41}
42
43impl CommandRegistry {
44 /// Creates a new command registry
45 pub fn new() -> Self {
46 let mut reg = Self {
47 prefix: String::new(),
48 commands: HashMap::new(),
49 aliases: HashMap::new(),
50 #[cfg(feature = "dispatch-cache")]
51 cache: std::sync::Mutex::new(None),
52 };
53
54 #[cfg(feature = "custom-commands")]
55 reg.load_custom_commands();
56
57 #[cfg(feature = "internal-commands")]
58 reg.load_internal_commands();
59
60 reg
61 }
62
63 /// Sets the command prefix
64 /// Sets an optional prefix used for routing commands of the form `prefix:cmd`.
65 pub fn set_prefix(&mut self, prefix: &str) {
66 self.prefix = prefix.to_string();
67 }
68
69 /// Gets the command prefix
70 /// Returns the configured prefix (empty string if not set).
71 pub fn get_prefix(&self) -> &str {
72 &self.prefix
73 }
74
75 /// Gets a command by name
76 /// Gets a command by its primary name.
77 #[inline(always)]
78 pub fn get(&self, name: &str) -> Option<&dyn Command> {
79 self.commands.get(name).map(|b| b.as_ref())
80 }
81
82 /// Gets a command by name with prefix
83 /// Registers a command and records its aliases for reverse lookup.
84 #[inline(always)]
85 pub fn register(&mut self, cmd: Box<dyn Command>) {
86 // capture name before moving the command
87 let name = cmd.name().to_string();
88 self.commands.insert(name.clone(), cmd);
89
90 // map each alias -> primary name without intermediate Vec allocations
91 for &alias in self.commands[&name].aliases() {
92 // avoid alias clobbering existing command names
93 if !self.commands.contains_key(alias) {
94 // store alias as owned String
95 self.aliases.insert(alias.to_string(), name.clone());
96 }
97 }
98 }
99
100 /// Returns all registered commands (read-only)
101 /// Returns an iterator over all registered commands.
102 pub fn all(&self) -> impl Iterator<Item = &Box<dyn Command>> {
103 self.commands.values()
104 }
105
106 // Note: runtime plugin loading has been removed from core for security/perf.
107
108 /// Resolves and executes a command by name or alias, with optional prefix routing.
109 ///
110 /// Behavior:
111 /// - Applies optional prefix routing (e.g., `tool:hello`).
112 /// - Resolves aliases to primary command names.
113 /// - Validates args via `Command::validate()` and logs a themed error on failure.
114 /// - Executes the command via `execute_with()`.
115 /// - Prints user-facing messages via `output::hook` and does not return an error.
116 ///
117 /// Example (illustrative):
118 /// ```ignore
119 /// use modcli::loader::CommandRegistry;
120 /// let reg = CommandRegistry::new();
121 /// // Will log an unknown command message via output hooks
122 /// reg.execute("does-not-exist", &vec![]);
123 /// ```
124 #[inline(always)]
125 pub fn execute(&self, cmd: &str, args: &[String]) {
126 if let Err(err) = self.try_execute(cmd, args) {
127 match err {
128 ModCliError::InvalidUsage(msg) => hook::error(&format!("Invalid usage: {msg}")),
129 ModCliError::UnknownCommand(name) => hook::unknown(&format!(
130 "[{name}]. Type `help` or `--help` for a list of available commands."
131 )),
132 other => hook::error(&format!("{other}")),
133 }
134 }
135 }
136
137 /// Resolves and executes a command by name or alias, with optional prefix routing.
138 /// Returns a structured error instead of printing/logging directly.
139 ///
140 /// Error mapping:
141 /// - `InvalidUsage(String)`: when `validate()` returns an error string.
142 /// - `UnknownCommand(String)`: command not found after alias/prefix resolution.
143 ///
144 /// Examples (illustrative):
145 ///
146 /// ```ignore
147 /// use modcli::loader::CommandRegistry;
148 /// // Assume `reg` has commands registered
149 /// let reg = CommandRegistry::new();
150 /// // Success
151 /// let _ = reg.try_execute("help", &vec![]);
152 /// // Error mapping (unknown)
153 /// match reg.try_execute("does-not-exist", &vec![]) {
154 /// Err(modcli::error::ModCliError::UnknownCommand(name)) => assert_eq!(name, "does-not-exist"),
155 /// _ => {}
156 /// }
157 /// ```
158 #[inline(always)]
159 pub fn try_execute(&self, cmd: &str, args: &[String]) -> Result<(), ModCliError> {
160 // Strip optional prefix `<prefix>:` without intermediate allocations
161 let token: &str = if !self.prefix.is_empty() && cmd.len() > self.prefix.len() + 1 {
162 let (maybe_prefix, rest_with_colon) = cmd.split_at(self.prefix.len());
163 if maybe_prefix == self.prefix && rest_with_colon.as_bytes().first() == Some(&b':') {
164 &rest_with_colon[1..]
165 } else {
166 cmd
167 }
168 } else {
169 cmd
170 };
171
172 #[cfg(feature = "dispatch-cache")]
173 if let Ok(guard) = self.cache.lock() {
174 if let Some((ref t, ref p)) = *guard {
175 if t == token {
176 if let Some(command) = self.commands.get(p.as_str()) {
177 if let Err(err) = command.validate(args) {
178 return Err(ModCliError::InvalidUsage(err));
179 }
180 command.execute_with(args, self);
181 return Ok(());
182 }
183 }
184 }
185 }
186
187 // Resolve command by direct name or alias with at most two lookups
188 if let Some(command) = self.commands.get(token) {
189 if let Err(err) = command.validate(args) {
190 return Err(ModCliError::InvalidUsage(err));
191 }
192 command.execute_with(args, self);
193 #[cfg(feature = "dispatch-cache")]
194 if let Ok(mut guard) = self.cache.lock() {
195 *guard = Some((token.to_string(), token.to_string()));
196 }
197 return Ok(());
198 }
199
200 if let Some(primary) = self.aliases.get(token) {
201 if let Some(command) = self.commands.get(primary.as_str()) {
202 if let Err(err) = command.validate(args) {
203 return Err(ModCliError::InvalidUsage(err));
204 }
205 command.execute_with(args, self);
206 #[cfg(feature = "dispatch-cache")]
207 if let Ok(mut guard) = self.cache.lock() {
208 *guard = Some((token.to_string(), primary.clone()));
209 }
210 return Ok(());
211 }
212 }
213
214 Err(ModCliError::UnknownCommand(cmd.to_string()))
215 }
216
217 #[cfg(feature = "internal-commands")]
218 pub fn load_internal_commands(&mut self) {
219 self.register(Box::new(PingCommand));
220 self.register(Box::new(HelloCommand));
221 self.register(Box::new(FrameworkCommand));
222 self.register(Box::new(HelpCommand::new()));
223 }
224
225 // Note: JSON loader has been removed from core. Use code registration.
226
227 pub fn len(&self) -> usize {
228 self.commands.len()
229 }
230
231 pub fn is_empty(&self) -> bool {
232 self.commands.is_empty()
233 }
234
235 #[cfg(feature = "custom-commands")]
236 pub fn load_custom_commands(&mut self) {
237 //self.register(Box::new(CustomCommand));
238 }
239}