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}