1use crate::model::{
2 ArgumentSpec, ArgumentType, EnvVarSpec, OptionSpec, OptionType, SubcommandSpec, ValidationRule,
3 ValidationType,
4};
5
6pub 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
25pub 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 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; 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
79pub 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 if !line.starts_with('-') {
99 continue;
100 }
101
102 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 let flag_part_normalized = flag_part.replace(" | ", ", ");
110
111 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 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 let (takes_argument, argument_name, option_type, choices) =
146 extract_option_metadata(&flag_part, description.as_deref());
147
148 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
171pub 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 for (idx, line) in lines.iter().enumerate() {
191 let trimmed = line.trim().to_lowercase();
192
193 if trimmed == "options:" || trimmed == "options" {
195 in_options_section = true;
196 options_start_idx = idx + 1;
197 continue;
198 }
199
200 if in_options_section {
202 let trimmed_line = line.trim_start();
203
204 if trimmed_line.is_empty() {
206 if idx > options_start_idx + 2 {
208 in_options_section = false;
209 }
210 continue;
211 }
212
213 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 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 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 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 in_options_section = false;
287 }
288 }
289 }
290
291 options
292}
293
294pub 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 for block in usage_blocks {
310 arguments.extend(parse_arguments_from_usage_line(block));
311 }
312
313 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 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
329fn parse_arguments_from_usage_line(usage_block: &str) -> Vec<ArgumentSpec> {
332 let mut arguments = Vec::new();
333
334 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 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
381fn 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 if trimmed == "arguments:" || trimmed == "arguments" {
392 in_arguments_section = true;
393 section_start_idx = idx + 1;
394 continue;
395 }
396
397 if in_arguments_section {
399 let trimmed_line = line.trim_start();
400
401 if trimmed_line.is_empty() {
403 if idx > section_start_idx + 2 {
404 in_arguments_section = false;
405 }
406 continue;
407 }
408
409 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 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 in_arguments_section = false;
427 }
428 }
429 }
430
431 arguments
432}
433
434fn parse_argument_line(line: &str) -> Option<ArgumentSpec> {
438 let trimmed = line.trim();
439
440 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 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 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
492fn infer_argument_type(name: &str, placeholder: Option<&str>) -> Option<ArgumentType> {
494 let name_upper = name.to_uppercase();
495
496 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 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 Some(ArgumentType::String)
529}
530
531fn extract_option_metadata(
534 flag_part: &str,
535 description: Option<&str>,
536) -> (bool, Option<String>, OptionType, Vec<String>) {
537 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 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 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 !takes_argument {
595 if let Some(desc) = description {
596 let desc_lower = desc.to_lowercase();
597 if desc_lower.contains("takes")
599 || desc_lower.contains("requires")
600 || desc_lower.contains("specify")
601 {
602 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 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
628fn 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
665fn extract_default_value(description: Option<&str>) -> Option<String> {
668 if let Some(desc) = description {
669 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
690fn split_flag_and_description(line: &str) -> (String, Option<String>) {
697 let mut best_split: Option<(String, String)> = None;
699
700 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 (line.to_string(), None)
722 }
723}
724
725pub 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 let mut header_indices = Vec::new();
750 for (idx, line) in lines.iter().enumerate() {
751 let l = line.trim().to_lowercase();
752 if l == "subcommands:" || l == "subcommands" || l == "commands:" || l == "commands" {
754 header_indices.push(idx);
755 }
756 else if l.ends_with("commands:") || l.ends_with("commands") {
758 if !l.contains("option") {
760 header_indices.push(idx);
761 }
762 }
763 }
764
765 let list_subcommands = find_subcommands_in_lists(&lines);
768
769 if header_indices.is_empty() {
770 return list_subcommands;
772 }
773
774 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 let trimmed_start = line.trim_start();
788 if raw == trimmed_start {
790 break;
792 }
793
794 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 name = name.trim_end_matches(',').trim().to_string();
807
808 if name.starts_with('-') {
810 i += 1;
811 continue;
812 }
813
814 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 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 if !next_trimmed.is_empty()
837 && next_raw != next_trimmed
838 && !next_trimmed.starts_with('-')
839 && next_indent > current_indent
840 {
841 let extra = next_trimmed.to_string();
843 description = Some(match description {
844 Some(existing) => format!("{existing} {extra}"),
845 None => extra,
846 });
847 i = j; }
849 }
850
851 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 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
877fn find_subcommands_in_lists(lines: &[String]) -> Vec<SubcommandSpec> {
883 let mut subcommands = Vec::new();
884
885 for (idx, line) in lines.iter().enumerate() {
887 let lower = line.trim().to_lowercase();
888
889 if (lower.contains("commands") && (lower.contains("are") || lower.contains("available")))
891 || (lower.contains("common") && lower.contains("commands"))
892 {
893 let mut i = idx + 1;
895 while i < lines.len() && i < idx + 50 {
896 let raw = &lines[i];
898 let trimmed = raw.trim_start();
899
900 if trimmed.is_empty() {
902 if i > idx + 3 {
903 break;
904 }
905 i += 1;
906 continue;
907 }
908
909 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 if raw != trimmed && !trimmed.starts_with('-') {
919 let parts: Vec<&str> = trimmed.splitn(2, char::is_whitespace).collect();
921 if let Some(raw_name) = parts.first() {
922 let name = raw_name.trim().trim_end_matches(',').trim();
924 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 break;
953 }
954
955 i += 1;
956 }
957 }
958 }
959
960 subcommands
961}
962pub 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 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 if trimmed_line.is_empty() {
995 if idx > section_start_idx + 3 {
996 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 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 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 let command = trimmed_line
1041 .trim_start_matches('$')
1042 .trim_start_matches('>')
1043 .trim_start_matches('#')
1044 .trim()
1045 .to_string();
1046
1047 let mut description = None;
1049
1050 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 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 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 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 if idx > section_start_idx + 2 {
1105 in_examples_section = false;
1106 }
1107 }
1108 }
1109 }
1110
1111 examples
1112}
1113
1114pub 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 let env_var_patterns = [
1133 (r"\$([A-Z_][A-Z0-9_]*)", "dollar_sign"),
1135 (r"\$\{([A-Z_][A-Z0-9_]*)\}", "dollar_brace"),
1136 (
1138 r"\b([A-Z_][A-Z0-9_]*)\s+(?:environment\s+)?variable",
1139 "explicit_var",
1140 ),
1141 (r"(?:set|use|via)\s+([A-Z_][A-Z0-9_]*)", "set_pattern"),
1143 ];
1144
1145 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 let mut found_vars = std::collections::HashSet::new();
1163
1164 for line in &lines {
1165 let line_lower = line.to_lowercase();
1166
1167 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 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 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 let option_mapped = option_map.get(&var_name).cloned();
1200
1201 let description = if line.len() > var_name.len() + 10 {
1203 Some(line.trim().to_string())
1204 } else {
1205 None
1206 };
1207
1208 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 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 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 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
1265fn extract_env_default_value(text: &str) -> Option<String> {
1267 let text_lower = text.to_lowercase();
1268
1269 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
1288pub 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 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 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 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 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 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
1393fn extract_validation_rule_from_description(desc: &str, target: &str) -> Option<ValidationRule> {
1395 let desc_lower = desc.to_lowercase();
1396
1397 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 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 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 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}