1pub(crate) mod template;
29
30use crate::core::command_def::{ArgDef, CommandDef, FlagDef};
31use crate::core::output_model::{
32 OutputDocument, OutputDocumentKind, OutputItems, OutputResult, RenderRecommendation,
33};
34use serde::{Deserialize, Serialize};
35use serde_json::{Map, Value, json};
36
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
38pub(crate) enum HelpLevel {
39 None,
40 Tiny,
41 #[default]
42 Normal,
43 Verbose,
44}
45
46impl HelpLevel {
47 pub(crate) fn parse(value: &str) -> Option<Self> {
48 match value.trim().to_ascii_lowercase().as_str() {
49 "none" | "off" => Some(Self::None),
50 "tiny" => Some(Self::Tiny),
51 "normal" => Some(Self::Normal),
52 "verbose" => Some(Self::Verbose),
53 _ => None,
54 }
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
73#[serde(default)]
74pub struct GuideView {
75 pub preamble: Vec<String>,
77 pub sections: Vec<GuideSection>,
79 pub epilogue: Vec<String>,
81 pub usage: Vec<String>,
83 pub commands: Vec<GuideEntry>,
85 pub arguments: Vec<GuideEntry>,
87 pub options: Vec<GuideEntry>,
89 pub common_invocation_options: Vec<GuideEntry>,
91 pub notes: Vec<String>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
101#[serde(default)]
102pub struct GuideEntry {
103 pub name: String,
105 pub short_help: String,
107 #[serde(skip)]
109 pub display_indent: Option<String>,
110 #[serde(skip)]
112 pub display_gap: Option<String>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
123#[serde(default)]
124pub struct GuideSection {
125 pub title: String,
127 pub kind: GuideSectionKind,
129 pub paragraphs: Vec<String>,
131 pub entries: Vec<GuideEntry>,
133 pub data: Option<Value>,
139}
140
141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "snake_case")]
144pub enum GuideSectionKind {
145 Usage,
147 Commands,
149 Options,
151 Arguments,
153 CommonInvocationOptions,
155 Notes,
157 #[default]
159 Custom,
160}
161
162impl GuideView {
163 pub fn from_text(help_text: &str) -> Self {
177 parse_help_view(help_text)
178 }
179
180 pub fn from_command_def(command: &CommandDef) -> Self {
199 guide_view_from_command_def(command)
200 }
201
202 pub fn to_output_result(&self) -> OutputResult {
223 let mut output = OutputResult::from_rows(vec![self.to_row()]).with_document(
227 OutputDocument::new(OutputDocumentKind::Guide, self.to_json_value()),
228 );
229 output.meta.render_recommendation = Some(RenderRecommendation::Guide);
230 output
231 }
232
233 pub fn to_json_value(&self) -> Value {
248 Value::Object(self.to_row())
249 }
250
251 pub fn try_from_output_result(output: &OutputResult) -> Option<Self> {
257 if let Some(document) = output.document.as_ref() {
262 return Self::try_from_output_document(document);
263 }
264
265 let rows = match &output.items {
266 OutputItems::Rows(rows) if rows.len() == 1 => rows,
267 _ => return None,
268 };
269 Self::try_from_row(&rows[0])
270 }
271
272 pub(crate) fn try_from_row_projection(output: &OutputResult) -> Option<Self> {
275 let rows = match &output.items {
276 OutputItems::Rows(rows) if rows.len() == 1 => rows,
277 _ => return None,
278 };
279 Self::try_from_row(&rows[0])
280 }
281
282 pub fn to_markdown(&self) -> String {
307 self.to_markdown_with_width(None)
308 }
309
310 pub fn to_markdown_with_width(&self, width: Option<usize>) -> String {
312 let mut settings = crate::ui::RenderSettings {
313 format: crate::core::output::OutputFormat::Markdown,
314 format_explicit: true,
315 ..crate::ui::RenderSettings::default()
316 };
317 settings.width = width;
318 crate::ui::render_structured_output_with_source_guide(
319 &self.to_output_result(),
320 Some(self),
321 &settings,
322 crate::ui::HelpLayout::Full,
323 )
324 }
325
326 pub fn to_value_lines(&self) -> Vec<String> {
328 let normalized = Self::normalize_restored_sections(self.clone());
329 let mut lines = Vec::new();
330 let use_ordered_sections = normalized.uses_ordered_section_representation();
331
332 append_value_paragraphs(&mut lines, &normalized.preamble);
333 if !(use_ordered_sections
334 && normalized.has_canonical_builtin_section_kind(GuideSectionKind::Usage))
335 {
336 append_value_paragraphs(&mut lines, &normalized.usage);
337 }
338 if !(use_ordered_sections
339 && normalized.has_canonical_builtin_section_kind(GuideSectionKind::Commands))
340 {
341 append_value_entries(&mut lines, &normalized.commands);
342 }
343 if !(use_ordered_sections
344 && normalized.has_canonical_builtin_section_kind(GuideSectionKind::Arguments))
345 {
346 append_value_entries(&mut lines, &normalized.arguments);
347 }
348 if !(use_ordered_sections
349 && normalized.has_canonical_builtin_section_kind(GuideSectionKind::Options))
350 {
351 append_value_entries(&mut lines, &normalized.options);
352 }
353 if !(use_ordered_sections
354 && normalized
355 .has_canonical_builtin_section_kind(GuideSectionKind::CommonInvocationOptions))
356 {
357 append_value_entries(&mut lines, &normalized.common_invocation_options);
358 }
359 if !(use_ordered_sections
360 && normalized.has_canonical_builtin_section_kind(GuideSectionKind::Notes))
361 {
362 append_value_paragraphs(&mut lines, &normalized.notes);
363 }
364
365 for section in &normalized.sections {
366 if !use_ordered_sections && section.is_canonical_builtin_section() {
367 continue;
368 }
369 append_value_paragraphs(&mut lines, §ion.paragraphs);
370 append_value_entries(&mut lines, §ion.entries);
371 if let Some(data) = section.data.as_ref() {
372 append_value_data(&mut lines, data);
373 }
374 }
375
376 append_value_paragraphs(&mut lines, &normalized.epilogue);
377
378 lines
379 }
380
381 pub fn merge(&mut self, mut other: GuideView) {
383 self.preamble.append(&mut other.preamble);
384 self.usage.append(&mut other.usage);
385 self.commands.append(&mut other.commands);
386 self.arguments.append(&mut other.arguments);
387 self.options.append(&mut other.options);
388 self.common_invocation_options
389 .append(&mut other.common_invocation_options);
390 self.notes.append(&mut other.notes);
391 self.sections.append(&mut other.sections);
392 self.epilogue.append(&mut other.epilogue);
393 }
394
395 pub(crate) fn filtered_for_help_level(&self, level: HelpLevel) -> Self {
396 let mut filtered = self.clone();
397 filtered.usage = if level >= HelpLevel::Tiny {
398 self.usage.clone()
399 } else {
400 Vec::new()
401 };
402 filtered.commands = if level >= HelpLevel::Normal {
403 self.commands.clone()
404 } else {
405 Vec::new()
406 };
407 filtered.arguments = if level >= HelpLevel::Normal {
408 self.arguments.clone()
409 } else {
410 Vec::new()
411 };
412 filtered.options = if level >= HelpLevel::Normal {
413 self.options.clone()
414 } else {
415 Vec::new()
416 };
417 filtered.common_invocation_options = if level >= HelpLevel::Verbose {
418 self.common_invocation_options.clone()
419 } else {
420 Vec::new()
421 };
422 filtered.notes = if level >= HelpLevel::Normal {
423 self.notes.clone()
424 } else {
425 Vec::new()
426 };
427 filtered.sections = self
428 .sections
429 .iter()
430 .filter(|section| level >= section.kind.min_help_level())
431 .cloned()
432 .collect();
433 filtered
434 }
435}
436
437impl GuideView {
438 fn try_from_output_document(document: &OutputDocument) -> Option<Self> {
439 match document.kind {
440 OutputDocumentKind::Guide => {
441 let view = Self::normalize_restored_sections(
442 serde_json::from_value(document.value.clone()).ok()?,
443 );
444 view.is_semantically_valid().then_some(view)
445 }
446 }
447 }
448
449 fn is_semantically_valid(&self) -> bool {
450 let entries_are_valid =
451 |entries: &[GuideEntry]| entries.iter().all(GuideEntry::is_semantically_valid);
452 let sections_are_valid = self
453 .sections
454 .iter()
455 .all(GuideSection::is_semantically_valid);
456 let has_content = !self.preamble.is_empty()
457 || !self.epilogue.is_empty()
458 || !self.usage.is_empty()
459 || !self.notes.is_empty()
460 || !self.commands.is_empty()
461 || !self.arguments.is_empty()
462 || !self.options.is_empty()
463 || !self.common_invocation_options.is_empty()
464 || !self.sections.is_empty();
465
466 has_content
467 && entries_are_valid(&self.commands)
468 && entries_are_valid(&self.arguments)
469 && entries_are_valid(&self.options)
470 && entries_are_valid(&self.common_invocation_options)
471 && sections_are_valid
472 }
473
474 fn to_row(&self) -> Map<String, Value> {
475 let mut row = Map::new();
476 let use_ordered_sections = self.uses_ordered_section_representation();
477
478 if !self.preamble.is_empty() {
479 row.insert("preamble".to_string(), string_array(&self.preamble));
480 }
481
482 if !(self.usage.is_empty()
483 || use_ordered_sections
484 && self.has_canonical_builtin_section_kind(GuideSectionKind::Usage))
485 {
486 row.insert("usage".to_string(), string_array(&self.usage));
487 }
488 if !(self.commands.is_empty()
489 || use_ordered_sections
490 && self.has_canonical_builtin_section_kind(GuideSectionKind::Commands))
491 {
492 row.insert("commands".to_string(), payload_entry_array(&self.commands));
493 }
494 if !(self.arguments.is_empty()
495 || use_ordered_sections
496 && self.has_canonical_builtin_section_kind(GuideSectionKind::Arguments))
497 {
498 row.insert(
499 "arguments".to_string(),
500 payload_entry_array(&self.arguments),
501 );
502 }
503 if !(self.options.is_empty()
504 || use_ordered_sections
505 && self.has_canonical_builtin_section_kind(GuideSectionKind::Options))
506 {
507 row.insert("options".to_string(), payload_entry_array(&self.options));
508 }
509 if !(self.common_invocation_options.is_empty()
510 || use_ordered_sections
511 && self
512 .has_canonical_builtin_section_kind(GuideSectionKind::CommonInvocationOptions))
513 {
514 row.insert(
515 "common_invocation_options".to_string(),
516 payload_entry_array(&self.common_invocation_options),
517 );
518 }
519 if !(self.notes.is_empty()
520 || use_ordered_sections
521 && self.has_canonical_builtin_section_kind(GuideSectionKind::Notes))
522 {
523 row.insert("notes".to_string(), string_array(&self.notes));
524 }
525 if !self.sections.is_empty() {
526 row.insert(
527 "sections".to_string(),
528 Value::Array(self.sections.iter().map(GuideSection::to_value).collect()),
529 );
530 }
531 if !self.epilogue.is_empty() {
532 row.insert("epilogue".to_string(), string_array(&self.epilogue));
533 }
534
535 row
536 }
537
538 fn try_from_row(row: &Map<String, Value>) -> Option<Self> {
539 let view = Self::normalize_restored_sections(Self {
540 preamble: row_string_array(row.get("preamble"))?,
541 usage: row_string_array(row.get("usage"))?,
542 commands: payload_entries(row.get("commands"))?,
543 arguments: payload_entries(row.get("arguments"))?,
544 options: payload_entries(row.get("options"))?,
545 common_invocation_options: payload_entries(row.get("common_invocation_options"))?,
546 notes: row_string_array(row.get("notes"))?,
547 sections: payload_sections(row.get("sections"))?,
548 epilogue: row_string_array(row.get("epilogue"))?,
549 });
550 view.is_semantically_valid().then_some(view)
551 }
552
553 fn normalize_restored_sections(mut view: Self) -> Self {
554 let use_ordered_sections = view.uses_ordered_section_representation();
567 let has_custom_sections = view
568 .sections
569 .iter()
570 .any(|section| !section.is_canonical_builtin_section());
571 let mut canonical_usage = Vec::new();
572 let mut canonical_commands = Vec::new();
573 let mut canonical_arguments = Vec::new();
574 let mut canonical_options = Vec::new();
575 let mut canonical_common_invocation_options = Vec::new();
576 let mut canonical_notes = Vec::new();
577
578 for section in &view.sections {
579 if !section.is_canonical_builtin_section() {
580 continue;
581 }
582
583 match section.kind {
584 GuideSectionKind::Usage => {
585 canonical_usage.extend(section.paragraphs.iter().cloned())
586 }
587 GuideSectionKind::Commands => {
588 canonical_commands.extend(section.entries.iter().cloned());
589 }
590 GuideSectionKind::Arguments => {
591 canonical_arguments.extend(section.entries.iter().cloned());
592 }
593 GuideSectionKind::Options => {
594 canonical_options.extend(section.entries.iter().cloned())
595 }
596 GuideSectionKind::CommonInvocationOptions => {
597 canonical_common_invocation_options.extend(section.entries.iter().cloned());
598 }
599 GuideSectionKind::Notes => {
600 canonical_notes.extend(section.paragraphs.iter().cloned())
601 }
602 GuideSectionKind::Custom => {}
603 }
604 }
605
606 if !use_ordered_sections || !has_custom_sections {
607 if view.has_canonical_builtin_section_kind(GuideSectionKind::Usage)
608 || view.usage.is_empty() && !canonical_usage.is_empty()
609 {
610 view.usage = canonical_usage;
611 }
612
613 if view.has_canonical_builtin_section_kind(GuideSectionKind::Commands)
614 || view.commands.is_empty() && !canonical_commands.is_empty()
615 {
616 view.commands = canonical_commands;
617 }
618
619 if view.has_canonical_builtin_section_kind(GuideSectionKind::Arguments)
620 || view.arguments.is_empty() && !canonical_arguments.is_empty()
621 {
622 view.arguments = canonical_arguments;
623 }
624
625 if view.has_canonical_builtin_section_kind(GuideSectionKind::Options)
626 || view.options.is_empty() && !canonical_options.is_empty()
627 {
628 view.options = canonical_options;
629 }
630
631 if view.has_canonical_builtin_section_kind(GuideSectionKind::CommonInvocationOptions)
632 || view.common_invocation_options.is_empty()
633 && !canonical_common_invocation_options.is_empty()
634 {
635 view.common_invocation_options = canonical_common_invocation_options;
636 }
637
638 if view.has_canonical_builtin_section_kind(GuideSectionKind::Notes)
639 || view.notes.is_empty() && !canonical_notes.is_empty()
640 {
641 view.notes = canonical_notes;
642 }
643
644 view.sections
645 .retain(|section| !section.is_canonical_builtin_section());
646 } else {
647 if view.usage.is_empty() && !canonical_usage.is_empty() {
648 view.usage = canonical_usage;
649 }
650 if view.commands.is_empty() && !canonical_commands.is_empty() {
651 view.commands = canonical_commands;
652 }
653 if view.arguments.is_empty() && !canonical_arguments.is_empty() {
654 view.arguments = canonical_arguments;
655 }
656 if view.options.is_empty() && !canonical_options.is_empty() {
657 view.options = canonical_options;
658 }
659 if view.common_invocation_options.is_empty()
660 && !canonical_common_invocation_options.is_empty()
661 {
662 view.common_invocation_options = canonical_common_invocation_options;
663 }
664 if view.notes.is_empty() && !canonical_notes.is_empty() {
665 view.notes = canonical_notes;
666 }
667 }
668 view
669 }
670
671 pub(crate) fn has_canonical_builtin_section_kind(&self, kind: GuideSectionKind) -> bool {
672 self.sections
673 .iter()
674 .any(|section| section.kind == kind && section.is_canonical_builtin_section())
675 }
676
677 pub(crate) fn uses_ordered_section_representation(&self) -> bool {
678 self.sections.iter().any(|section| {
679 !section.is_canonical_builtin_section()
680 || canonical_section_owns_ordered_content(self, section)
681 })
682 }
683}
684
685fn canonical_section_owns_ordered_content(view: &GuideView, section: &GuideSection) -> bool {
686 let has_data = !matches!(section.data, None | Some(Value::Null));
687 (match section.kind {
688 GuideSectionKind::Usage => !section.paragraphs.is_empty() && view.usage.is_empty(),
693 GuideSectionKind::Commands => !section.entries.is_empty() && view.commands.is_empty(),
694 GuideSectionKind::Arguments => !section.entries.is_empty() && view.arguments.is_empty(),
695 GuideSectionKind::Options => !section.entries.is_empty() && view.options.is_empty(),
696 GuideSectionKind::CommonInvocationOptions => {
697 !section.entries.is_empty() && view.common_invocation_options.is_empty()
698 }
699 GuideSectionKind::Notes => !section.paragraphs.is_empty() && view.notes.is_empty(),
700 GuideSectionKind::Custom => false,
701 }) || has_data
702}
703
704impl GuideEntry {
705 fn is_semantically_valid(&self) -> bool {
706 !self.name.is_empty() || !self.short_help.is_empty()
707 }
708}
709
710impl GuideSection {
711 fn is_semantically_valid(&self) -> bool {
712 let has_data = !matches!(self.data, None | Some(Value::Null));
713 let has_content =
714 !self.title.is_empty() || !self.paragraphs.is_empty() || !self.entries.is_empty();
715 (has_content || has_data) && self.entries.iter().all(GuideEntry::is_semantically_valid)
716 }
717
718 pub(crate) fn is_canonical_builtin_section(&self) -> bool {
719 let expected = match self.kind {
720 GuideSectionKind::Usage => "Usage",
721 GuideSectionKind::Commands => "Commands",
722 GuideSectionKind::Arguments => "Arguments",
723 GuideSectionKind::Options => "Options",
724 GuideSectionKind::CommonInvocationOptions => "Common Invocation Options",
725 GuideSectionKind::Notes => "Notes",
726 GuideSectionKind::Custom => return false,
727 };
728
729 self.title.trim().eq_ignore_ascii_case(expected)
730 }
731}
732
733fn append_value_paragraphs(lines: &mut Vec<String>, paragraphs: &[String]) {
734 if paragraphs.is_empty() {
735 return;
736 }
737 if !lines.is_empty() {
738 lines.push(String::new());
739 }
740 lines.extend(paragraphs.iter().cloned());
741}
742
743fn append_value_entries(lines: &mut Vec<String>, entries: &[GuideEntry]) {
744 let values = entries
745 .iter()
746 .filter_map(value_line_for_entry)
747 .collect::<Vec<_>>();
748
749 if values.is_empty() {
750 return;
751 }
752 if !lines.is_empty() {
753 lines.push(String::new());
754 }
755 lines.extend(values);
756}
757
758fn append_value_data(lines: &mut Vec<String>, data: &Value) {
759 let values = data_value_lines(data);
760 if values.is_empty() {
761 return;
762 }
763 if !lines.is_empty() {
764 lines.push(String::new());
765 }
766 lines.extend(values);
767}
768
769fn data_value_lines(value: &Value) -> Vec<String> {
770 if let Some(entries) = payload_entry_array_as_entries(value) {
771 return entries.iter().filter_map(value_line_for_entry).collect();
772 }
773
774 match value {
775 Value::Null => Vec::new(),
776 Value::Array(items) => items.iter().flat_map(data_value_lines).collect(),
777 Value::Object(map) => map
778 .values()
779 .filter(|value| !value.is_null())
780 .map(guide_value_to_display)
781 .collect(),
782 scalar => vec![guide_value_to_display(scalar)],
783 }
784}
785
786fn payload_entry_array_as_entries(value: &Value) -> Option<Vec<GuideEntry>> {
787 let Value::Array(items) = value else {
788 return None;
789 };
790
791 items.iter().map(payload_entry_value_as_entry).collect()
792}
793
794fn payload_entry_value_as_entry(value: &Value) -> Option<GuideEntry> {
795 let Value::Object(map) = value else {
796 return None;
797 };
798 if map.keys().any(|key| key != "name" && key != "short_help") {
799 return None;
800 }
801
802 Some(GuideEntry {
803 name: map.get("name")?.as_str()?.to_string(),
804 short_help: map
805 .get("short_help")
806 .and_then(Value::as_str)
807 .unwrap_or_default()
808 .to_string(),
809 display_indent: None,
810 display_gap: None,
811 })
812}
813
814fn guide_value_to_display(value: &Value) -> String {
815 match value {
816 Value::Null => "null".to_string(),
817 Value::Bool(value) => value.to_string().to_ascii_lowercase(),
818 Value::Number(value) => value.to_string(),
819 Value::String(value) => value.clone(),
820 Value::Array(values) => values
821 .iter()
822 .map(guide_value_to_display)
823 .collect::<Vec<_>>()
824 .join(", "),
825 Value::Object(map) => {
826 if map.is_empty() {
827 return "{}".to_string();
828 }
829 let mut keys = map.keys().collect::<Vec<_>>();
830 keys.sort();
831 let preview = keys
832 .into_iter()
833 .take(3)
834 .cloned()
835 .collect::<Vec<_>>()
836 .join(", ");
837 if map.len() > 3 {
838 format!("{{{preview}, ...}}")
839 } else {
840 format!("{{{preview}}}")
841 }
842 }
843 }
844}
845
846fn value_line_for_entry(entry: &GuideEntry) -> Option<String> {
847 if !entry.short_help.trim().is_empty() {
848 return Some(entry.short_help.clone());
849 }
850 if !entry.name.trim().is_empty() {
851 return Some(entry.name.clone());
852 }
853 None
854}
855
856impl GuideSection {
857 fn to_value(&self) -> Value {
858 let mut section = Map::new();
859 section.insert("title".to_string(), Value::String(self.title.clone()));
860 section.insert(
861 "kind".to_string(),
862 Value::String(self.kind.as_str().to_string()),
863 );
864 section.insert("paragraphs".to_string(), string_array(&self.paragraphs));
865 section.insert(
866 "entries".to_string(),
867 Value::Array(
868 self.entries
869 .iter()
870 .map(payload_entry_value)
871 .collect::<Vec<_>>(),
872 ),
873 );
874 if let Some(data) = self.data.as_ref() {
875 section.insert("data".to_string(), data.clone());
876 }
877 Value::Object(section)
878 }
879}
880
881impl GuideSection {
882 pub fn new(title: impl Into<String>, kind: GuideSectionKind) -> Self {
898 Self {
899 title: title.into(),
900 kind,
901 paragraphs: Vec::new(),
902 entries: Vec::new(),
903 data: None,
904 }
905 }
906
907 pub fn paragraph(mut self, text: impl Into<String>) -> Self {
909 self.paragraphs.push(text.into());
910 self
911 }
912
913 pub fn data(mut self, value: Value) -> Self {
918 self.data = Some(value);
919 self
920 }
921
922 pub fn entry(mut self, name: impl Into<String>, short_help: impl Into<String>) -> Self {
924 self.entries.push(GuideEntry {
925 name: name.into(),
926 short_help: short_help.into(),
927 display_indent: None,
928 display_gap: None,
929 });
930 self
931 }
932}
933
934impl GuideSectionKind {
935 pub fn as_str(self) -> &'static str {
949 match self {
950 GuideSectionKind::Usage => "usage",
951 GuideSectionKind::Commands => "commands",
952 GuideSectionKind::Options => "options",
953 GuideSectionKind::Arguments => "arguments",
954 GuideSectionKind::CommonInvocationOptions => "common_invocation_options",
955 GuideSectionKind::Notes => "notes",
956 GuideSectionKind::Custom => "custom",
957 }
958 }
959
960 pub(crate) fn min_help_level(self) -> HelpLevel {
961 match self {
962 GuideSectionKind::Usage => HelpLevel::Tiny,
963 GuideSectionKind::CommonInvocationOptions => HelpLevel::Verbose,
964 GuideSectionKind::Commands
965 | GuideSectionKind::Options
966 | GuideSectionKind::Arguments
967 | GuideSectionKind::Notes
968 | GuideSectionKind::Custom => HelpLevel::Normal,
969 }
970 }
971}
972
973fn string_array(values: &[String]) -> Value {
974 Value::Array(
975 values
976 .iter()
977 .map(|value| Value::String(value.trim().to_string()))
978 .collect(),
979 )
980}
981
982fn row_string_array(value: Option<&Value>) -> Option<Vec<String>> {
983 let Some(value) = value else {
984 return Some(Vec::new());
985 };
986 let Value::Array(values) = value else {
987 return None;
988 };
989 values
990 .iter()
991 .map(|value| value.as_str().map(ToOwned::to_owned))
992 .collect()
993}
994
995fn payload_entry_value(entry: &GuideEntry) -> Value {
996 json!({
997 "name": entry.name,
998 "short_help": entry.short_help,
999 })
1000}
1001
1002fn payload_entry_array(entries: &[GuideEntry]) -> Value {
1003 Value::Array(entries.iter().map(payload_entry_value).collect())
1004}
1005
1006fn payload_entries(value: Option<&Value>) -> Option<Vec<GuideEntry>> {
1007 let Some(value) = value else {
1008 return Some(Vec::new());
1009 };
1010 let Value::Array(entries) = value else {
1011 return None;
1012 };
1013
1014 let mut out = Vec::new();
1015 for entry in entries {
1016 let Value::Object(entry) = entry else {
1017 return None;
1018 };
1019 let name = entry
1020 .get("name")
1021 .and_then(Value::as_str)
1022 .unwrap_or_default()
1023 .to_string();
1024 let short_help = entry
1025 .get("short_help")
1026 .or_else(|| entry.get("summary"))
1027 .and_then(Value::as_str)
1028 .unwrap_or_default()
1029 .to_string();
1030 out.push(GuideEntry {
1031 name,
1032 short_help,
1033 display_indent: None,
1034 display_gap: None,
1035 });
1036 }
1037 Some(out)
1038}
1039
1040fn payload_sections(value: Option<&Value>) -> Option<Vec<GuideSection>> {
1041 let Some(value) = value else {
1042 return Some(Vec::new());
1043 };
1044 let Value::Array(sections) = value else {
1045 return None;
1046 };
1047
1048 let mut out = Vec::new();
1049 for section in sections {
1050 let Value::Object(section) = section else {
1051 return None;
1052 };
1053 let title = section.get("title")?.as_str()?.to_string();
1054 let kind = match section
1055 .get("kind")
1056 .and_then(Value::as_str)
1057 .unwrap_or("custom")
1058 {
1059 "custom" => GuideSectionKind::Custom,
1060 "notes" => GuideSectionKind::Notes,
1061 "usage" => GuideSectionKind::Usage,
1062 "commands" => GuideSectionKind::Commands,
1063 "arguments" => GuideSectionKind::Arguments,
1064 "options" => GuideSectionKind::Options,
1065 "common_invocation_options" => GuideSectionKind::CommonInvocationOptions,
1066 _ => return None,
1067 };
1068 out.push(GuideSection {
1069 title,
1070 kind,
1071 paragraphs: row_string_array(section.get("paragraphs"))?,
1072 entries: payload_entries(section.get("entries"))?,
1073 data: section.get("data").cloned(),
1074 });
1075 }
1076 Some(out)
1077}
1078
1079fn guide_view_from_command_def(command: &CommandDef) -> GuideView {
1080 let usage = command
1081 .usage
1082 .clone()
1083 .or_else(|| default_usage(command))
1084 .map(|usage| vec![usage])
1085 .unwrap_or_default();
1086
1087 let visible_subcommands = command
1088 .subcommands
1089 .iter()
1090 .filter(|subcommand| !subcommand.hidden)
1091 .collect::<Vec<_>>();
1092 let commands = visible_subcommands
1093 .into_iter()
1094 .map(|subcommand| GuideEntry {
1095 name: subcommand.name.clone(),
1096 short_help: subcommand.about.clone().unwrap_or_default(),
1097 display_indent: None,
1098 display_gap: None,
1099 })
1100 .collect();
1101
1102 let visible_args = command
1103 .args
1104 .iter()
1105 .filter(|arg| !arg.id.is_empty())
1106 .collect::<Vec<_>>();
1107 let arguments = visible_args
1108 .into_iter()
1109 .map(|arg| GuideEntry {
1110 name: arg_label(arg),
1111 short_help: arg.help.clone().unwrap_or_default(),
1112 display_indent: None,
1113 display_gap: None,
1114 })
1115 .collect();
1116
1117 let visible_flags = command
1118 .flags
1119 .iter()
1120 .filter(|flag| !flag.hidden)
1121 .collect::<Vec<_>>();
1122 let options = visible_flags
1123 .into_iter()
1124 .map(|flag| GuideEntry {
1125 name: flag_label(flag),
1126 short_help: flag.help.clone().unwrap_or_default(),
1127 display_indent: Some(if flag.short.is_some() {
1128 " ".to_string()
1129 } else {
1130 " ".to_string()
1131 }),
1132 display_gap: None,
1133 })
1134 .collect();
1135
1136 let preamble = command
1137 .before_help
1138 .iter()
1139 .flat_map(|text| text.lines().map(ToString::to_string))
1140 .collect();
1141 let epilogue = command
1142 .after_help
1143 .iter()
1144 .flat_map(|text| text.lines().map(ToString::to_string))
1145 .collect();
1146
1147 GuideView {
1148 preamble,
1149 sections: Vec::new(),
1150 epilogue,
1151 usage,
1152 commands,
1153 arguments,
1154 options,
1155 common_invocation_options: Vec::new(),
1156 notes: Vec::new(),
1157 }
1158}
1159
1160fn default_usage(command: &CommandDef) -> Option<String> {
1161 if command.name.trim().is_empty() {
1162 return None;
1163 }
1164
1165 let mut parts = vec![command.name.clone()];
1166 if !command
1167 .flags
1168 .iter()
1169 .filter(|flag| !flag.hidden)
1170 .collect::<Vec<_>>()
1171 .is_empty()
1172 {
1173 parts.push("[OPTIONS]".to_string());
1174 }
1175 for arg in command.args.iter().filter(|arg| !arg.id.is_empty()) {
1176 let label = arg_label(arg);
1177 if arg.required {
1178 parts.push(label);
1179 } else {
1180 parts.push(format!("[{label}]"));
1181 }
1182 }
1183 if !command
1184 .subcommands
1185 .iter()
1186 .filter(|subcommand| !subcommand.hidden)
1187 .collect::<Vec<_>>()
1188 .is_empty()
1189 {
1190 parts.push("<COMMAND>".to_string());
1191 }
1192 Some(parts.join(" "))
1193}
1194
1195fn arg_label(arg: &ArgDef) -> String {
1196 arg.value_name.clone().unwrap_or_else(|| arg.id.clone())
1197}
1198
1199fn flag_label(flag: &FlagDef) -> String {
1200 let mut labels = Vec::new();
1201 if let Some(short) = flag.short {
1202 labels.push(format!("-{short}"));
1203 }
1204 if let Some(long) = flag.long.as_deref() {
1205 labels.push(format!("--{long}"));
1206 }
1207 if flag.takes_value
1208 && let Some(value_name) = flag.value_name.as_deref()
1209 {
1210 labels.push(format!("<{value_name}>"));
1211 }
1212 labels.join(", ")
1213}
1214
1215fn parse_help_view(help_text: &str) -> GuideView {
1216 let mut view = GuideView::default();
1217 let mut current: Option<GuideSection> = None;
1218 let mut saw_section = false;
1219
1220 for raw_line in help_text.lines() {
1221 let line = raw_line.trim_end();
1222 if let Some((title, kind, body)) = parse_section_header(line) {
1223 if let Some(section) = current.take() {
1224 view.sections.push(section);
1225 }
1226 saw_section = true;
1227 let mut section = GuideSection::new(title, kind);
1228 if let Some(body) = body {
1229 section.paragraphs.push(body);
1230 }
1231 current = Some(section);
1232 continue;
1233 }
1234
1235 if current
1236 .as_ref()
1237 .is_some_and(|section| line_belongs_to_epilogue(section.kind, line))
1238 {
1239 if let Some(section) = current.take() {
1240 view.sections.push(section);
1241 }
1242 view.epilogue.push(line.to_string());
1243 continue;
1244 }
1245
1246 if let Some(section) = current.as_mut() {
1247 parse_section_line(section, line);
1248 } else if !line.is_empty() {
1249 if saw_section {
1250 view.epilogue.push(line.to_string());
1251 } else {
1252 view.preamble.push(line.to_string());
1253 }
1254 }
1255 }
1256
1257 if let Some(section) = current {
1258 view.sections.push(section);
1259 }
1260
1261 repartition_builtin_sections(view)
1262}
1263
1264fn line_belongs_to_epilogue(kind: GuideSectionKind, line: &str) -> bool {
1265 if line.trim().is_empty() {
1266 return false;
1267 }
1268
1269 matches!(
1270 kind,
1271 GuideSectionKind::Commands | GuideSectionKind::Options | GuideSectionKind::Arguments
1272 ) && !line.starts_with(' ')
1273}
1274
1275fn parse_section_header(line: &str) -> Option<(String, GuideSectionKind, Option<String>)> {
1276 if let Some(usage) = line.strip_prefix("Usage:") {
1277 return Some((
1278 "Usage".to_string(),
1279 GuideSectionKind::Usage,
1280 Some(usage.trim().to_string()),
1281 ));
1282 }
1283
1284 let (title, kind) = match line {
1285 "Commands:" => ("Commands".to_string(), GuideSectionKind::Commands),
1286 "Options:" => ("Options".to_string(), GuideSectionKind::Options),
1287 "Arguments:" => ("Arguments".to_string(), GuideSectionKind::Arguments),
1288 "Common Invocation Options:" => (
1289 "Common Invocation Options".to_string(),
1290 GuideSectionKind::CommonInvocationOptions,
1291 ),
1292 "Notes:" => ("Notes".to_string(), GuideSectionKind::Notes),
1293 _ if !line.starts_with(' ') && line.ends_with(':') => (
1294 line.trim_end_matches(':').trim().to_string(),
1295 GuideSectionKind::Custom,
1296 ),
1297 _ => return None,
1298 };
1299
1300 Some((title, kind, None))
1301}
1302
1303fn parse_section_line(section: &mut GuideSection, line: &str) {
1304 if line.trim().is_empty() {
1305 return;
1306 }
1307
1308 if matches!(
1309 section.kind,
1310 GuideSectionKind::Commands
1311 | GuideSectionKind::Options
1312 | GuideSectionKind::Arguments
1313 | GuideSectionKind::CommonInvocationOptions
1314 ) {
1315 let indent_len = line.len().saturating_sub(line.trim_start().len());
1316 let (_, rest) = line.split_at(indent_len);
1317 let split = help_description_split(section.kind, rest).unwrap_or(rest.len());
1318 let (head, tail) = rest.split_at(split);
1319 let display_indent = Some(" ".repeat(indent_len));
1320 let display_gap = (!tail.is_empty()).then(|| {
1321 tail.chars()
1322 .take_while(|ch| ch.is_whitespace())
1323 .collect::<String>()
1324 });
1325 section.entries.push(GuideEntry {
1326 name: head.trim().to_string(),
1327 short_help: tail.trim().to_string(),
1328 display_indent,
1329 display_gap,
1330 });
1331 return;
1332 }
1333
1334 section.paragraphs.push(line.to_string());
1335}
1336
1337fn repartition_builtin_sections(mut view: GuideView) -> GuideView {
1338 let sections = std::mem::take(&mut view.sections);
1339 for section in sections {
1340 match section.kind {
1341 GuideSectionKind::Usage => view.usage.extend(section.paragraphs),
1342 GuideSectionKind::Commands => view.commands.extend(section.entries),
1343 GuideSectionKind::Arguments => view.arguments.extend(section.entries),
1344 GuideSectionKind::Options => view.options.extend(section.entries),
1345 GuideSectionKind::CommonInvocationOptions => {
1346 view.common_invocation_options.extend(section.entries);
1347 }
1348 GuideSectionKind::Notes => view.notes.extend(section.paragraphs),
1349 GuideSectionKind::Custom => view.sections.push(section),
1350 }
1351 }
1352 view
1353}
1354
1355fn help_description_split(kind: GuideSectionKind, line: &str) -> Option<usize> {
1356 let mut saw_non_whitespace = false;
1357 let mut run_start = None;
1358 let mut run_len = 0usize;
1359
1360 for (idx, ch) in line.char_indices() {
1361 if ch.is_whitespace() {
1362 if saw_non_whitespace {
1363 run_start.get_or_insert(idx);
1364 run_len += 1;
1365 }
1366 continue;
1367 }
1368
1369 if saw_non_whitespace && run_len >= 2 {
1370 return run_start;
1371 }
1372
1373 saw_non_whitespace = true;
1374 run_start = None;
1375 run_len = 0;
1376 }
1377
1378 if matches!(
1379 kind,
1380 GuideSectionKind::Commands | GuideSectionKind::Arguments
1381 ) {
1382 return line.find(char::is_whitespace);
1383 }
1384
1385 None
1386}
1387
1388#[cfg(test)]
1389mod tests;