help_probe/
validation.rs

1use crate::model::{ArgumentSpec, ArgumentType, OptionSpec, ProbeResult};
2use serde::Serialize;
3
4/// Result of validating a command invocation.
5#[derive(Debug, Serialize)]
6pub struct ValidationResult {
7    /// Whether the command is valid.
8    pub is_valid: bool,
9    /// List of validation errors.
10    pub errors: Vec<ValidationError>,
11    /// List of validation warnings.
12    pub warnings: Vec<ValidationError>,
13}
14
15/// A single validation error or warning.
16#[derive(Debug, Serialize, Clone)]
17pub struct ValidationError {
18    /// Type of validation error.
19    pub error_type: ValidationErrorType,
20    /// Human-readable error message.
21    pub message: String,
22    /// The option or argument that caused the error (if applicable).
23    pub target: Option<String>,
24}
25
26/// Types of validation errors.
27#[derive(Debug, Serialize, Clone)]
28pub enum ValidationErrorType {
29    /// Required argument is missing.
30    MissingRequiredArgument,
31    /// Required option is missing.
32    MissingRequiredOption,
33    /// Unknown option provided.
34    UnknownOption,
35    /// Option requires an argument but none provided.
36    OptionMissingArgument,
37    /// Option provided but doesn't take an argument.
38    OptionUnexpectedArgument,
39    /// Invalid argument type (e.g., number expected but string provided).
40    InvalidArgumentType,
41    /// Unknown subcommand.
42    UnknownSubcommand,
43    /// Too many arguments provided (non-variadic argument limit exceeded).
44    TooManyArguments,
45    /// Too few arguments provided.
46    TooFewArguments,
47}
48
49/// Validate a command invocation against a ProbeResult.
50///
51/// This checks:
52/// - Required arguments are provided
53/// - Required options are provided
54/// - Options are valid (known to the command)
55/// - Arguments match expected types
56/// - Subcommands are valid
57///
58/// # Examples
59///
60/// ```
61/// use help_probe::{validation::validate_command, model::{ProbeResult, OptionSpec, OptionType}};
62///
63/// let result = ProbeResult {
64///     command: "mytool".to_string(),
65///     args: vec![],
66///     exit_code: Some(0),
67///     timed_out: false,
68///     help_flag_detected: true,
69///     usage_blocks: vec![],
70///     options: vec![OptionSpec {
71///         short_flags: vec!["-v".to_string()],
72///         long_flags: vec!["--verbose".to_string()],
73///         description: Some("Verbose output".to_string()),
74///         option_type: OptionType::Boolean,
75///         required: false,
76///         default_value: None,
77///         takes_argument: false,
78///         argument_name: None,
79///         choices: vec![],
80///     }],
81///     subcommands: vec![],
82///     arguments: vec![],
83///     examples: vec![],
84///     environment_variables: vec![],
85///     validation_rules: vec![],
86///     raw_stdout: String::new(),
87///     raw_stderr: String::new(),
88/// };
89///
90/// // Valid command
91/// let validation = validate_command(&result, "mytool", &["--verbose".to_string()]);
92/// assert!(validation.is_valid);
93///
94/// // Invalid option
95/// let validation = validate_command(&result, "mytool", &["--unknown".to_string()]);
96/// assert!(!validation.is_valid);
97/// assert!(validation.errors.iter().any(|e| matches!(e.error_type, help_probe::validation::ValidationErrorType::UnknownOption)));
98/// ```
99pub fn validate_command(result: &ProbeResult, command: &str, args: &[String]) -> ValidationResult {
100    let mut errors = Vec::new();
101    let mut warnings = Vec::new();
102
103    // Verify command name matches
104    if command != result.command {
105        warnings.push(ValidationError {
106            error_type: ValidationErrorType::UnknownSubcommand,
107            message: format!(
108                "Command name mismatch: expected '{}', got '{}'",
109                result.command, command
110            ),
111            target: Some(command.to_string()),
112        });
113    }
114
115    // Parse arguments into options, subcommands, and positional arguments
116    let parsed = parse_command_args(args, result);
117
118    // Validate options
119    validate_options(result, &parsed, &mut errors, &mut warnings);
120
121    // Validate arguments
122    validate_arguments(result, &parsed, &mut errors, &mut warnings);
123
124    // Validate subcommands
125    validate_subcommands(result, &parsed, &mut errors, &mut warnings);
126
127    ValidationResult {
128        is_valid: errors.is_empty(),
129        errors,
130        warnings,
131    }
132}
133
134/// Parsed representation of command arguments.
135struct ParsedArgs {
136    /// Options found (flag -> value if takes argument)
137    options: std::collections::HashMap<String, Option<String>>,
138    /// Subcommands found
139    subcommands: Vec<String>,
140    /// Positional arguments
141    positional_args: Vec<String>,
142}
143
144/// Parse command arguments into options, subcommands, and positional arguments.
145fn parse_command_args(args: &[String], result: &ProbeResult) -> ParsedArgs {
146    let mut options = std::collections::HashMap::new();
147    let mut subcommands = Vec::new();
148    let mut positional_args = Vec::new();
149
150    // Build set of all known options
151    let mut known_options = std::collections::HashSet::new();
152    for opt in &result.options {
153        for short in &opt.short_flags {
154            known_options.insert(short.clone());
155        }
156        for long in &opt.long_flags {
157            known_options.insert(long.clone());
158        }
159    }
160
161    // Build set of known subcommands
162    let known_subcommands: std::collections::HashSet<String> =
163        result.subcommands.iter().map(|s| s.name.clone()).collect();
164
165    let mut i = 0;
166    while i < args.len() {
167        let arg = &args[i];
168
169        // Check if it's an option
170        if arg.starts_with("--") {
171            // Long option
172            let (opt_name, value) = if let Some(eq_pos) = arg.find('=') {
173                (
174                    arg[..eq_pos].to_string(),
175                    Some(arg[eq_pos + 1..].to_string()),
176                )
177            } else {
178                (arg.clone(), None)
179            };
180
181            // Check if next arg is a value (not an option or subcommand)
182            if value.is_none() && i + 1 < args.len() {
183                let next = &args[i + 1];
184                if !next.starts_with('-') && !known_subcommands.contains(next) {
185                    // Check if this option takes an argument
186                    if let Some(opt_spec) = find_option_by_flag(&opt_name, result) {
187                        if opt_spec.takes_argument {
188                            options.insert(opt_name, Some(next.clone()));
189                            i += 2;
190                            continue;
191                        }
192                    }
193                }
194            }
195
196            options.insert(opt_name, value);
197            i += 1;
198        } else if arg.starts_with('-') && arg.len() > 1 {
199            // Short option(s)
200            let chars: Vec<char> = arg.chars().skip(1).collect();
201            for (idx, ch) in chars.iter().enumerate() {
202                let opt_name = format!("-{}", ch);
203                if known_options.contains(&opt_name) {
204                    // Check if this option takes an argument
205                    if let Some(opt_spec) = find_option_by_flag(&opt_name, result) {
206                        if opt_spec.takes_argument {
207                            // Last char in group, next arg is value
208                            if idx == chars.len() - 1 && i + 1 < args.len() {
209                                let next = &args[i + 1];
210                                if !next.starts_with('-') {
211                                    options.insert(opt_name, Some(next.clone()));
212                                    i += 2;
213                                    break;
214                                }
215                            }
216                        }
217                    }
218                    options.insert(opt_name, None);
219                }
220            }
221            i += 1;
222        } else if known_subcommands.contains(arg) {
223            // Subcommand
224            subcommands.push(arg.clone());
225            i += 1;
226        } else {
227            // Positional argument
228            positional_args.push(arg.clone());
229            i += 1;
230        }
231    }
232
233    ParsedArgs {
234        options,
235        subcommands,
236        positional_args,
237    }
238}
239
240/// Find an option spec by its flag name.
241fn find_option_by_flag<'a>(flag: &str, result: &'a ProbeResult) -> Option<&'a OptionSpec> {
242    result.options.iter().find(|opt| {
243        opt.short_flags.contains(&flag.to_string()) || opt.long_flags.contains(&flag.to_string())
244    })
245}
246
247/// Validate options in the parsed command.
248fn validate_options(
249    result: &ProbeResult,
250    parsed: &ParsedArgs,
251    errors: &mut Vec<ValidationError>,
252    warnings: &mut Vec<ValidationError>,
253) {
254    // Build set of all known options
255    let mut known_options = std::collections::HashSet::new();
256    for opt in &result.options {
257        for short in &opt.short_flags {
258            known_options.insert(short.clone());
259        }
260        for long in &opt.long_flags {
261            known_options.insert(long.clone());
262        }
263    }
264
265    // Check for unknown options
266    for opt_name in parsed.options.keys() {
267        if !known_options.contains(opt_name) {
268            errors.push(ValidationError {
269                error_type: ValidationErrorType::UnknownOption,
270                message: format!("Unknown option: {}", opt_name),
271                target: Some(opt_name.clone()),
272            });
273        }
274    }
275
276    // Check required options
277    for opt in &result.options {
278        if opt.required {
279            let found = opt
280                .short_flags
281                .iter()
282                .any(|f| parsed.options.contains_key(f))
283                || opt
284                    .long_flags
285                    .iter()
286                    .any(|f| parsed.options.contains_key(f));
287
288            if !found {
289                let opt_name = opt
290                    .long_flags
291                    .first()
292                    .or_else(|| opt.short_flags.first())
293                    .unwrap();
294                errors.push(ValidationError {
295                    error_type: ValidationErrorType::MissingRequiredOption,
296                    message: format!("Required option missing: {}", opt_name),
297                    target: Some(opt_name.clone()),
298                });
299            }
300        }
301
302        // Check if option that takes argument has one
303        if opt.takes_argument {
304            for flag in &opt.short_flags {
305                if let Some(value) = parsed.options.get(flag) {
306                    if value.is_none() {
307                        errors.push(ValidationError {
308                            error_type: ValidationErrorType::OptionMissingArgument,
309                            message: format!("Option {} requires an argument", flag),
310                            target: Some(flag.clone()),
311                        });
312                    }
313                }
314            }
315            for flag in &opt.long_flags {
316                if let Some(value) = parsed.options.get(flag) {
317                    if value.is_none() {
318                        errors.push(ValidationError {
319                            error_type: ValidationErrorType::OptionMissingArgument,
320                            message: format!("Option {} requires an argument", flag),
321                            target: Some(flag.clone()),
322                        });
323                    }
324                }
325            }
326        } else {
327            // Check if boolean option has unexpected argument
328            for flag in &opt.short_flags {
329                if let Some(Some(_)) = parsed.options.get(flag) {
330                    warnings.push(ValidationError {
331                        error_type: ValidationErrorType::OptionUnexpectedArgument,
332                        message: format!("Option {} does not take an argument", flag),
333                        target: Some(flag.clone()),
334                    });
335                }
336            }
337            for flag in &opt.long_flags {
338                if let Some(Some(_)) = parsed.options.get(flag) {
339                    warnings.push(ValidationError {
340                        error_type: ValidationErrorType::OptionUnexpectedArgument,
341                        message: format!("Option {} does not take an argument", flag),
342                        target: Some(flag.clone()),
343                    });
344                }
345            }
346        }
347    }
348}
349
350/// Validate arguments in the parsed command.
351fn validate_arguments(
352    result: &ProbeResult,
353    parsed: &ParsedArgs,
354    errors: &mut Vec<ValidationError>,
355    warnings: &mut Vec<ValidationError>,
356) {
357    let required_args: Vec<&ArgumentSpec> = result
358        .arguments
359        .iter()
360        .filter(|a| a.required && !a.variadic)
361        .collect();
362
363    let variadic_args: Vec<&ArgumentSpec> =
364        result.arguments.iter().filter(|a| a.variadic).collect();
365
366    // Check required arguments
367    let required_count = required_args.len();
368    if parsed.positional_args.len() < required_count {
369        errors.push(ValidationError {
370            error_type: ValidationErrorType::TooFewArguments,
371            message: format!(
372                "Too few arguments: expected at least {}, got {}",
373                required_count,
374                parsed.positional_args.len()
375            ),
376            target: None,
377        });
378    }
379
380    // Check variadic arguments (if any)
381    if variadic_args.is_empty() && parsed.positional_args.len() > required_count {
382        errors.push(ValidationError {
383            error_type: ValidationErrorType::TooManyArguments,
384            message: format!(
385                "Too many arguments: expected {}, got {}",
386                required_count,
387                parsed.positional_args.len()
388            ),
389            target: None,
390        });
391    }
392
393    // Validate argument types (basic checking)
394    for (idx, arg_value) in parsed.positional_args.iter().enumerate() {
395        if let Some(arg_spec) = result.arguments.get(idx) {
396            if let Some(arg_type) = &arg_spec.arg_type {
397                if !validate_argument_type(arg_value, arg_type) {
398                    warnings.push(ValidationError {
399                        error_type: ValidationErrorType::InvalidArgumentType,
400                        message: format!(
401                            "Argument '{}' may not match expected type {:?}",
402                            arg_value, arg_type
403                        ),
404                        target: Some(arg_spec.name.clone()),
405                    });
406                }
407            }
408        }
409    }
410}
411
412/// Validate that an argument value matches the expected type.
413fn validate_argument_type(value: &str, arg_type: &ArgumentType) -> bool {
414    match arg_type {
415        ArgumentType::Number => value.parse::<f64>().is_ok() || value.parse::<i64>().is_ok(),
416        ArgumentType::Path => {
417            // Basic path validation - check if it looks like a path
418            value.contains('/') || value.contains('\\') || !value.contains(' ')
419        }
420        ArgumentType::Url => value.starts_with("http://") || value.starts_with("https://"),
421        ArgumentType::Email => value.contains('@') && value.contains('.'),
422        ArgumentType::String => true, // Everything is a string
423    }
424}
425
426/// Validate subcommands in the parsed command.
427fn validate_subcommands(
428    result: &ProbeResult,
429    parsed: &ParsedArgs,
430    errors: &mut Vec<ValidationError>,
431    _warnings: &mut Vec<ValidationError>,
432) {
433    let known_subcommands: std::collections::HashSet<String> =
434        result.subcommands.iter().map(|s| s.name.clone()).collect();
435
436    for subcmd in &parsed.subcommands {
437        if !known_subcommands.contains(subcmd) {
438            errors.push(ValidationError {
439                error_type: ValidationErrorType::UnknownSubcommand,
440                message: format!("Unknown subcommand: {}", subcmd),
441                target: Some(subcmd.clone()),
442            });
443        }
444    }
445}