help_probe/
parser.rs

1use crate::model::{
2    ArgumentSpec, ArgumentType, EnvVarSpec, OptionSpec, OptionType, SubcommandSpec, ValidationRule,
3    ValidationType,
4};
5
6/// Detect whether the user-supplied args contain a likely "help" flag.
7///
8/// We look for:
9///   - -h
10///   - --help
11///   - help
12///   - /?
13///   - -?
14///   - --usage
15pub fn detect_help_flag(args: &[String]) -> bool {
16    args.iter().any(|arg| {
17        let lower = arg.to_lowercase();
18        matches!(
19            lower.as_str(),
20            "-h" | "--help" | "help" | "/?" | "-?" | "--usage"
21        )
22    })
23}
24
25/// Parse "usage" blocks from stdout/stderr.
26///
27/// Strategy:
28/// - Combine stdout and stderr lines.
29/// - Find all lines where "usage" appears (case-insensitive),
30///   or line starts with "usage".
31/// - For each such line, capture N lines before and after.
32///
33/// This is intentionally simple and heuristic.
34pub fn parse_usages(stdout: &[u8], stderr: &[u8]) -> Vec<String> {
35    let stdout_s = String::from_utf8_lossy(stdout);
36    let stderr_s = String::from_utf8_lossy(stderr);
37
38    let lines: Vec<String> = stdout_s
39        .lines()
40        .map(|s| s.to_string())
41        .chain(stderr_s.lines().map(|s| s.to_string()))
42        .collect();
43
44    if lines.is_empty() {
45        return Vec::new();
46    }
47
48    let mut usage_indices = Vec::new();
49
50    for (idx, line) in lines.iter().enumerate() {
51        let l = line.to_lowercase();
52        if l.contains("usage:") || l.starts_with("usage ") || l.starts_with("usage:") {
53            usage_indices.push(idx);
54        }
55    }
56
57    if usage_indices.is_empty() {
58        return Vec::new();
59    }
60
61    // Sort and dedup indices
62    usage_indices.sort_unstable();
63    usage_indices.dedup();
64
65    let mut results = Vec::new();
66    let context_before = 1usize;
67    let context_after = 10usize; // Increased to capture more options
68
69    for idx in usage_indices {
70        let start = idx.saturating_sub(context_before);
71        let end = std::cmp::min(idx + 1 + context_after, lines.len());
72        let block = lines[start..end].join("\n");
73        results.push(block);
74    }
75
76    results
77}
78
79/// Attempt to parse option/flag lines from the usage blocks and OPTIONS sections.
80///
81/// Heuristic:
82/// - For each line in each block:
83///   - Trim leading whitespace.
84///   - If line starts with '-' or '--', assume it's an option line.
85///   - Split into "flag part" and "description part".
86///   - Parse flags like "-h", "--help", "-v, --verbose", "-p | --paginate".
87pub fn parse_options_from_usage_blocks(blocks: &[String]) -> Vec<OptionSpec> {
88    let mut options = Vec::new();
89
90    for block in blocks {
91        for raw_line in block.lines() {
92            let line = raw_line.trim_start();
93            if line.is_empty() {
94                continue;
95            }
96
97            // Heuristic: option lines often start with '-' or '--'
98            if !line.starts_with('-') {
99                continue;
100            }
101
102            // Split into flag part and description part.
103            let (flag_part, desc_part) = split_flag_and_description(line);
104
105            let mut short_flags = Vec::new();
106            let mut long_flags = Vec::new();
107
108            // Handle pipe-separated flags like "-p | --paginate"
109            let flag_part_normalized = flag_part.replace(" | ", ", ");
110
111            // Split on commas or whitespace, but be smarter about it
112            for token in flag_part_normalized.split(|c: char| c.is_whitespace() || c == ',') {
113                let t = token.trim();
114                if t.is_empty() {
115                    continue;
116                }
117
118                // Remove common placeholders/values like <FILE>, [PATH], =FILE, etc.
119                let cleaned = t
120                    .split(|c: char| c == '<' || c == '>' || c == '[' || c == ']' || c == '=')
121                    .next()
122                    .unwrap_or(t)
123                    .trim();
124
125                if cleaned.is_empty() {
126                    continue;
127                }
128
129                if cleaned.starts_with("--") {
130                    long_flags.push(cleaned.to_string());
131                } else if cleaned.starts_with('-') {
132                    short_flags.push(cleaned.to_string());
133                }
134            }
135
136            if short_flags.is_empty() && long_flags.is_empty() {
137                continue;
138            }
139
140            let description = desc_part
141                .map(|s| s.trim().to_string())
142                .filter(|s| !s.is_empty());
143
144            // Extract enhanced metadata from flag_part and description
145            let (takes_argument, argument_name, option_type, choices) =
146                extract_option_metadata(&flag_part, description.as_deref());
147
148            // Determine if required (typically options are optional, but check description)
149            let required = description
150                .as_ref()
151                .map(|d| d.to_lowercase().contains("required"))
152                .unwrap_or(false);
153
154            options.push(OptionSpec {
155                short_flags,
156                long_flags,
157                description: description.clone(),
158                option_type,
159                required,
160                default_value: extract_default_value(description.as_deref()),
161                takes_argument,
162                argument_name,
163                choices,
164            });
165        }
166    }
167
168    options
169}
170
171/// Parse options from separate OPTIONS sections in the help text.
172/// This complements parse_options_from_usage_blocks by finding options
173/// that appear in dedicated OPTIONS sections.
174pub fn parse_options_from_sections(full_stdout: &str, full_stderr: &str) -> Vec<OptionSpec> {
175    let lines: Vec<String> = full_stdout
176        .lines()
177        .map(|s| s.to_string())
178        .chain(full_stderr.lines().map(|s| s.to_string()))
179        .collect();
180
181    if lines.is_empty() {
182        return Vec::new();
183    }
184
185    let mut options = Vec::new();
186    let mut in_options_section = false;
187    let mut options_start_idx = 0;
188
189    // Find OPTIONS sections
190    for (idx, line) in lines.iter().enumerate() {
191        let trimmed = line.trim().to_lowercase();
192
193        // Check if this is an OPTIONS section header
194        if trimmed == "options:" || trimmed == "options" {
195            in_options_section = true;
196            options_start_idx = idx + 1;
197            continue;
198        }
199
200        // If we're in an options section, parse lines until we hit a non-option line
201        if in_options_section {
202            let trimmed_line = line.trim_start();
203
204            // Stop if we hit a blank line or a section header (non-indented, uppercase-ish)
205            if trimmed_line.is_empty() {
206                // Blank line might be separator, continue for a bit
207                if idx > options_start_idx + 2 {
208                    in_options_section = false;
209                }
210                continue;
211            }
212
213            // Stop if we hit another section header (like "COMMANDS:", "SUBCOMMANDS:")
214            let lower = trimmed_line.to_lowercase();
215            if (lower.ends_with(':')
216                || lower.ends_with("commands")
217                || lower.ends_with("subcommands"))
218                && !trimmed_line.starts_with(' ')
219                && !trimmed_line.starts_with('\t')
220            {
221                in_options_section = false;
222                continue;
223            }
224
225            // Parse option lines (start with -)
226            if trimmed_line.starts_with('-') {
227                let (flag_part, desc_part) = split_flag_and_description(trimmed_line);
228
229                let mut short_flags = Vec::new();
230                let mut long_flags = Vec::new();
231
232                let flag_part_normalized = flag_part.replace(" | ", ", ");
233
234                for token in flag_part_normalized.split(|c: char| c.is_whitespace() || c == ',') {
235                    let t = token.trim();
236                    if t.is_empty() {
237                        continue;
238                    }
239
240                    // Remove placeholders
241                    let cleaned = t
242                        .split(|c: char| c == '<' || c == '>' || c == '[' || c == ']' || c == '=')
243                        .next()
244                        .unwrap_or(t)
245                        .trim();
246
247                    if cleaned.is_empty() {
248                        continue;
249                    }
250
251                    if cleaned.starts_with("--") {
252                        long_flags.push(cleaned.to_string());
253                    } else if cleaned.starts_with('-') {
254                        short_flags.push(cleaned.to_string());
255                    }
256                }
257
258                if !short_flags.is_empty() || !long_flags.is_empty() {
259                    let description = desc_part
260                        .map(|s| s.trim().to_string())
261                        .filter(|s| !s.is_empty());
262
263                    // Extract enhanced metadata
264                    let (takes_argument, argument_name, option_type, choices) =
265                        extract_option_metadata(&flag_part, description.as_deref());
266
267                    let required = description
268                        .as_ref()
269                        .map(|d| d.to_lowercase().contains("required"))
270                        .unwrap_or(false);
271
272                    options.push(OptionSpec {
273                        short_flags,
274                        long_flags,
275                        description: description.clone(),
276                        option_type,
277                        required,
278                        default_value: extract_default_value(description.as_deref()),
279                        takes_argument,
280                        argument_name,
281                        choices,
282                    });
283                }
284            } else if !trimmed_line.starts_with(' ') && !trimmed_line.starts_with('\t') {
285                // Non-indented, non-option line - probably end of section
286                in_options_section = false;
287            }
288        }
289    }
290
291    options
292}
293
294/// Attempt to parse arguments/parameters from usage blocks and Arguments sections.
295///
296/// Strategy:
297/// - Parse usage lines for argument patterns: `<ARG>`, `[ARG]`, `<ARG>...`
298/// - Extract from "Arguments:" sections in help text
299/// - Infer types from placeholders (`<FILE>`, `<PORT>`, `<URL>`)
300/// - Extract descriptions from help text
301pub fn parse_arguments(
302    full_stdout: &str,
303    full_stderr: &str,
304    usage_blocks: &[String],
305) -> Vec<ArgumentSpec> {
306    let mut arguments = Vec::new();
307
308    // First, extract from usage blocks (usage lines)
309    for block in usage_blocks {
310        arguments.extend(parse_arguments_from_usage_line(block));
311    }
312
313    // Then, extract from "Arguments:" sections
314    let lines: Vec<String> = full_stdout
315        .lines()
316        .map(|s| s.to_string())
317        .chain(full_stderr.lines().map(|s| s.to_string()))
318        .collect();
319
320    arguments.extend(parse_arguments_from_section(&lines));
321
322    // Deduplicate arguments (same name)
323    arguments.sort_by(|a, b| a.name.cmp(&b.name));
324    arguments.dedup_by(|a, b| a.name == b.name && a.placeholder == b.placeholder);
325
326    arguments
327}
328
329/// Parse arguments from a usage line.
330/// Extracts patterns like: `<FILE>`, `[OPTIONAL]`, `<PATTERN>...`, `[ARGS]...`
331fn parse_arguments_from_usage_line(usage_block: &str) -> Vec<ArgumentSpec> {
332    let mut arguments = Vec::new();
333
334    // Look for usage lines
335    for line in usage_block.lines() {
336        let line_lower = line.to_lowercase();
337        if !line_lower.contains("usage:") && !line_lower.starts_with("usage ") {
338            continue;
339        }
340
341        // Extract argument patterns from the usage line
342        // Patterns: <ARG>, [ARG], <ARG>..., [ARG]...
343        let re = regex::Regex::new(r"(<([^>]+)>|\[([^\]]+)\])(\.\.\.)?").unwrap();
344
345        for cap in re.captures_iter(line) {
346            if let Some(full_match) = cap.get(1) {
347                let is_required = full_match.as_str().starts_with('<');
348                let name = if let Some(m) = cap.get(2) {
349                    m.as_str().to_string()
350                } else if let Some(m) = cap.get(3) {
351                    m.as_str().to_string()
352                } else {
353                    continue;
354                };
355                let is_variadic = cap.get(4).is_some();
356                let placeholder = Some({
357                    let mut p = full_match.as_str().to_string();
358                    if is_variadic {
359                        p.push_str("...");
360                    }
361                    p
362                });
363
364                let arg_type = infer_argument_type(&name, placeholder.as_deref());
365
366                arguments.push(ArgumentSpec {
367                    name: name.clone(),
368                    description: None,
369                    required: is_required,
370                    variadic: is_variadic,
371                    arg_type,
372                    placeholder,
373                });
374            }
375        }
376    }
377
378    arguments
379}
380
381/// Parse arguments from "Arguments:" sections in help text.
382fn parse_arguments_from_section(lines: &[String]) -> Vec<ArgumentSpec> {
383    let mut arguments = Vec::new();
384    let mut in_arguments_section = false;
385    let mut section_start_idx = 0;
386
387    for (idx, line) in lines.iter().enumerate() {
388        let trimmed = line.trim().to_lowercase();
389
390        // Check if this is an "Arguments:" section header
391        if trimmed == "arguments:" || trimmed == "arguments" {
392            in_arguments_section = true;
393            section_start_idx = idx + 1;
394            continue;
395        }
396
397        // If we're in an arguments section, parse lines until we hit a non-argument line
398        if in_arguments_section {
399            let trimmed_line = line.trim_start();
400
401            // Stop if we hit a blank line or a section header (non-indented, uppercase-ish)
402            if trimmed_line.is_empty() {
403                if idx > section_start_idx + 2 {
404                    in_arguments_section = false;
405                }
406                continue;
407            }
408
409            // Stop if we hit another section header
410            let lower = trimmed_line.to_lowercase();
411            if (lower.ends_with(':') || lower.contains("options") || lower.contains("commands"))
412                && !trimmed_line.starts_with(' ')
413                && !trimmed_line.starts_with('\t')
414            {
415                in_arguments_section = false;
416                continue;
417            }
418
419            // Parse argument lines (typically start with <ARG> or [ARG])
420            if trimmed_line.starts_with('<') || trimmed_line.starts_with('[') {
421                if let Some(arg) = parse_argument_line(trimmed_line) {
422                    arguments.push(arg);
423                }
424            } else if !trimmed_line.starts_with(' ') && !trimmed_line.starts_with('\t') {
425                // Non-indented, non-argument line - probably end of section
426                in_arguments_section = false;
427            }
428        }
429    }
430
431    arguments
432}
433
434/// Parse a single argument line from an Arguments section.
435/// Format: "  <FILE>        Description of the file"
436/// or:     "  [OUTPUT]      Optional output path"
437fn parse_argument_line(line: &str) -> Option<ArgumentSpec> {
438    let trimmed = line.trim();
439
440    // Extract placeholder pattern
441    let re = regex::Regex::new(r"^(<([^>]+)>|\[([^\]]+)\])(\.\.\.)?").unwrap();
442    let cap = re.captures(trimmed)?;
443
444    let full_match = cap.get(1)?;
445    let is_required = full_match.as_str().starts_with('<');
446    let name = if let Some(m) = cap.get(2) {
447        m.as_str().to_string()
448    } else if let Some(m) = cap.get(3) {
449        m.as_str().to_string()
450    } else {
451        return None;
452    };
453    let is_variadic = cap.get(4).is_some();
454    let placeholder = Some({
455        let mut p = full_match.as_str().to_string();
456        if is_variadic {
457            p.push_str("...");
458        }
459        p
460    });
461
462    // Extract description (everything after the placeholder, separated by 2+ spaces or tab)
463    let full_cap = cap.get(0)?;
464    let desc_start = full_cap.end();
465    let rest = &trimmed[desc_start..].trim();
466    let description = if rest.is_empty() {
467        None
468    } else {
469        // Try to split on 2+ spaces or tab
470        let desc = if let Some(idx) = rest.find("  ") {
471            rest[..idx].trim().to_string()
472        } else if let Some(idx) = rest.find('\t') {
473            rest[..idx].trim().to_string()
474        } else {
475            rest.to_string()
476        };
477        if desc.is_empty() { None } else { Some(desc) }
478    };
479
480    let arg_type = infer_argument_type(&name, placeholder.as_deref());
481
482    Some(ArgumentSpec {
483        name,
484        description,
485        required: is_required,
486        variadic: is_variadic,
487        arg_type,
488        placeholder,
489    })
490}
491
492/// Infer argument type from name and placeholder.
493fn infer_argument_type(name: &str, placeholder: Option<&str>) -> Option<ArgumentType> {
494    let name_upper = name.to_uppercase();
495
496    // Check name patterns
497    if name_upper.contains("FILE") || name_upper.contains("PATH") || name_upper.contains("DIR") {
498        return Some(ArgumentType::Path);
499    }
500    if name_upper.contains("URL") {
501        return Some(ArgumentType::Url);
502    }
503    if name_upper.contains("EMAIL") {
504        return Some(ArgumentType::Email);
505    }
506    if name_upper.contains("PORT") || name_upper.contains("NUM") || name_upper.contains("COUNT") {
507        return Some(ArgumentType::Number);
508    }
509
510    // Check placeholder patterns
511    if let Some(ph) = placeholder {
512        let ph_lower = ph.to_lowercase();
513        if ph_lower.contains("file") || ph_lower.contains("path") || ph_lower.contains("dir") {
514            return Some(ArgumentType::Path);
515        }
516        if ph_lower.contains("url") {
517            return Some(ArgumentType::Url);
518        }
519        if ph_lower.contains("email") {
520            return Some(ArgumentType::Email);
521        }
522        if ph_lower.contains("port") || ph_lower.contains("num") {
523            return Some(ArgumentType::Number);
524        }
525    }
526
527    // Default to String if we can't infer
528    Some(ArgumentType::String)
529}
530
531/// Extract enhanced metadata from an option flag part and description.
532/// Returns: (takes_argument, argument_name, option_type, choices)
533fn extract_option_metadata(
534    flag_part: &str,
535    description: Option<&str>,
536) -> (bool, Option<String>, OptionType, Vec<String>) {
537    // Check if option takes an argument by looking for patterns in flag_part
538    // Patterns: --file <FILE>, --output=<PATH>, --level {debug|info|warn}
539
540    // Check for argument patterns: <ARG>, [ARG], =ARG, {choice1|choice2|choice3}
541    // Note: =ARG pattern - match = followed by non-whitespace, non-comma characters
542    // Use separate patterns to avoid regex issues
543    // Escape braces: { and } are special in regex
544    let choice_pattern = regex::Regex::new(r"\{([^}]+)\}").unwrap();
545    let arg_pattern = regex::Regex::new(r"(<([^>]+)>|\[([^\]]+)\]|=\s*([^,\s]+))").unwrap();
546
547    let mut takes_argument = false;
548    let mut argument_name = None;
549    let choices = Vec::new();
550
551    // First check for choice/enum pattern: {choice1|choice2|choice3}
552    // Check both flag_part and description
553    let check_choice = |text: &str| -> Option<Vec<String>> {
554        if let Some(cap) = choice_pattern.captures(text) {
555            if let Some(choices_str) = cap.get(1) {
556                let choices_text = choices_str.as_str();
557                let ch: Vec<String> = choices_text
558                    .split('|')
559                    .map(|c| c.trim().to_string())
560                    .filter(|c| !c.is_empty())
561                    .collect();
562                if !ch.is_empty() {
563                    return Some(ch);
564                }
565            }
566        }
567        None
568    };
569
570    if let Some(ch) = check_choice(flag_part) {
571        return (true, None, OptionType::Choice, ch);
572    }
573
574    if let Some(desc) = description {
575        if let Some(ch) = check_choice(desc) {
576            return (true, None, OptionType::Choice, ch);
577        }
578    }
579
580    for cap in arg_pattern.captures_iter(flag_part) {
581        takes_argument = true;
582
583        // Extract argument name from <ARG>, [ARG], or =ARG
584        if let Some(m) = cap.get(2) {
585            argument_name = Some(m.as_str().to_string());
586        } else if let Some(m) = cap.get(3) {
587            argument_name = Some(m.as_str().to_string());
588        } else if let Some(m) = cap.get(4) {
589            argument_name = Some(m.as_str().to_string());
590        }
591    }
592
593    // If no argument pattern found, check description for hints
594    if !takes_argument {
595        if let Some(desc) = description {
596            let desc_lower = desc.to_lowercase();
597            // Look for patterns like "takes <FILE>", "requires <PATH>", etc.
598            if desc_lower.contains("takes")
599                || desc_lower.contains("requires")
600                || desc_lower.contains("specify")
601            {
602                // Try to extract from description
603                let desc_arg_pattern = regex::Regex::new(r"<([^>]+)>|\[([^\]]+)\]").unwrap();
604                if let Some(cap) = desc_arg_pattern.captures(desc) {
605                    takes_argument = true;
606                    if let Some(m) = cap.get(1) {
607                        argument_name = Some(m.as_str().to_string());
608                    } else if let Some(m) = cap.get(2) {
609                        argument_name = Some(m.as_str().to_string());
610                    }
611                }
612            }
613        }
614    }
615
616    // Infer option type
617    let option_type = if !choices.is_empty() {
618        OptionType::Choice
619    } else if takes_argument {
620        infer_option_type(argument_name.as_deref(), description)
621    } else {
622        OptionType::Boolean
623    };
624
625    (takes_argument, argument_name, option_type, choices)
626}
627
628/// Infer option type from argument name and description.
629fn infer_option_type(argument_name: Option<&str>, description: Option<&str>) -> OptionType {
630    if let Some(name) = argument_name {
631        let name_upper = name.to_uppercase();
632        if name_upper.contains("FILE") || name_upper.contains("PATH") || name_upper.contains("DIR")
633        {
634            return OptionType::Path;
635        }
636        if name_upper.contains("PORT")
637            || name_upper.contains("NUM")
638            || name_upper.contains("COUNT")
639            || name_upper.contains("SIZE")
640        {
641            return OptionType::Number;
642        }
643    }
644
645    if let Some(desc) = description {
646        let desc_lower = desc.to_lowercase();
647        if desc_lower.contains("file")
648            || desc_lower.contains("path")
649            || desc_lower.contains("directory")
650        {
651            return OptionType::Path;
652        }
653        if desc_lower.contains("port")
654            || desc_lower.contains("number")
655            || desc_lower.contains("count")
656            || desc_lower.contains("numeric")
657        {
658            return OptionType::Number;
659        }
660    }
661
662    OptionType::String
663}
664
665/// Extract default value from description.
666/// Looks for patterns like "default: value", "defaults to value", "(default: value)"
667fn extract_default_value(description: Option<&str>) -> Option<String> {
668    if let Some(desc) = description {
669        // Try various patterns
670        let patterns = [
671            r"default:\s*([^\s,;)]+)",
672            r"defaults?\s+to\s+([^\s,;)]+)",
673            r"\(default:\s*([^)]+)\)",
674            r"\[default:\s*([^\]]+)\]",
675        ];
676
677        for pattern in &patterns {
678            let re = regex::Regex::new(pattern).unwrap();
679            if let Some(cap) = re.captures(desc) {
680                if let Some(m) = cap.get(1) {
681                    return Some(m.as_str().trim().to_string());
682                }
683            }
684        }
685    }
686
687    None
688}
689
690/// Simple heuristic to split an option line into "flags" and "description".
691///
692/// Examples:
693///   "-h, --help    Show help message"
694///   "  -v, --verbose   Verbose mode"
695///   "--version Print version"
696fn split_flag_and_description(line: &str) -> (String, Option<String>) {
697    // First try to split on two or more spaces
698    let mut best_split: Option<(String, String)> = None;
699
700    // Find first occurrence of "  " (two spaces) or a tab as a separator.
701    if let Some(idx) = line.find("  ") {
702        let (left, right) = line.split_at(idx);
703        let flag_part = left.trim_end().to_string();
704        let desc_part = right.trim_start().to_string();
705        if !flag_part.is_empty() && !desc_part.is_empty() {
706            best_split = Some((flag_part, desc_part));
707        }
708    } else if let Some(idx) = line.find('\t') {
709        let (left, right) = line.split_at(idx);
710        let flag_part = left.trim_end().to_string();
711        let desc_part = right.trim_start().to_string();
712        if !flag_part.is_empty() && !desc_part.is_empty() {
713            best_split = Some((flag_part, desc_part));
714        }
715    }
716
717    if let Some((flags, desc)) = best_split {
718        (flags, Some(desc))
719    } else {
720        // Fallback: no clear separator; treat entire line as flags only
721        (line.to_string(), None)
722    }
723}
724
725/// Attempt to parse subcommands from the full help text (stdout+stderr, as strings).
726///
727/// Heuristic:
728/// - Look for a section header like "SUBCOMMANDS", "Subcommands", "Commands".
729/// - Then look at following indented lines of form:
730///      subcmd   Description...
731///   or:
732///      subcmd
733///        Longer description...
734pub fn parse_subcommands(full_stdout: &str, full_stderr: &str) -> Vec<SubcommandSpec> {
735    let lines: Vec<String> = full_stdout
736        .lines()
737        .map(|s| s.to_string())
738        .chain(full_stderr.lines().map(|s| s.to_string()))
739        .collect();
740
741    if lines.is_empty() {
742        return Vec::new();
743    }
744
745    let mut subcommands = Vec::new();
746
747    // Find indices of possible "subcommands" headers.
748    // Support various formats: "SUBCOMMANDS:", "Commands:", "Management Commands:", etc.
749    let mut header_indices = Vec::new();
750    for (idx, line) in lines.iter().enumerate() {
751        let l = line.trim().to_lowercase();
752        // Exact matches
753        if l == "subcommands:" || l == "subcommands" || l == "commands:" || l == "commands" {
754            header_indices.push(idx);
755        }
756        // Pattern matches for variations like "Management Commands:", "Basic Commands (Beginner):"
757        else if l.ends_with("commands:") || l.ends_with("commands") {
758            // Check if it's actually a commands section (not something like "options")
759            if !l.contains("option") {
760                header_indices.push(idx);
761            }
762        }
763    }
764
765    // Also try to find subcommands in lists without explicit headers
766    // (e.g., "Some common cargo commands are:" followed by indented command names)
767    let list_subcommands = find_subcommands_in_lists(&lines);
768
769    if header_indices.is_empty() {
770        // If no explicit headers found, return list-based subcommands if any
771        return list_subcommands;
772    }
773
774    // For each header, inspect following lines until blank line or non-indented.
775    for header_idx in header_indices {
776        let mut i = header_idx + 1;
777        while i < lines.len() {
778            let raw = &lines[i];
779            let line = raw.trim_end();
780
781            if line.trim().is_empty() {
782                break;
783            }
784
785            // We expect some indentation before subcommand name
786            // e.g. "  build   Compile the current package"
787            let trimmed_start = line.trim_start();
788            // If there's no leading indent at all and no spaces, likely not a subcommand block.
789            if raw == trimmed_start {
790                // No indentation; this probably means the section ended.
791                break;
792            }
793
794            // Attempt: split into "name" and "description"
795            let mut parts = trimmed_start.splitn(2, char::is_whitespace);
796            let mut name = parts.next().unwrap_or("").trim().to_string();
797            let rest = parts.next().unwrap_or("").trim().to_string();
798
799            if name.is_empty() {
800                i += 1;
801                continue;
802            }
803
804            // Clean up name: remove trailing commas and other punctuation
805            // Cargo format: "build, b    Description" -> "build"
806            name = name.trim_end_matches(',').trim().to_string();
807
808            // Stop if the "name" looks like an option (starts with '-')
809            if name.starts_with('-') {
810                i += 1;
811                continue;
812            }
813
814            // Skip invalid names like "..." or empty after cleaning
815            if name == "..." || name.is_empty() {
816                i += 1;
817                continue;
818            }
819
820            let mut description = if rest.is_empty() { None } else { Some(rest) };
821
822            // Optional: capture one more indented description line if present.
823            // A continuation line should be MORE indented than the current line's content.
824            let current_indent = raw.len() - trimmed_start.len();
825            let j = i + 1;
826            if j < lines.len() {
827                let next_raw = &lines[j];
828                let next_trimmed = next_raw.trim_start();
829                let next_indent = next_raw.len() - next_trimmed.len();
830
831                // Only treat as continuation if:
832                // 1. It's not empty
833                // 2. It's indented (more than the current line's content)
834                // 3. It doesn't start with '-' (not an option)
835                // 4. It's more indented than the current line (actual continuation)
836                if !next_trimmed.is_empty()
837                    && next_raw != next_trimmed
838                    && !next_trimmed.starts_with('-')
839                    && next_indent > current_indent
840                {
841                    // Consider this a continuation of the description.
842                    let extra = next_trimmed.to_string();
843                    description = Some(match description {
844                        Some(existing) => format!("{existing} {extra}"),
845                        None => extra,
846                    });
847                    i = j; // skip the continuation line as well
848                }
849            }
850
851            // Use cleaned name for the subcommand
852            let sc_name = name.clone();
853            subcommands.push(SubcommandSpec {
854                name: sc_name.clone(),
855                description,
856                full_path: sc_name,
857                parent: None,
858                options: Vec::new(),
859                arguments: Vec::new(),
860                subcommands: Vec::new(),
861            });
862
863            i += 1;
864        }
865    }
866
867    // Merge with list-based subcommands, avoiding duplicates
868    for list_sub in list_subcommands {
869        if !subcommands.iter().any(|s| s.name == list_sub.name) {
870            subcommands.push(list_sub);
871        }
872    }
873
874    subcommands
875}
876
877/// Find subcommands in lists without explicit "SUBCOMMANDS:" headers.
878/// This handles formats like:
879///   "Some common cargo commands are:"
880///   "    build       Compile the current package"
881///   "    run         Run a binary"
882fn find_subcommands_in_lists(lines: &[String]) -> Vec<SubcommandSpec> {
883    let mut subcommands = Vec::new();
884
885    // Look for patterns like "commands are:", "available commands:", etc.
886    for (idx, line) in lines.iter().enumerate() {
887        let lower = line.trim().to_lowercase();
888
889        // Check if this line suggests a command list
890        if (lower.contains("commands") && (lower.contains("are") || lower.contains("available")))
891            || (lower.contains("common") && lower.contains("commands"))
892        {
893            // Look at following lines for indented command names
894            let mut i = idx + 1;
895            while i < lines.len() && i < idx + 50 {
896                // Limit search range
897                let raw = &lines[i];
898                let trimmed = raw.trim_start();
899
900                // Stop on blank line or non-indented line that looks like a section
901                if trimmed.is_empty() {
902                    if i > idx + 3 {
903                        break;
904                    }
905                    i += 1;
906                    continue;
907                }
908
909                // Stop if we hit a section header
910                let lower_next = trimmed.to_lowercase();
911                if (lower_next.ends_with(':') && !trimmed.starts_with(' '))
912                    || (lower_next.contains("options") && !trimmed.starts_with(' '))
913                {
914                    break;
915                }
916
917                // Check if line is indented and looks like a command
918                if raw != trimmed && !trimmed.starts_with('-') {
919                    // Split into name and description
920                    let parts: Vec<&str> = trimmed.splitn(2, char::is_whitespace).collect();
921                    if let Some(raw_name) = parts.first() {
922                        // Clean up name: remove trailing commas and trim
923                        let name = raw_name.trim().trim_end_matches(',').trim();
924                        // Valid command name: not empty, doesn't start with special chars
925                        if !name.is_empty()
926                            && name != "..."
927                            && !name.starts_with('-')
928                            && !name.starts_with('[')
929                            && name
930                                .chars()
931                                .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
932                        {
933                            let description = parts
934                                .get(1)
935                                .map(|s| s.trim().to_string())
936                                .filter(|s| !s.is_empty());
937
938                            let sc_name = name.to_string();
939                            subcommands.push(SubcommandSpec {
940                                name: sc_name.clone(),
941                                description,
942                                full_path: sc_name,
943                                parent: None,
944                                options: Vec::new(),
945                                arguments: Vec::new(),
946                                subcommands: Vec::new(),
947                            });
948                        }
949                    }
950                } else if raw == trimmed {
951                    // Non-indented line, probably end of list
952                    break;
953                }
954
955                i += 1;
956            }
957        }
958    }
959
960    subcommands
961}
962/// Parse examples from help text.
963///
964/// Looks for "Examples:" or "EXAMPLES:" sections and extracts:
965/// - Command lines (typically starting with $, >, or the command name)
966/// - Descriptions (lines before or after command lines)
967/// - Tags (inferred from context or explicit tags)
968pub fn parse_examples(stdout: &str, stderr: &str) -> Vec<crate::model::Example> {
969    let mut examples = Vec::new();
970    let combined = format!("{}\n{}", stdout, stderr);
971    let lines: Vec<String> = combined.lines().map(|s| s.to_string()).collect();
972
973    let mut in_examples_section = false;
974    let mut section_start_idx = 0;
975
976    for (idx, line) in lines.iter().enumerate() {
977        let trimmed = line.trim().to_lowercase();
978
979        // Check if this is an "Examples:" section header
980        if trimmed == "examples:"
981            || trimmed == "example:"
982            || trimmed.starts_with("examples:")
983            || trimmed.starts_with("example:")
984        {
985            in_examples_section = true;
986            section_start_idx = idx + 1;
987            continue;
988        }
989
990        if in_examples_section {
991            let trimmed_line = line.trim_start();
992
993            // Stop if we hit a blank line followed by another section header
994            if trimmed_line.is_empty() {
995                if idx > section_start_idx + 3 {
996                    // Check if next non-empty line is a section header
997                    let mut found_header = false;
998                    for j in (idx + 1)..lines.len().min(idx + 5) {
999                        let next_trimmed = lines[j].trim().to_lowercase();
1000                        if next_trimmed.ends_with(':')
1001                            && !next_trimmed.starts_with(' ')
1002                            && !next_trimmed.starts_with('\t')
1003                        {
1004                            found_header = true;
1005                            break;
1006                        }
1007                    }
1008                    if found_header {
1009                        in_examples_section = false;
1010                    }
1011                }
1012                continue;
1013            }
1014
1015            // Stop if we hit another section header
1016            let lower = trimmed_line.to_lowercase();
1017            if (lower.ends_with(':')
1018                || lower.contains("options")
1019                || lower.contains("commands")
1020                || lower.contains("arguments"))
1021                && !trimmed_line.starts_with(' ')
1022                && !trimmed_line.starts_with('\t')
1023            {
1024                in_examples_section = false;
1025                continue;
1026            }
1027
1028            // Look for example command lines
1029            // Patterns: $ command, > command, command (without leading $/>), or indented command
1030            let is_command_line = trimmed_line.starts_with('$')
1031                || trimmed_line.starts_with('>')
1032                || trimmed_line.starts_with('#')
1033                || (trimmed_line.len() > 0
1034                    && !trimmed_line.starts_with('-')
1035                    && !trimmed_line.starts_with('[')
1036                    && (trimmed_line.contains(' ') || trimmed_line.len() > 10));
1037
1038            if is_command_line {
1039                // Extract command (remove $, >, # prefix and trim)
1040                let command = trimmed_line
1041                    .trim_start_matches('$')
1042                    .trim_start_matches('>')
1043                    .trim_start_matches('#')
1044                    .trim()
1045                    .to_string();
1046
1047                // Look for description on previous or next lines
1048                let mut description = None;
1049
1050                // Check previous line (if it's not a command line)
1051                if idx > 0 {
1052                    let prev_line = lines[idx - 1].trim();
1053                    if !prev_line.is_empty()
1054                        && !prev_line.starts_with('$')
1055                        && !prev_line.starts_with('>')
1056                        && !prev_line.starts_with('#')
1057                        && prev_line.len() > 10
1058                    {
1059                        description = Some(prev_line.to_string());
1060                    }
1061                }
1062
1063                // Check next line (if it's not a command line)
1064                if description.is_none() && idx + 1 < lines.len() {
1065                    let next_line = lines[idx + 1].trim();
1066                    if !next_line.is_empty()
1067                        && !next_line.starts_with('$')
1068                        && !next_line.starts_with('>')
1069                        && !next_line.starts_with('#')
1070                        && !next_line.starts_with('-')
1071                        && next_line.len() > 10
1072                    {
1073                        description = Some(next_line.to_string());
1074                    }
1075                }
1076
1077                // Infer tags from context
1078                let mut tags = Vec::new();
1079                if let Some(desc) = &description {
1080                    let desc_lower = desc.to_lowercase();
1081                    if desc_lower.contains("basic") || desc_lower.contains("simple") {
1082                        tags.push("basic".to_string());
1083                    }
1084                    if desc_lower.contains("advanced") || desc_lower.contains("complex") {
1085                        tags.push("advanced".to_string());
1086                    }
1087                    if desc_lower.contains("common") || desc_lower.contains("typical") {
1088                        tags.push("common".to_string());
1089                    }
1090                }
1091
1092                // If no tags inferred, add "example" as default
1093                if tags.is_empty() {
1094                    tags.push("example".to_string());
1095                }
1096
1097                examples.push(crate::model::Example {
1098                    command,
1099                    description,
1100                    tags,
1101                });
1102            } else if !trimmed_line.starts_with(' ') && !trimmed_line.starts_with('\t') {
1103                // Non-indented, non-command line - probably end of section
1104                if idx > section_start_idx + 2 {
1105                    in_examples_section = false;
1106                }
1107            }
1108        }
1109    }
1110
1111    examples
1112}
1113
1114/// Parse environment variables from help text.
1115///
1116/// Looks for patterns like:
1117/// - "Can be set via $VAR"
1118/// - "Uses VAR environment variable"
1119/// - "VAR (default: value)"
1120/// - "Set VAR to ..."
1121/// - Option descriptions mentioning environment variables
1122pub fn parse_environment_variables(
1123    stdout: &str,
1124    stderr: &str,
1125    options: &[OptionSpec],
1126) -> Vec<EnvVarSpec> {
1127    let mut env_vars = Vec::new();
1128    let combined = format!("{}\n{}", stdout, stderr);
1129    let lines: Vec<String> = combined.lines().map(|s| s.to_string()).collect();
1130
1131    // Patterns to detect environment variable mentions
1132    let env_var_patterns = [
1133        // $VAR or ${VAR}
1134        (r"\$([A-Z_][A-Z0-9_]*)", "dollar_sign"),
1135        (r"\$\{([A-Z_][A-Z0-9_]*)\}", "dollar_brace"),
1136        // "VAR environment variable" or "VAR env var"
1137        (
1138            r"\b([A-Z_][A-Z0-9_]*)\s+(?:environment\s+)?variable",
1139            "explicit_var",
1140        ),
1141        // "Set VAR to" or "VAR can be set"
1142        (r"(?:set|use|via)\s+([A-Z_][A-Z0-9_]*)", "set_pattern"),
1143    ];
1144
1145    // Build a map of option names to flags for mapping
1146    let mut option_map = std::collections::HashMap::new();
1147    for opt in options {
1148        for long_flag in &opt.long_flags {
1149            let opt_name = long_flag
1150                .trim_start_matches("--")
1151                .replace('-', "_")
1152                .to_uppercase();
1153            option_map.insert(opt_name.clone(), long_flag.clone());
1154        }
1155        for short_flag in &opt.short_flags {
1156            let opt_name = short_flag.trim_start_matches("-").to_uppercase();
1157            option_map.insert(opt_name, short_flag.clone());
1158        }
1159    }
1160
1161    // Track found env vars to avoid duplicates
1162    let mut found_vars = std::collections::HashSet::new();
1163
1164    for line in &lines {
1165        let line_lower = line.to_lowercase();
1166
1167        // Skip if line doesn't seem to mention environment variables
1168        if !line_lower.contains("environment")
1169            && !line_lower.contains("env")
1170            && !line_lower.contains("$")
1171            && !line_lower.contains("variable")
1172        {
1173            continue;
1174        }
1175
1176        // Try each pattern
1177        for (pattern, _pattern_type) in &env_var_patterns {
1178            let re = regex::Regex::new(pattern).unwrap();
1179            for cap in re.captures_iter(line) {
1180                if let Some(var_name) = cap.get(1) {
1181                    let var_name = var_name.as_str().to_uppercase();
1182
1183                    // Skip common false positives
1184                    if var_name.len() < 2
1185                        || var_name == "THE"
1186                        || var_name == "CAN"
1187                        || var_name == "SET"
1188                    {
1189                        continue;
1190                    }
1191
1192                    if found_vars.contains(&var_name) {
1193                        continue;
1194                    }
1195
1196                    found_vars.insert(var_name.clone());
1197
1198                    // Try to map to an option
1199                    let option_mapped = option_map.get(&var_name).cloned();
1200
1201                    // Extract description (the line itself, or nearby context)
1202                    let description = if line.len() > var_name.len() + 10 {
1203                        Some(line.trim().to_string())
1204                    } else {
1205                        None
1206                    };
1207
1208                    // Try to extract default value
1209                    let default_value = extract_env_default_value(line);
1210
1211                    env_vars.push(EnvVarSpec {
1212                        name: var_name,
1213                        description,
1214                        option_mapped,
1215                        default_value,
1216                    });
1217                }
1218            }
1219        }
1220
1221        // Also check for explicit mappings in option descriptions
1222        for opt in options {
1223            if let Some(desc) = &opt.description {
1224                let desc_lower = desc.to_lowercase();
1225                if desc_lower.contains("environment") || desc_lower.contains("env var") {
1226                    // Look for VAR pattern in description
1227                    let var_re = regex::Regex::new(r"\b([A-Z_][A-Z0-9_]{2,})\b").unwrap();
1228                    for cap in var_re.captures_iter(desc) {
1229                        let var_name = cap.get(1).unwrap().as_str().to_uppercase();
1230
1231                        // Skip if it's the option name itself
1232                        if var_name
1233                            == opt
1234                                .long_flags
1235                                .first()
1236                                .unwrap_or(&String::new())
1237                                .trim_start_matches("--")
1238                                .replace('-', "_")
1239                                .to_uppercase()
1240                        {
1241                            continue;
1242                        }
1243
1244                        if !found_vars.contains(&var_name) && var_name.len() >= 3 {
1245                            found_vars.insert(var_name.clone());
1246
1247                            let option_mapped = opt.long_flags.first().cloned();
1248
1249                            env_vars.push(EnvVarSpec {
1250                                name: var_name,
1251                                description: Some(desc.clone()),
1252                                option_mapped,
1253                                default_value: extract_env_default_value(desc),
1254                            });
1255                        }
1256                    }
1257                }
1258            }
1259        }
1260    }
1261
1262    env_vars
1263}
1264
1265/// Extract default value from environment variable description.
1266fn extract_env_default_value(text: &str) -> Option<String> {
1267    let text_lower = text.to_lowercase();
1268
1269    // Patterns for default values
1270    let patterns = [
1271        r"default[:\s]+([^\s,;)]+)",
1272        r"defaults?\s+to\s+([^\s,;)]+)",
1273        r"\(default[:\s]+([^)]+)\)",
1274    ];
1275
1276    for pattern in &patterns {
1277        let re = regex::Regex::new(pattern).unwrap();
1278        if let Some(cap) = re.captures(&text_lower) {
1279            if let Some(m) = cap.get(1) {
1280                return Some(m.as_str().trim().to_string());
1281            }
1282        }
1283    }
1284
1285    None
1286}
1287
1288/// Parse validation rules from help text.
1289///
1290/// Extracts validation rules from descriptions, looking for patterns like:
1291/// - "must be between 1-100" (Range)
1292/// - "valid email" (Format)
1293/// - "must match pattern X" (Pattern)
1294/// - "one of: a, b, c" (Choice)
1295pub fn parse_validation_rules(
1296    _stdout: &str,
1297    _stderr: &str,
1298    options: &[OptionSpec],
1299    arguments: &[ArgumentSpec],
1300) -> Vec<ValidationRule> {
1301    let mut rules = Vec::new();
1302
1303    // Extract rules from option descriptions
1304    for opt in options {
1305        if let Some(desc) = &opt.description {
1306            if let Some(rule) = extract_validation_rule_from_description(
1307                desc,
1308                &opt.long_flags.first().unwrap_or(&String::new()).clone(),
1309            ) {
1310                rules.push(rule);
1311            }
1312        }
1313    }
1314
1315    // Extract rules from argument descriptions
1316    for arg in arguments {
1317        if let Some(desc) = &arg.description {
1318            if let Some(rule) = extract_validation_rule_from_description(desc, &arg.name) {
1319                rules.push(rule);
1320            }
1321        }
1322
1323        // Add required rule if argument is required
1324        if arg.required {
1325            rules.push(ValidationRule {
1326                target: arg.name.clone(),
1327                rule_type: ValidationType::Required,
1328                pattern: None,
1329                min: None,
1330                max: None,
1331                message: Some(format!("{} is required", arg.name)),
1332            });
1333        }
1334
1335        // Infer format rules from argument types
1336        if let Some(arg_type) = &arg.arg_type {
1337            match arg_type {
1338                ArgumentType::Email => {
1339                    rules.push(ValidationRule {
1340                        target: arg.name.clone(),
1341                        rule_type: ValidationType::Format,
1342                        pattern: Some(
1343                            r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$".to_string(),
1344                        ),
1345                        min: None,
1346                        max: None,
1347                        message: Some("Must be a valid email address".to_string()),
1348                    });
1349                }
1350                ArgumentType::Url => {
1351                    rules.push(ValidationRule {
1352                        target: arg.name.clone(),
1353                        rule_type: ValidationType::Format,
1354                        pattern: Some(r"^https?://.+".to_string()),
1355                        min: None,
1356                        max: None,
1357                        message: Some("Must be a valid URL".to_string()),
1358                    });
1359                }
1360                ArgumentType::Number => {
1361                    rules.push(ValidationRule {
1362                        target: arg.name.clone(),
1363                        rule_type: ValidationType::Pattern,
1364                        pattern: Some(r"^\d+(\.\d+)?$".to_string()),
1365                        min: None,
1366                        max: None,
1367                        message: Some("Must be a number".to_string()),
1368                    });
1369                }
1370                _ => {}
1371            }
1372        }
1373    }
1374
1375    // Extract choice rules from options with choices
1376    for opt in options {
1377        if !opt.choices.is_empty() {
1378            let target = opt.long_flags.first().unwrap_or(&String::new()).clone();
1379            rules.push(ValidationRule {
1380                target: target.clone(),
1381                rule_type: ValidationType::Choice,
1382                pattern: None,
1383                min: None,
1384                max: None,
1385                message: Some(format!("Must be one of: {}", opt.choices.join(", "))),
1386            });
1387        }
1388    }
1389
1390    rules
1391}
1392
1393/// Extract validation rule from a description string.
1394fn extract_validation_rule_from_description(desc: &str, target: &str) -> Option<ValidationRule> {
1395    let desc_lower = desc.to_lowercase();
1396
1397    // Check for range patterns: "between 1-100", "1 to 100", "must be 1-100"
1398    let range_pattern = regex::Regex::new(
1399        r"(?:between|from|range|must be)\s+(\d+(?:\.\d+)?)\s*(?:-|to)\s*(\d+(?:\.\d+)?)",
1400    )
1401    .unwrap();
1402    if let Some(cap) = range_pattern.captures(&desc_lower) {
1403        if let (Some(min_str), Some(max_str)) = (cap.get(1), cap.get(2)) {
1404            if let (Ok(min), Ok(max)) = (
1405                min_str.as_str().parse::<f64>(),
1406                max_str.as_str().parse::<f64>(),
1407            ) {
1408                return Some(ValidationRule {
1409                    target: target.to_string(),
1410                    rule_type: ValidationType::Range,
1411                    pattern: None,
1412                    min: Some(min),
1413                    max: Some(max),
1414                    message: Some(format!("Must be between {} and {}", min, max)),
1415                });
1416            }
1417        }
1418    }
1419
1420    // Check for min/max separately: "minimum 1", "max 100"
1421    let min_pattern = regex::Regex::new(r"(?:minimum|min|at least|>=)\s+(\d+(?:\.\d+)?)").unwrap();
1422    let max_pattern = regex::Regex::new(r"(?:maximum|max|at most|<=)\s+(\d+(?:\.\d+)?)").unwrap();
1423
1424    let min = min_pattern
1425        .captures(&desc_lower)
1426        .and_then(|c| c.get(1))
1427        .and_then(|m| m.as_str().parse::<f64>().ok());
1428    let max = max_pattern
1429        .captures(&desc_lower)
1430        .and_then(|c| c.get(1))
1431        .and_then(|m| m.as_str().parse::<f64>().ok());
1432
1433    if min.is_some() || max.is_some() {
1434        return Some(ValidationRule {
1435            target: target.to_string(),
1436            rule_type: ValidationType::Range,
1437            pattern: None,
1438            min,
1439            max,
1440            message: Some(desc.to_string()),
1441        });
1442    }
1443
1444    // Check for format patterns: "valid email", "valid URL", "valid path"
1445    if desc_lower.contains("valid email") || desc_lower.contains("email address") {
1446        return Some(ValidationRule {
1447            target: target.to_string(),
1448            rule_type: ValidationType::Format,
1449            pattern: Some(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$".to_string()),
1450            min: None,
1451            max: None,
1452            message: Some("Must be a valid email address".to_string()),
1453        });
1454    }
1455
1456    if desc_lower.contains("valid url") || desc_lower.contains("url") {
1457        return Some(ValidationRule {
1458            target: target.to_string(),
1459            rule_type: ValidationType::Format,
1460            pattern: Some(r"^https?://.+".to_string()),
1461            min: None,
1462            max: None,
1463            message: Some("Must be a valid URL".to_string()),
1464        });
1465    }
1466
1467    // Check for pattern mentions: "must match pattern", "regex", etc.
1468    let pattern_mention =
1469        regex::Regex::new(r"(?:pattern|regex|match|format)\s*[:=]\s*([^\s,;)]+)").unwrap();
1470    if let Some(cap) = pattern_mention.captures(desc) {
1471        if let Some(pattern) = cap.get(1) {
1472            return Some(ValidationRule {
1473                target: target.to_string(),
1474                rule_type: ValidationType::Pattern,
1475                pattern: Some(pattern.as_str().to_string()),
1476                min: None,
1477                max: None,
1478                message: Some(desc.to_string()),
1479            });
1480        }
1481    }
1482
1483    None
1484}