falcon_cli/router.rs
1// Copyright 2025 Aquila Labs of Alberta, Canada <matt@cicero.sh>
2// Licensed under either the Apache License, Version 2.0 OR the MIT License, at your option.
3// You may not use this file except in compliance with one of the Licenses.
4// Apache License text: https://www.apache.org/licenses/LICENSE-2.0
5// MIT License text: https://opensource.org/licenses/MIT
6
7use super::{CliCommand, CliHelpScreen, CliRequest};
8use crate::*;
9use std::collections::HashMap;
10use std::env;
11use strsim::levenshtein;
12
13/// The main router for CLI commands.
14///
15/// This struct manages all registered commands, categories, and global flags.
16/// It handles parsing command line arguments and routing them to the appropriate
17/// command handler.
18#[derive(Default)]
19pub struct CliRouter {
20 /// The application name displayed in help screens.
21 pub app_name: String,
22 /// Version message displayed with -v or --version flags.
23 pub version_message: String,
24 /// Internal: Alias of the handler for this router node.
25 pub handler_alias: Option<String>,
26 /// Map of command aliases to their handlers.
27 pub handlers: HashMap<String, CliHandler>,
28 /// Map of command aliases to their implementations.
29 pub commands: HashMap<String, Box<dyn CliCommand>>,
30 /// Map of category aliases to their definitions.
31 pub categories: HashMap<String, CliCategory>,
32 /// Flags to ignore during command lookup.
33 pub ignore_flags: HashMap<String, bool>,
34 /// List of global flags available to all commands.
35 pub global_flags: Vec<CliGlobalFlag>,
36 /// Internal: Whether global flags have been parsed.
37 pub parsed_global_flags: bool,
38 /// Internal: Child routers for nested command structures.
39 pub children: HashMap<String, Box<CliRouter>>,
40}
41
42/// Handler configuration for a CLI command.
43///
44/// Contains metadata about how a command should be invoked and parsed.
45#[derive(Clone)]
46pub struct CliHandler {
47 /// The primary alias for the command.
48 pub alias: String,
49 /// Alternate shortcuts for invoking the command.
50 pub shortcuts: Vec<String>,
51 /// Flags that expect a value (e.g., `--output filename`).
52 pub value_flags: Vec<String>,
53}
54
55/// A category for organizing related commands.
56///
57/// Categories are displayed in the help index and can contain multiple commands.
58#[derive(Clone)]
59pub struct CliCategory {
60 /// The category's alias/identifier.
61 pub alias: String,
62 /// The display title for the category.
63 pub title: String,
64 /// A description of what commands in this category do.
65 pub description: String,
66}
67
68/// A global flag available to all commands.
69///
70/// Global flags are processed before command routing and can be accessed
71/// via the router's `has_global()` and `get_global()` methods.
72#[derive(Clone, Default)]
73pub struct CliGlobalFlag {
74 /// Short form of the flag (e.g., "-v").
75 pub short: String,
76 /// Long form of the flag (e.g., "--verbose").
77 pub long: String,
78 /// Description of what the flag does.
79 pub desc: String,
80 /// Whether this flag expects a value.
81 pub is_value: bool,
82 /// Whether this flag was provided.
83 pub has: bool,
84 /// The value provided with this flag (if applicable).
85 pub value: Option<String>,
86}
87
88impl CliRouter {
89 /// Creates a new CLI router.
90 ///
91 /// # Example
92 ///
93 /// ```
94 /// use falcon_cli::CliRouter;
95 ///
96 /// let mut router = CliRouter::new();
97 /// router.app_name("My Application");
98 /// ```
99 pub fn new() -> Self {
100 Self::default()
101 }
102
103 /// Registers a command with the router.
104 ///
105 /// Links a struct that implements `CliCommand` to a command name, along with
106 /// optional shortcuts and flags that expect values.
107 ///
108 /// # Arguments
109 ///
110 /// * `alias` - The full name of the command
111 /// * `shortcuts` - Vector of alternate ways to invoke the command
112 /// * `value_flags` - Vector of flags that expect a value (e.g., `["--output", "--config"]`)
113 ///
114 /// # Example
115 ///
116 /// ```no_run
117 /// # use falcon_cli::{CliRouter, CliCommand, CliRequest, CliHelpScreen};
118 /// # #[derive(Default)]
119 /// # struct BuildCommand;
120 /// # impl CliCommand for BuildCommand {
121 /// # fn process(&self, req: &CliRequest) -> anyhow::Result<()> { Ok(()) }
122 /// # fn help(&self) -> CliHelpScreen { CliHelpScreen::new("", "", "") }
123 /// # }
124 /// let mut router = CliRouter::new();
125 /// router.add::<BuildCommand>(
126 /// "build",
127 /// vec!["b"],
128 /// vec!["--output", "--config"]
129 /// );
130 /// ```
131 pub fn add<T>(&mut self, alias: &str, shortcuts: Vec<&str>, value_flags: Vec<&str>)
132 where
133 T: CliCommand + Default + 'static,
134 {
135 // Set handler
136 let handler = CliHandler {
137 alias: alias.to_lowercase(),
138 shortcuts: shortcuts.clone().into_iter().map(|s| s.to_string()).collect(),
139 value_flags: value_flags.clone().into_iter().map(|s| s.to_string()).collect(),
140 };
141 self.handlers.insert(alias.to_string(), handler.clone());
142 self.commands.insert(alias.to_lowercase(), Box::<T>::default());
143
144 // Set queue to add
145 let mut queue: Vec<String> = shortcuts.clone().into_iter().map(|s| s.to_string()).collect();
146 queue.insert(0, alias.to_string());
147
148 // Add queue
149 for cmd_alias in queue.iter() {
150 let mut child = &mut *self;
151 for segment in cmd_alias.split_whitespace() {
152 child =
153 child.children.entry(segment.to_string()).or_insert(Box::new(CliRouter::new()));
154 }
155 child.handler_alias = Some(handler.alias.to_string());
156 }
157 }
158
159 /// Sets the application name displayed in help screens.
160 ///
161 /// # Arguments
162 ///
163 /// * `name` - The application name
164 ///
165 /// # Example
166 ///
167 /// ```
168 /// # use falcon_cli::CliRouter;
169 /// let mut router = CliRouter::new();
170 /// router.app_name("MyApp v1.0");
171 /// ```
172 pub fn app_name(&mut self, name: &str) {
173 self.app_name = name.to_string();
174 }
175
176 /// Sets the version message displayed with -v or --version.
177 ///
178 /// # Arguments
179 ///
180 /// * `msg` - The version message
181 ///
182 /// # Example
183 ///
184 /// ```
185 /// # use falcon_cli::CliRouter;
186 /// let mut router = CliRouter::new();
187 /// router.version_message("MyApp version 1.0.0");
188 /// ```
189 pub fn version_message(&mut self, msg: &str) {
190 self.version_message = msg.to_string();
191 }
192
193 /// Registers a global flag available to all commands.
194 ///
195 /// Global flags are processed before command routing and can be checked
196 /// using `has_global()` or retrieved using `get_global()`.
197 ///
198 /// # Arguments
199 ///
200 /// * `short` - Short form of the flag (e.g., "-v")
201 /// * `long` - Long form of the flag (e.g., "--verbose")
202 /// * `is_value` - Whether the flag expects a value
203 /// * `desc` - Description of what the flag does
204 ///
205 /// # Example
206 ///
207 /// ```
208 /// # use falcon_cli::CliRouter;
209 /// let mut router = CliRouter::new();
210 /// router.global("-v", "--verbose", false, "Enable verbose output");
211 /// router.global("-c", "--config", true, "Specify config file");
212 /// ```
213 pub fn global(&mut self, short: &str, long: &str, is_value: bool, desc: &str) {
214 self.global_flags.push(CliGlobalFlag {
215 short: short.to_string(),
216 long: long.to_string(),
217 is_value,
218 desc: desc.to_string(),
219 ..Default::default()
220 });
221 }
222
223 /// Checks if a global flag was provided.
224 ///
225 /// # Arguments
226 ///
227 /// * `flag` - The flag to check (short or long form)
228 ///
229 /// # Returns
230 ///
231 /// Returns `true` if the flag was provided, `false` otherwise.
232 ///
233 /// # Example
234 ///
235 /// ```no_run
236 /// # use falcon_cli::CliRouter;
237 /// let mut router = CliRouter::new();
238 /// router.global("-v", "--verbose", false, "Verbose output");
239 /// if router.has_global("-v") {
240 /// println!("Verbose mode enabled");
241 /// }
242 /// ```
243 pub fn has_global(&mut self, flag: &str) -> bool {
244 if !self.parsed_global_flags {
245 self.get_raw_args();
246 }
247 let flag_chk = flag.to_string();
248
249 if let Some(index) =
250 self.global_flags.iter().position(|gf| gf.short == flag_chk || gf.long == flag_chk)
251 {
252 return self.global_flags[index].has;
253 }
254
255 false
256 }
257
258 /// Gets the value of a global flag.
259 ///
260 /// # Arguments
261 ///
262 /// * `flag` - The flag to retrieve (short or long form)
263 ///
264 /// # Returns
265 ///
266 /// Returns `Some(String)` with the flag's value, or `None` if not provided or not a value flag.
267 ///
268 /// # Example
269 ///
270 /// ```no_run
271 /// # use falcon_cli::CliRouter;
272 /// let mut router = CliRouter::new();
273 /// router.global("-c", "--config", true, "Config file");
274 /// if let Some(config) = router.get_global("--config") {
275 /// println!("Using config: {}", config);
276 /// }
277 /// ```
278 pub fn get_global(&mut self, flag: &str) -> Option<String> {
279 if !self.parsed_global_flags {
280 self.get_raw_args();
281 }
282 let flag_chk = flag.to_string();
283
284 if let Some(index) =
285 self.global_flags.iter().position(|gf| gf.short == flag_chk || gf.long == flag_chk)
286 {
287 return self.global_flags[index].value.clone();
288 }
289
290 None
291 }
292
293 /// Adds a flag to ignore during command lookup.
294 ///
295 /// Ignored flags are stripped from arguments before command routing occurs.
296 ///
297 /// # Arguments
298 ///
299 /// * `flag` - The flag to ignore
300 /// * `is_value` - Whether the flag expects a value (which should also be ignored)
301 ///
302 /// # Example
303 ///
304 /// ```
305 /// # use falcon_cli::CliRouter;
306 /// let mut router = CliRouter::new();
307 /// router.ignore("--internal-flag", false);
308 /// router.ignore("--debug-port", true);
309 /// ```
310 pub fn ignore(&mut self, flag: &str, is_value: bool) {
311 self.ignore_flags.insert(flag.to_string(), is_value);
312 }
313
314 /// Looks up and routes to the appropriate command handler.
315 ///
316 /// This method parses command line arguments, determines which command to execute,
317 /// and returns the parsed request along with the command handler. It is automatically
318 /// called by `cli_run()` and typically should not be called manually.
319 ///
320 /// # Returns
321 ///
322 /// Returns `Some((CliRequest, &Box<dyn CliCommand>))` if a command was found,
323 /// or `None` if no command matched.
324 pub fn lookup(&mut self) -> Option<(CliRequest, &Box<dyn CliCommand>)> {
325 // Get raw args from command line, after filtering ignore flags out
326 let mut args = self.get_raw_args()?;
327
328 // Check for help
329
330 let is_help = self.is_help(&mut args);
331 // Lookup handler
332 let handler = self.lookup_handler(&mut args)?;
333
334 // Gather flags
335 let (flags, flag_values) = self.gather_flags(&mut args, &handler);
336
337 // Return
338 let req = CliRequest {
339 cmd_alias: handler.alias.to_string(),
340 is_help,
341 args,
342 flags,
343 flag_values,
344 shortcuts: handler.shortcuts.to_vec(),
345 };
346
347 let cmd = self.commands.get(&handler.alias).unwrap();
348 Some((req, cmd))
349 }
350
351 fn get_raw_args(&mut self) -> Option<Vec<String>> {
352 let mut cmd_args = vec![];
353 let mut skip_next = true;
354 let mut global_value_index: Option<usize> = None;
355 self.parsed_global_flags = true;
356
357 for value in env::args() {
358 if skip_next {
359 skip_next = false;
360 if let Some(index) = global_value_index {
361 self.global_flags[index].value = Some(value.to_string());
362 global_value_index = None;
363 }
364 continue;
365 }
366
367 if ["-v", "--version"].contains(&value.as_str()) && !self.version_message.is_empty() {
368 println!("{}", self.version_message);
369 std::process::exit(0);
370 } else if let Some(is_value) = self.ignore_flags.get(&value) {
371 skip_next = *is_value;
372 } else if let Some(index) = self
373 .global_flags
374 .iter()
375 .position(|gf| [gf.short.to_string(), gf.long.to_string()].contains(&value))
376 {
377 skip_next = self.global_flags[index].is_value;
378 if skip_next {
379 global_value_index = Some(index);
380 }
381 } else {
382 cmd_args.push(value.to_string());
383 }
384 }
385
386 if !cmd_args.is_empty() {
387 Some(cmd_args)
388 } else {
389 None
390 }
391 }
392
393 /// Check for help being requested
394 fn is_help(&self, args: &mut Vec<String>) -> bool {
395 let mut is_help = false;
396 if ["help", "-h"].contains(&args[0].as_str()) {
397 is_help = true;
398 args.remove(0);
399
400 if args.is_empty() {
401 CliHelpScreen::render_index(self);
402 }
403
404 // Check category help
405 let cat_alias = args.join(" ").to_string();
406 if self.categories.contains_key(&cat_alias) {
407 CliHelpScreen::render_category(&self, &cat_alias);
408 }
409 }
410
411 is_help
412 }
413
414 fn lookup_handler(&self, args: &mut Vec<String>) -> Option<CliHandler> {
415 let mut h_alias: Option<String> = None;
416 let (mut start, mut length) = (0, 0);
417
418 let mut child = self;
419 for (pos, segment) in args.iter().enumerate() {
420 if segment.starts_with("-") {
421 continue;
422 }
423
424 if let Some(next) = child.children.get(&segment.to_lowercase()) {
425 if length == 0 {
426 (start, length) = (pos, 1);
427 } else {
428 length += 1;
429 }
430
431 if let Some(h_child) = &next.handler_alias {
432 h_alias = Some(h_child.clone());
433 }
434 child = next;
435 } else if h_alias.is_some() {
436 break;
437 } else {
438 child = self;
439 length = 0;
440 }
441 }
442
443 // Check for typos, if none
444 if h_alias.is_none() {
445 h_alias = self.lookup_similar(args);
446 } else if h_alias.is_some() {
447 args.drain(start..start + length);
448 } else {
449 return None;
450 }
451
452 let handler = self.handlers.get(&h_alias?)?;
453 Some(handler.clone())
454 }
455
456 fn gather_flags(
457 &self,
458 args: &mut Vec<String>,
459 handler: &CliHandler,
460 ) -> (Vec<String>, HashMap<String, String>) {
461 let mut incl_value = false;
462 let mut flags = vec![];
463 let mut flag_values: HashMap<String, String> = HashMap::new();
464 let mut final_args = vec![];
465
466 // Iterate over args
467 for (pos, value) in args.iter().enumerate() {
468 if incl_value {
469 flag_values.insert(args[pos - 1].to_string(), value.to_string());
470 incl_value = false;
471 } else if value.starts_with("-") && handler.value_flags.contains(&value) {
472 incl_value = true;
473 } else if value.starts_with("--") {
474 flags.push(value.to_string());
475 } else if value.starts_with("-") {
476 for char in value[1..].chars() {
477 flags.push(format!("-{}", char));
478 }
479 } else {
480 final_args.push(value.to_string());
481 }
482 }
483
484 *args = final_args;
485 (flags, flag_values)
486 }
487
488 /// Attempts to find a similar command when an exact match isn't found.
489 ///
490 /// Uses Levenshtein distance to find commands that closely resemble the input,
491 /// handling potential typos. If a close match is found, prompts the user for confirmation.
492 /// This method is called automatically by `lookup()`.
493 ///
494 /// # Arguments
495 ///
496 /// * `args` - The command line arguments to search against
497 ///
498 /// # Returns
499 ///
500 /// Returns `Some(String)` with the corrected command name if found and confirmed,
501 /// or `None` otherwise.
502 fn lookup_similar(&self, args: &mut Vec<String>) -> Option<String> {
503 let start = args.iter().position(|a| !a.starts_with("-")).unwrap_or(0);
504 let search_args =
505 args.clone().into_iter().filter(|a| !a.starts_with("-")).collect::<Vec<String>>();
506
507 // Get available commands to search
508 let mut commands: Vec<String> = self.commands.keys().map(|c| c.to_string()).collect();
509 commands.sort_by(|a, b| {
510 let a_count = a.chars().filter(|c| c.is_whitespace()).count();
511 let b_count = b.chars().filter(|c| c.is_whitespace()).count();
512 b_count.cmp(&a_count)
513 });
514 let (mut distance, mut bin_length, mut found_cmd) = (0, 0, String::new());
515
516 // Go through commands
517 for chk_alias in commands {
518 let length = chk_alias.chars().filter(|c| c.is_whitespace()).count() + 1;
519
520 // Check lowest distance, if we're completed a bin
521 if bin_length != length && bin_length > 0 && distance > 0 && distance < 4 {
522 let confirm_msg = format!(
523 "No command with that name exists, but a similar command with the name '{}' does exist. Is this the command you wish to run?",
524 found_cmd
525 );
526 if cli_confirm(&confirm_msg) {
527 let end = (start + length).min(args.len());
528 args.drain(start..end);
529 return Some(found_cmd);
530 } else {
531 return None;
532 }
533 } else if bin_length != length {
534 bin_length = length;
535 distance = 0;
536 found_cmd = String::new();
537 }
538
539 let end = search_args.len().min(length);
540 let search_str = search_args[..end].join(" ").to_string();
541
542 let chk_distance = levenshtein(&chk_alias, &search_str);
543 if chk_distance < distance || distance == 0 {
544 distance = chk_distance;
545 found_cmd = chk_alias.to_string();
546 }
547 }
548
549 None
550 }
551
552 /// Adds a category for organizing related commands.
553 ///
554 /// Categories are displayed in the help index and can contain multiple commands.
555 /// Useful for organizing large CLI applications with many commands.
556 ///
557 /// # Arguments
558 ///
559 /// * `alias` - The category's identifier
560 /// * `title` - The display title for the category
561 /// * `description` - Description of what commands in this category do
562 ///
563 /// # Example
564 ///
565 /// ```
566 /// # use falcon_cli::CliRouter;
567 /// let mut router = CliRouter::new();
568 /// router.add_category("database", "Database Commands", "Manage database operations");
569 /// router.add_category("user", "User Commands", "Manage user accounts");
570 /// ```
571 pub fn add_category(&mut self, alias: &str, title: &str, description: &str) {
572 self.categories.insert(
573 alias.to_lowercase(),
574 CliCategory {
575 alias: alias.to_lowercase(),
576 title: title.to_string(),
577 description: description.to_string(),
578 },
579 );
580 }
581}