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