falcon_cli/
request.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 crate::error::CliError;
8use std::collections::HashMap;
9use std::fs;
10use std::ops::Range;
11use url::Url;
12
13/// Represents a parsed CLI command request.
14///
15/// This struct contains all the parsed information from a command line invocation,
16/// including the command name, arguments, flags, and their values.
17pub struct CliRequest {
18    /// The primary alias of the command that was invoked.
19    pub cmd_alias: String,
20    /// Whether help was requested for this command.
21    pub is_help: bool,
22    /// Positional arguments passed to the command.
23    pub args: Vec<String>,
24    /// Boolean flags that were provided (e.g., `-v`, `--verbose`).
25    pub flags: Vec<String>,
26    /// Flags with associated values (e.g., `--output file.txt`).
27    pub flag_values: HashMap<String, String>,
28    /// List of shortcut aliases for this command.
29    pub shortcuts: Vec<String>,
30}
31
32/// Format validators for command arguments and flags.
33///
34/// These validators can be used to ensure arguments and flags conform to
35/// expected formats before processing.
36#[derive(Clone, PartialEq)]
37pub enum CliFormat {
38    /// Accept any string value.
39    Any,
40    /// Must be a valid integer.
41    Integer,
42    /// Must be a valid decimal number.
43    Decimal,
44    /// Must be a boolean value (true/false, yes/no, 1/0).
45    Boolean,
46    /// Must be a valid email address.
47    Email,
48    /// Must be a valid URL.
49    Url,
50    /// String length must be within the specified range.
51    StringRange(Range<usize>),
52    /// Integer value must be within the specified range.
53    IntegerRange(Range<i64>),
54    /// Decimal value must be within the specified range.
55    DecimalRange(Range<f64>),
56    /// Value must be one of the specified options.
57    OneOf(Vec<String>),
58    /// Must be a path to an existing file.
59    File,
60    /// Must be a path to an existing directory.
61    Directory,
62}
63
64impl CliRequest {
65    /// Ensures that at least the specified number of parameters were provided.
66    ///
67    /// # Arguments
68    ///
69    /// * `num` - The minimum number of required parameters
70    ///
71    /// # Returns
72    ///
73    /// Returns `Ok(())` if enough parameters were provided, or `CliError::MissingParams` otherwise.
74    ///
75    /// # Example
76    ///
77    /// ```no_run
78    /// # use falcon_cli::{CliRequest, CliCommand, CliHelpScreen};
79    /// # struct MyCmd;
80    /// # impl CliCommand for MyCmd {
81    /// #   fn help(&self) -> CliHelpScreen { CliHelpScreen::new("", "", "") }
82    /// fn process(&self, req: &CliRequest) -> anyhow::Result<()> {
83    ///     req.require_params(2)?;  // Require at least 2 parameters
84    ///     let source = &req.args[0];
85    ///     let dest = &req.args[1];
86    ///     // ... process command
87    ///     Ok(())
88    /// }
89    /// # }
90    /// ```
91    pub fn require_params(&self, num: usize) -> Result<(), CliError> {
92        match self.args.len() {
93            len if len >= num => Ok(()),
94            _ => Err(CliError::MissingParams),
95        }
96    }
97
98    /// Ensures that the specified flag was provided.
99    ///
100    /// # Arguments
101    ///
102    /// * `flag` - The name of the required flag
103    ///
104    /// # Returns
105    ///
106    /// Returns `Ok(())` if the flag is present, or `CliError::MissingFlag` otherwise.
107    ///
108    /// # Example
109    ///
110    /// ```no_run
111    /// # use falcon_cli::{CliRequest, CliCommand, CliHelpScreen};
112    /// # struct MyCmd;
113    /// # impl CliCommand for MyCmd {
114    /// #   fn help(&self) -> CliHelpScreen { CliHelpScreen::new("", "", "") }
115    /// fn process(&self, req: &CliRequest) -> anyhow::Result<()> {
116    ///     req.require_flag("--output")?;  // Require --output flag
117    ///     let output = req.get_flag("--output").unwrap();
118    ///     // ... process command
119    ///     Ok(())
120    /// }
121    /// # }
122    /// ```
123    pub fn require_flag(&self, flag: &str) -> Result<(), CliError> {
124        if self.has_flag(&flag) {
125            Ok(())
126        } else {
127            Err(CliError::MissingFlag(flag.to_string()))
128        }
129    }
130
131    /// Gets the value of a flag if it was provided.
132    ///
133    /// # Arguments
134    ///
135    /// * `flag` - The name of the flag
136    ///
137    /// # Returns
138    ///
139    /// Returns `Some(String)` with the flag's value, or `None` if the flag wasn't provided.
140    ///
141    /// # Example
142    ///
143    /// ```no_run
144    /// # use falcon_cli::CliRequest;
145    /// # fn example(req: &CliRequest) {
146    /// if let Some(output) = req.get_flag("--output") {
147    ///     println!("Output file: {}", output);
148    /// }
149    /// # }
150    /// ```
151    pub fn get_flag(&self, flag: &str) -> Option<String> {
152        match self.flag_values.get(&flag.to_string()) {
153            Some(r) => Some(r.clone()),
154            None => None,
155        }
156    }
157
158    /// Validates that a flag's value conforms to the specified format.
159    ///
160    /// # Arguments
161    ///
162    /// * `flag` - The name of the flag to validate
163    /// * `format` - The format validator to apply
164    ///
165    /// # Returns
166    ///
167    /// Returns `Ok(())` if the flag value is valid, or a `CliError` describing the issue.
168    ///
169    /// # Example
170    ///
171    /// ```no_run
172    /// # use falcon_cli::{CliRequest, CliFormat, CliCommand, CliHelpScreen};
173    /// # struct MyCmd;
174    /// # impl CliCommand for MyCmd {
175    /// #   fn help(&self) -> CliHelpScreen { CliHelpScreen::new("", "", "") }
176    /// fn process(&self, req: &CliRequest) -> anyhow::Result<()> {
177    ///     req.validate_flag("--port", CliFormat::Integer)?;
178    ///     // Now we know --port contains a valid integer
179    ///     Ok(())
180    /// }
181    /// # }
182    /// ```
183    pub fn validate_flag(&self, flag: &str, format: CliFormat) -> Result<(), CliError> {
184        let value = self.get_flag(&flag).ok_or(CliError::MissingFlag(flag.to_string()))?;
185        self.validate(0, &value, format.clone())?;
186        Ok(())
187    }
188
189    /// Checks if a flag was provided.
190    ///
191    /// # Arguments
192    ///
193    /// * `flag` - The name of the flag to check
194    ///
195    /// # Returns
196    ///
197    /// Returns `true` if the flag is present, `false` otherwise.
198    ///
199    /// # Example
200    ///
201    /// ```no_run
202    /// # use falcon_cli::CliRequest;
203    /// # fn example(req: &CliRequest) {
204    /// if req.has_flag("--verbose") {
205    ///     println!("Verbose mode enabled");
206    /// }
207    /// # }
208    /// ```
209    pub fn has_flag(&self, flag: &str) -> bool {
210        self.flags.contains(&flag.to_string()) || self.flag_values.contains_key(&flag.to_string())
211    }
212
213    /// Validates that all parameters conform to the specified formats.
214    ///
215    /// # Arguments
216    ///
217    /// * `formats` - A vector of format validators, one for each parameter position
218    ///
219    /// # Returns
220    ///
221    /// Returns `Ok(())` if all parameters are valid, or a `CliError` for the first invalid parameter.
222    ///
223    /// # Example
224    ///
225    /// ```no_run
226    /// # use falcon_cli::{CliRequest, CliFormat, CliCommand, CliHelpScreen};
227    /// # struct MyCmd;
228    /// # impl CliCommand for MyCmd {
229    /// #   fn help(&self) -> CliHelpScreen { CliHelpScreen::new("", "", "") }
230    /// fn process(&self, req: &CliRequest) -> anyhow::Result<()> {
231    ///     req.validate_params(vec![
232    ///         CliFormat::File,           // First arg must be a file
233    ///         CliFormat::IntegerRange(1..100),  // Second arg: 1-99
234    ///     ])?;
235    ///     // Now we know the parameters are valid
236    ///     Ok(())
237    /// }
238    /// # }
239    /// ```
240    pub fn validate_params(&self, formats: Vec<CliFormat>) -> Result<(), CliError> {
241        for (pos, format) in formats.iter().enumerate() {
242            let arg = self.args.get(pos).ok_or_else(|| {
243                CliError::InvalidParam(pos, format!("Expected parameter at position {}", pos))
244            })?;
245
246            self.validate(pos, &arg, format.clone())?;
247        }
248
249        Ok(())
250    }
251
252    /// Validates a single value against a format specification.
253    ///
254    /// Internal method used by `validate_params` and `validate_flag`.
255    ///
256    /// # Arguments
257    ///
258    /// * `pos` - The position of the parameter (for error messages)
259    /// * `arg` - The value to validate
260    /// * `format` - The format validator to apply
261    fn validate(&self, pos: usize, arg: &str, format: CliFormat) -> Result<(), CliError> {
262        match format {
263            CliFormat::Any => return Ok(()),
264            CliFormat::Integer => {
265                arg.parse::<i64>().map_err(|_| {
266                    CliError::InvalidParam(pos, format!("Expected integer, got '{}'", arg))
267                })?;
268            }
269            CliFormat::Decimal => {
270                arg.parse::<f64>().map_err(|_| {
271                    CliError::InvalidParam(pos, format!("Expected decimal number, got '{}'", arg))
272                })?;
273            }
274            CliFormat::Boolean => {
275                if !["true", "false", "1", "0", "yes", "no"].contains(&arg.to_lowercase().as_str())
276                {
277                    return Err(CliError::InvalidParam(
278                        pos,
279                        format!("Expected boolean (true/false/yes/no/1/0), got '{}'", arg),
280                    ));
281                }
282            }
283            CliFormat::Email => {
284                if !arg.contains('@') || !arg.contains('.') {
285                    return Err(CliError::InvalidParam(
286                        pos,
287                        format!("Expected valid email, got '{}'", arg),
288                    ));
289                }
290            }
291            CliFormat::Url => {
292                Url::parse(arg).map_err(|_| {
293                    CliError::InvalidParam(pos, format!("Expected valid URL, got '{}'", arg))
294                })?;
295            }
296            CliFormat::StringRange(range) => {
297                let len = arg.len();
298                if !range.contains(&len) {
299                    return Err(CliError::InvalidParam(
300                        pos,
301                        format!(
302                            "String length must be between {} and {}, got length {}",
303                            range.start, range.end, len
304                        ),
305                    ));
306                }
307            }
308            CliFormat::IntegerRange(range) => {
309                let val = arg.parse::<i64>().map_err(|_| {
310                    CliError::InvalidParam(pos, format!("Expected integer, got '{}'", arg))
311                })?;
312                if !range.contains(&val) {
313                    return Err(CliError::InvalidParam(
314                        pos,
315                        format!(
316                            "Integer must be between {} and {}, got {}",
317                            range.start, range.end, val
318                        ),
319                    ));
320                }
321            }
322            CliFormat::DecimalRange(range) => {
323                let val = arg.parse::<f64>().map_err(|_| {
324                    CliError::InvalidParam(pos, format!("Expected decimal, got '{}'", arg))
325                })?;
326                if val < range.start || val >= range.end {
327                    return Err(CliError::InvalidParam(
328                        pos,
329                        format!(
330                            "Decimal must be between {} and {}, got {}",
331                            range.start, range.end, val
332                        ),
333                    ));
334                }
335            }
336            CliFormat::OneOf(options) => {
337                if !options.contains(&arg.to_string()) {
338                    return Err(CliError::InvalidParam(
339                        pos,
340                        format!(
341                            "Expected one of ({}), got '{}'",
342                            options.join(" / ").to_string(),
343                            arg
344                        ),
345                    ));
346                }
347            }
348            CliFormat::File => {
349                let metadata = fs::metadata(&arg)?;
350                if !metadata.is_file() {
351                    return Err(CliError::InvalidParam(
352                        pos,
353                        format!("File does not exist, '{}'", arg),
354                    ));
355                }
356            }
357            CliFormat::Directory => {
358                let metadata = fs::metadata(&arg)?;
359                if !metadata.is_dir() {
360                    return Err(CliError::InvalidParam(
361                        pos,
362                        format!("Directory does not exist, '{}'", arg),
363                    ));
364                }
365            }
366        };
367
368        Ok(())
369    }
370}