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}