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 {
146 parse_help_view(help_text)
147 }
148
149 pub fn from_command_def(command: &CommandDef) -> Self {
166 guide_view_from_command_def(command)
167 }
168
169 pub fn to_output_result(&self) -> OutputResult {
187 let mut output = OutputResult::from_rows(vec![self.to_row()]).with_document(
191 OutputDocument::new(OutputDocumentKind::Guide, self.to_json_value()),
192 );
193 output.meta.render_recommendation = Some(RenderRecommendation::Guide);
194 output
195 }
196
197 pub fn to_json_value(&self) -> Value {
199 Value::Object(self.to_row())
200 }
201
202 pub fn try_from_output_result(output: &OutputResult) -> Option<Self> {
208 if let Some(document) = output.document.as_ref() {
213 return Self::try_from_output_document(document);
214 }
215
216 let rows = match &output.items {
217 OutputItems::Rows(rows) if rows.len() == 1 => rows,
218 _ => return None,
219 };
220 Self::try_from_row(&rows[0])
221 }
222
223 pub fn to_markdown(&self) -> String {
238 self.to_markdown_with_width(None)
239 }
240
241 pub fn to_markdown_with_width(&self, width: Option<usize>) -> String {
243 DocumentModel::from_guide_view(self).to_markdown_with_width(width)
244 }
245
246 pub fn to_value_lines(&self) -> Vec<String> {
248 let normalized = Self::normalize_restored_sections(self.clone());
249 let mut lines = Vec::new();
250 let use_ordered_sections = normalized.uses_ordered_section_representation();
251
252 append_value_paragraphs(&mut lines, &normalized.preamble);
253 if !(use_ordered_sections
254 && normalized.has_canonical_builtin_section_kind(GuideSectionKind::Usage))
255 {
256 append_value_paragraphs(&mut lines, &normalized.usage);
257 }
258 if !(use_ordered_sections
259 && normalized.has_canonical_builtin_section_kind(GuideSectionKind::Commands))
260 {
261 append_value_entries(&mut lines, &normalized.commands);
262 }
263 if !(use_ordered_sections
264 && normalized.has_canonical_builtin_section_kind(GuideSectionKind::Arguments))
265 {
266 append_value_entries(&mut lines, &normalized.arguments);
267 }
268 if !(use_ordered_sections
269 && normalized.has_canonical_builtin_section_kind(GuideSectionKind::Options))
270 {
271 append_value_entries(&mut lines, &normalized.options);
272 }
273 if !(use_ordered_sections
274 && normalized
275 .has_canonical_builtin_section_kind(GuideSectionKind::CommonInvocationOptions))
276 {
277 append_value_entries(&mut lines, &normalized.common_invocation_options);
278 }
279 if !(use_ordered_sections
280 && normalized.has_canonical_builtin_section_kind(GuideSectionKind::Notes))
281 {
282 append_value_paragraphs(&mut lines, &normalized.notes);
283 }
284
285 for section in &normalized.sections {
286 if !use_ordered_sections && section.is_canonical_builtin_section() {
287 continue;
288 }
289 append_value_paragraphs(&mut lines, §ion.paragraphs);
290 append_value_entries(&mut lines, §ion.entries);
291 if let Some(data) = section.data.as_ref() {
292 append_value_data(&mut lines, data);
293 }
294 }
295
296 append_value_paragraphs(&mut lines, &normalized.epilogue);
297
298 lines
299 }
300
301 pub fn merge(&mut self, mut other: GuideView) {
303 self.preamble.append(&mut other.preamble);
304 self.usage.append(&mut other.usage);
305 self.commands.append(&mut other.commands);
306 self.arguments.append(&mut other.arguments);
307 self.options.append(&mut other.options);
308 self.common_invocation_options
309 .append(&mut other.common_invocation_options);
310 self.notes.append(&mut other.notes);
311 self.sections.append(&mut other.sections);
312 self.epilogue.append(&mut other.epilogue);
313 }
314
315 pub(crate) fn filtered_for_help_level(&self, level: HelpLevel) -> Self {
316 let mut filtered = self.clone();
317 filtered.usage = if level >= HelpLevel::Tiny {
318 self.usage.clone()
319 } else {
320 Vec::new()
321 };
322 filtered.commands = if level >= HelpLevel::Normal {
323 self.commands.clone()
324 } else {
325 Vec::new()
326 };
327 filtered.arguments = if level >= HelpLevel::Normal {
328 self.arguments.clone()
329 } else {
330 Vec::new()
331 };
332 filtered.options = if level >= HelpLevel::Normal {
333 self.options.clone()
334 } else {
335 Vec::new()
336 };
337 filtered.common_invocation_options = if level >= HelpLevel::Verbose {
338 self.common_invocation_options.clone()
339 } else {
340 Vec::new()
341 };
342 filtered.notes = if level >= HelpLevel::Normal {
343 self.notes.clone()
344 } else {
345 Vec::new()
346 };
347 filtered.sections = self
348 .sections
349 .iter()
350 .filter(|section| level >= section.kind.min_help_level())
351 .cloned()
352 .collect();
353 filtered
354 }
355}
356
357impl GuideView {
358 fn try_from_output_document(document: &OutputDocument) -> Option<Self> {
359 match document.kind {
360 OutputDocumentKind::Guide => {
361 let view = Self::normalize_restored_sections(
362 serde_json::from_value(document.value.clone()).ok()?,
363 );
364 view.is_semantically_valid().then_some(view)
365 }
366 }
367 }
368
369 fn is_semantically_valid(&self) -> bool {
370 let entries_are_valid =
371 |entries: &[GuideEntry]| entries.iter().all(GuideEntry::is_semantically_valid);
372 let sections_are_valid = self
373 .sections
374 .iter()
375 .all(GuideSection::is_semantically_valid);
376 let has_content = !self.preamble.is_empty()
377 || !self.epilogue.is_empty()
378 || !self.usage.is_empty()
379 || !self.notes.is_empty()
380 || !self.commands.is_empty()
381 || !self.arguments.is_empty()
382 || !self.options.is_empty()
383 || !self.common_invocation_options.is_empty()
384 || !self.sections.is_empty();
385
386 has_content
387 && entries_are_valid(&self.commands)
388 && entries_are_valid(&self.arguments)
389 && entries_are_valid(&self.options)
390 && entries_are_valid(&self.common_invocation_options)
391 && sections_are_valid
392 }
393
394 fn to_row(&self) -> Map<String, Value> {
395 let mut row = Map::new();
396 let use_ordered_sections = self.uses_ordered_section_representation();
397
398 if !self.preamble.is_empty() {
399 row.insert("preamble".to_string(), string_array(&self.preamble));
400 }
401
402 if !(self.usage.is_empty()
403 || use_ordered_sections
404 && self.has_canonical_builtin_section_kind(GuideSectionKind::Usage))
405 {
406 row.insert("usage".to_string(), string_array(&self.usage));
407 }
408 if !(self.commands.is_empty()
409 || use_ordered_sections
410 && self.has_canonical_builtin_section_kind(GuideSectionKind::Commands))
411 {
412 row.insert("commands".to_string(), payload_entry_array(&self.commands));
413 }
414 if !(self.arguments.is_empty()
415 || use_ordered_sections
416 && self.has_canonical_builtin_section_kind(GuideSectionKind::Arguments))
417 {
418 row.insert(
419 "arguments".to_string(),
420 payload_entry_array(&self.arguments),
421 );
422 }
423 if !(self.options.is_empty()
424 || use_ordered_sections
425 && self.has_canonical_builtin_section_kind(GuideSectionKind::Options))
426 {
427 row.insert("options".to_string(), payload_entry_array(&self.options));
428 }
429 if !(self.common_invocation_options.is_empty()
430 || use_ordered_sections
431 && self
432 .has_canonical_builtin_section_kind(GuideSectionKind::CommonInvocationOptions))
433 {
434 row.insert(
435 "common_invocation_options".to_string(),
436 payload_entry_array(&self.common_invocation_options),
437 );
438 }
439 if !(self.notes.is_empty()
440 || use_ordered_sections
441 && self.has_canonical_builtin_section_kind(GuideSectionKind::Notes))
442 {
443 row.insert("notes".to_string(), string_array(&self.notes));
444 }
445 if !self.sections.is_empty() {
446 row.insert(
447 "sections".to_string(),
448 Value::Array(self.sections.iter().map(GuideSection::to_value).collect()),
449 );
450 }
451 if !self.epilogue.is_empty() {
452 row.insert("epilogue".to_string(), string_array(&self.epilogue));
453 }
454
455 row
456 }
457
458 fn try_from_row(row: &Map<String, Value>) -> Option<Self> {
459 let view = Self::normalize_restored_sections(Self {
460 preamble: row_string_array(row.get("preamble"))?,
461 usage: row_string_array(row.get("usage"))?,
462 commands: payload_entries(row.get("commands"))?,
463 arguments: payload_entries(row.get("arguments"))?,
464 options: payload_entries(row.get("options"))?,
465 common_invocation_options: payload_entries(row.get("common_invocation_options"))?,
466 notes: row_string_array(row.get("notes"))?,
467 sections: payload_sections(row.get("sections"))?,
468 epilogue: row_string_array(row.get("epilogue"))?,
469 });
470 view.is_semantically_valid().then_some(view)
471 }
472
473 fn normalize_restored_sections(mut view: Self) -> Self {
474 let use_ordered_sections = view.uses_ordered_section_representation();
487 let mut canonical_usage = Vec::new();
488 let mut canonical_commands = Vec::new();
489 let mut canonical_arguments = Vec::new();
490 let mut canonical_options = Vec::new();
491 let mut canonical_common_invocation_options = Vec::new();
492 let mut canonical_notes = Vec::new();
493
494 for section in &view.sections {
495 if !section.is_canonical_builtin_section() {
496 continue;
497 }
498
499 match section.kind {
500 GuideSectionKind::Usage => {
501 canonical_usage.extend(section.paragraphs.iter().cloned())
502 }
503 GuideSectionKind::Commands => {
504 canonical_commands.extend(section.entries.iter().cloned());
505 }
506 GuideSectionKind::Arguments => {
507 canonical_arguments.extend(section.entries.iter().cloned());
508 }
509 GuideSectionKind::Options => {
510 canonical_options.extend(section.entries.iter().cloned())
511 }
512 GuideSectionKind::CommonInvocationOptions => {
513 canonical_common_invocation_options.extend(section.entries.iter().cloned());
514 }
515 GuideSectionKind::Notes => {
516 canonical_notes.extend(section.paragraphs.iter().cloned())
517 }
518 GuideSectionKind::Custom => {}
519 }
520 }
521
522 if !use_ordered_sections {
523 if view.has_canonical_builtin_section_kind(GuideSectionKind::Usage)
524 || view.usage.is_empty() && !canonical_usage.is_empty()
525 {
526 view.usage = canonical_usage;
527 }
528
529 if view.has_canonical_builtin_section_kind(GuideSectionKind::Commands)
530 || view.commands.is_empty() && !canonical_commands.is_empty()
531 {
532 view.commands = canonical_commands;
533 }
534
535 if view.has_canonical_builtin_section_kind(GuideSectionKind::Arguments)
536 || view.arguments.is_empty() && !canonical_arguments.is_empty()
537 {
538 view.arguments = canonical_arguments;
539 }
540
541 if view.has_canonical_builtin_section_kind(GuideSectionKind::Options)
542 || view.options.is_empty() && !canonical_options.is_empty()
543 {
544 view.options = canonical_options;
545 }
546
547 if view.has_canonical_builtin_section_kind(GuideSectionKind::CommonInvocationOptions)
548 || view.common_invocation_options.is_empty()
549 && !canonical_common_invocation_options.is_empty()
550 {
551 view.common_invocation_options = canonical_common_invocation_options;
552 }
553
554 if view.has_canonical_builtin_section_kind(GuideSectionKind::Notes)
555 || view.notes.is_empty() && !canonical_notes.is_empty()
556 {
557 view.notes = canonical_notes;
558 }
559
560 view.sections
561 .retain(|section| !section.is_canonical_builtin_section());
562 } else {
563 if view.usage.is_empty() && !canonical_usage.is_empty() {
564 view.usage = canonical_usage;
565 }
566 if view.commands.is_empty() && !canonical_commands.is_empty() {
567 view.commands = canonical_commands;
568 }
569 if view.arguments.is_empty() && !canonical_arguments.is_empty() {
570 view.arguments = canonical_arguments;
571 }
572 if view.options.is_empty() && !canonical_options.is_empty() {
573 view.options = canonical_options;
574 }
575 if view.common_invocation_options.is_empty()
576 && !canonical_common_invocation_options.is_empty()
577 {
578 view.common_invocation_options = canonical_common_invocation_options;
579 }
580 if view.notes.is_empty() && !canonical_notes.is_empty() {
581 view.notes = canonical_notes;
582 }
583 }
584 view
585 }
586
587 pub(crate) fn has_canonical_builtin_section_kind(&self, kind: GuideSectionKind) -> bool {
588 self.sections
589 .iter()
590 .any(|section| section.kind == kind && section.is_canonical_builtin_section())
591 }
592
593 pub(crate) fn uses_ordered_section_representation(&self) -> bool {
594 self.sections
595 .iter()
596 .any(|section| !section.is_canonical_builtin_section())
597 }
598}
599
600impl GuideEntry {
601 fn is_semantically_valid(&self) -> bool {
602 !self.name.is_empty() || !self.short_help.is_empty()
603 }
604}
605
606impl GuideSection {
607 fn is_semantically_valid(&self) -> bool {
608 let has_data = !matches!(self.data, None | Some(Value::Null));
609 let has_content =
610 !self.title.is_empty() || !self.paragraphs.is_empty() || !self.entries.is_empty();
611 (has_content || has_data) && self.entries.iter().all(GuideEntry::is_semantically_valid)
612 }
613
614 pub(crate) fn is_canonical_builtin_section(&self) -> bool {
615 let expected = match self.kind {
616 GuideSectionKind::Usage => "Usage",
617 GuideSectionKind::Commands => "Commands",
618 GuideSectionKind::Arguments => "Arguments",
619 GuideSectionKind::Options => "Options",
620 GuideSectionKind::CommonInvocationOptions => "Common Invocation Options",
621 GuideSectionKind::Notes => "Notes",
622 GuideSectionKind::Custom => return false,
623 };
624
625 self.title.trim().eq_ignore_ascii_case(expected)
626 }
627}
628
629fn append_value_paragraphs(lines: &mut Vec<String>, paragraphs: &[String]) {
630 if paragraphs.is_empty() {
631 return;
632 }
633 if !lines.is_empty() {
634 lines.push(String::new());
635 }
636 lines.extend(paragraphs.iter().cloned());
637}
638
639fn append_value_entries(lines: &mut Vec<String>, entries: &[GuideEntry]) {
640 let values = entries
641 .iter()
642 .filter_map(value_line_for_entry)
643 .collect::<Vec<_>>();
644
645 if values.is_empty() {
646 return;
647 }
648 if !lines.is_empty() {
649 lines.push(String::new());
650 }
651 lines.extend(values);
652}
653
654fn append_value_data(lines: &mut Vec<String>, data: &Value) {
655 let values = data_value_lines(data);
656 if values.is_empty() {
657 return;
658 }
659 if !lines.is_empty() {
660 lines.push(String::new());
661 }
662 lines.extend(values);
663}
664
665fn data_value_lines(value: &Value) -> Vec<String> {
666 if let Some(entries) = payload_entry_array_as_entries(value) {
667 return entries.iter().filter_map(value_line_for_entry).collect();
668 }
669
670 match value {
671 Value::Null => Vec::new(),
672 Value::Array(items) => items.iter().flat_map(data_value_lines).collect(),
673 Value::Object(map) => map
674 .values()
675 .filter(|value| !value.is_null())
676 .map(guide_value_to_display)
677 .collect(),
678 scalar => vec![guide_value_to_display(scalar)],
679 }
680}
681
682fn payload_entry_array_as_entries(value: &Value) -> Option<Vec<GuideEntry>> {
683 let Value::Array(items) = value else {
684 return None;
685 };
686
687 items.iter().map(payload_entry_value_as_entry).collect()
688}
689
690fn payload_entry_value_as_entry(value: &Value) -> Option<GuideEntry> {
691 let Value::Object(map) = value else {
692 return None;
693 };
694 if map.keys().any(|key| key != "name" && key != "short_help") {
695 return None;
696 }
697
698 Some(GuideEntry {
699 name: map.get("name")?.as_str()?.to_string(),
700 short_help: map
701 .get("short_help")
702 .and_then(Value::as_str)
703 .unwrap_or_default()
704 .to_string(),
705 display_indent: None,
706 display_gap: None,
707 })
708}
709
710fn guide_value_to_display(value: &Value) -> String {
711 match value {
712 Value::Null => "null".to_string(),
713 Value::Bool(value) => value.to_string().to_ascii_lowercase(),
714 Value::Number(value) => value.to_string(),
715 Value::String(value) => value.clone(),
716 Value::Array(values) => values
717 .iter()
718 .map(guide_value_to_display)
719 .collect::<Vec<_>>()
720 .join(", "),
721 Value::Object(map) => {
722 if map.is_empty() {
723 return "{}".to_string();
724 }
725 let mut keys = map.keys().collect::<Vec<_>>();
726 keys.sort();
727 let preview = keys
728 .into_iter()
729 .take(3)
730 .cloned()
731 .collect::<Vec<_>>()
732 .join(", ");
733 if map.len() > 3 {
734 format!("{{{preview}, ...}}")
735 } else {
736 format!("{{{preview}}}")
737 }
738 }
739 }
740}
741
742fn value_line_for_entry(entry: &GuideEntry) -> Option<String> {
743 if !entry.short_help.trim().is_empty() {
744 return Some(entry.short_help.clone());
745 }
746 if !entry.name.trim().is_empty() {
747 return Some(entry.name.clone());
748 }
749 None
750}
751
752impl GuideSection {
753 fn to_value(&self) -> Value {
754 let mut section = Map::new();
755 section.insert("title".to_string(), Value::String(self.title.clone()));
756 section.insert(
757 "kind".to_string(),
758 Value::String(self.kind.as_str().to_string()),
759 );
760 section.insert("paragraphs".to_string(), string_array(&self.paragraphs));
761 section.insert(
762 "entries".to_string(),
763 Value::Array(
764 self.entries
765 .iter()
766 .map(payload_entry_value)
767 .collect::<Vec<_>>(),
768 ),
769 );
770 if let Some(data) = self.data.as_ref() {
771 section.insert("data".to_string(), data.clone());
772 }
773 Value::Object(section)
774 }
775}
776
777impl GuideSection {
778 pub fn new(title: impl Into<String>, kind: GuideSectionKind) -> Self {
780 Self {
781 title: title.into(),
782 kind,
783 paragraphs: Vec::new(),
784 entries: Vec::new(),
785 data: None,
786 }
787 }
788
789 pub fn paragraph(mut self, text: impl Into<String>) -> Self {
791 self.paragraphs.push(text.into());
792 self
793 }
794
795 pub fn data(mut self, value: Value) -> Self {
800 self.data = Some(value);
801 self
802 }
803
804 pub fn entry(mut self, name: impl Into<String>, short_help: impl Into<String>) -> Self {
806 self.entries.push(GuideEntry {
807 name: name.into(),
808 short_help: short_help.into(),
809 display_indent: None,
810 display_gap: None,
811 });
812 self
813 }
814}
815
816impl GuideSectionKind {
817 pub fn as_str(self) -> &'static str {
831 match self {
832 GuideSectionKind::Usage => "usage",
833 GuideSectionKind::Commands => "commands",
834 GuideSectionKind::Options => "options",
835 GuideSectionKind::Arguments => "arguments",
836 GuideSectionKind::CommonInvocationOptions => "common_invocation_options",
837 GuideSectionKind::Notes => "notes",
838 GuideSectionKind::Custom => "custom",
839 }
840 }
841
842 pub(crate) fn min_help_level(self) -> HelpLevel {
843 match self {
844 GuideSectionKind::Usage => HelpLevel::Tiny,
845 GuideSectionKind::CommonInvocationOptions => HelpLevel::Verbose,
846 GuideSectionKind::Commands
847 | GuideSectionKind::Options
848 | GuideSectionKind::Arguments
849 | GuideSectionKind::Notes
850 | GuideSectionKind::Custom => HelpLevel::Normal,
851 }
852 }
853}
854
855fn string_array(values: &[String]) -> Value {
856 Value::Array(
857 values
858 .iter()
859 .map(|value| Value::String(value.trim().to_string()))
860 .collect(),
861 )
862}
863
864fn row_string_array(value: Option<&Value>) -> Option<Vec<String>> {
865 let Some(value) = value else {
866 return Some(Vec::new());
867 };
868 let Value::Array(values) = value else {
869 return None;
870 };
871 values
872 .iter()
873 .map(|value| value.as_str().map(ToOwned::to_owned))
874 .collect()
875}
876
877fn payload_entry_value(entry: &GuideEntry) -> Value {
878 json!({
879 "name": entry.name,
880 "short_help": entry.short_help,
881 })
882}
883
884fn payload_entry_array(entries: &[GuideEntry]) -> Value {
885 Value::Array(entries.iter().map(payload_entry_value).collect())
886}
887
888fn payload_entries(value: Option<&Value>) -> Option<Vec<GuideEntry>> {
889 let Some(value) = value else {
890 return Some(Vec::new());
891 };
892 let Value::Array(entries) = value else {
893 return None;
894 };
895
896 let mut out = Vec::new();
897 for entry in entries {
898 let Value::Object(entry) = entry else {
899 return None;
900 };
901 let name = entry
902 .get("name")
903 .and_then(Value::as_str)
904 .unwrap_or_default()
905 .to_string();
906 let short_help = entry
907 .get("short_help")
908 .or_else(|| entry.get("summary"))
909 .and_then(Value::as_str)
910 .unwrap_or_default()
911 .to_string();
912 out.push(GuideEntry {
913 name,
914 short_help,
915 display_indent: None,
916 display_gap: None,
917 });
918 }
919 Some(out)
920}
921
922fn payload_sections(value: Option<&Value>) -> Option<Vec<GuideSection>> {
923 let Some(value) = value else {
924 return Some(Vec::new());
925 };
926 let Value::Array(sections) = value else {
927 return None;
928 };
929
930 let mut out = Vec::new();
931 for section in sections {
932 let Value::Object(section) = section else {
933 return None;
934 };
935 let title = section.get("title")?.as_str()?.to_string();
936 let kind = match section
937 .get("kind")
938 .and_then(Value::as_str)
939 .unwrap_or("custom")
940 {
941 "custom" => GuideSectionKind::Custom,
942 "notes" => GuideSectionKind::Notes,
943 "usage" => GuideSectionKind::Usage,
944 "commands" => GuideSectionKind::Commands,
945 "arguments" => GuideSectionKind::Arguments,
946 "options" => GuideSectionKind::Options,
947 "common_invocation_options" => GuideSectionKind::CommonInvocationOptions,
948 _ => return None,
949 };
950 out.push(GuideSection {
951 title,
952 kind,
953 paragraphs: row_string_array(section.get("paragraphs"))?,
954 entries: payload_entries(section.get("entries"))?,
955 data: section.get("data").cloned(),
956 });
957 }
958 Some(out)
959}
960
961fn guide_view_from_command_def(command: &CommandDef) -> GuideView {
962 let usage = command
963 .usage
964 .clone()
965 .or_else(|| default_usage(command))
966 .map(|usage| vec![usage])
967 .unwrap_or_default();
968
969 let visible_subcommands = command
970 .subcommands
971 .iter()
972 .filter(|subcommand| !subcommand.hidden)
973 .collect::<Vec<_>>();
974 let commands = visible_subcommands
975 .into_iter()
976 .map(|subcommand| GuideEntry {
977 name: subcommand.name.clone(),
978 short_help: subcommand.about.clone().unwrap_or_default(),
979 display_indent: None,
980 display_gap: None,
981 })
982 .collect();
983
984 let visible_args = command
985 .args
986 .iter()
987 .filter(|arg| !arg.id.is_empty())
988 .collect::<Vec<_>>();
989 let arguments = visible_args
990 .into_iter()
991 .map(|arg| GuideEntry {
992 name: arg_label(arg),
993 short_help: arg.help.clone().unwrap_or_default(),
994 display_indent: None,
995 display_gap: None,
996 })
997 .collect();
998
999 let visible_flags = command
1000 .flags
1001 .iter()
1002 .filter(|flag| !flag.hidden)
1003 .collect::<Vec<_>>();
1004 let options = visible_flags
1005 .into_iter()
1006 .map(|flag| GuideEntry {
1007 name: flag_label(flag),
1008 short_help: flag.help.clone().unwrap_or_default(),
1009 display_indent: Some(if flag.short.is_some() {
1010 " ".to_string()
1011 } else {
1012 " ".to_string()
1013 }),
1014 display_gap: None,
1015 })
1016 .collect();
1017
1018 let preamble = command
1019 .before_help
1020 .iter()
1021 .flat_map(|text| text.lines().map(ToString::to_string))
1022 .collect();
1023 let epilogue = command
1024 .after_help
1025 .iter()
1026 .flat_map(|text| text.lines().map(ToString::to_string))
1027 .collect();
1028
1029 GuideView {
1030 preamble,
1031 sections: Vec::new(),
1032 epilogue,
1033 usage,
1034 commands,
1035 arguments,
1036 options,
1037 common_invocation_options: Vec::new(),
1038 notes: Vec::new(),
1039 }
1040}
1041
1042fn default_usage(command: &CommandDef) -> Option<String> {
1043 if command.name.trim().is_empty() {
1044 return None;
1045 }
1046
1047 let mut parts = vec![command.name.clone()];
1048 if !command
1049 .flags
1050 .iter()
1051 .filter(|flag| !flag.hidden)
1052 .collect::<Vec<_>>()
1053 .is_empty()
1054 {
1055 parts.push("[OPTIONS]".to_string());
1056 }
1057 for arg in command.args.iter().filter(|arg| !arg.id.is_empty()) {
1058 let label = arg_label(arg);
1059 if arg.required {
1060 parts.push(label);
1061 } else {
1062 parts.push(format!("[{label}]"));
1063 }
1064 }
1065 if !command
1066 .subcommands
1067 .iter()
1068 .filter(|subcommand| !subcommand.hidden)
1069 .collect::<Vec<_>>()
1070 .is_empty()
1071 {
1072 parts.push("<COMMAND>".to_string());
1073 }
1074 Some(parts.join(" "))
1075}
1076
1077fn arg_label(arg: &ArgDef) -> String {
1078 arg.value_name.clone().unwrap_or_else(|| arg.id.clone())
1079}
1080
1081fn flag_label(flag: &FlagDef) -> String {
1082 let mut labels = Vec::new();
1083 if let Some(short) = flag.short {
1084 labels.push(format!("-{short}"));
1085 }
1086 if let Some(long) = flag.long.as_deref() {
1087 labels.push(format!("--{long}"));
1088 }
1089 if flag.takes_value
1090 && let Some(value_name) = flag.value_name.as_deref()
1091 {
1092 labels.push(format!("<{value_name}>"));
1093 }
1094 labels.join(", ")
1095}
1096
1097fn parse_help_view(help_text: &str) -> GuideView {
1098 let mut view = GuideView::default();
1099 let mut current: Option<GuideSection> = None;
1100 let mut saw_section = false;
1101
1102 for raw_line in help_text.lines() {
1103 let line = raw_line.trim_end();
1104 if let Some((title, kind, body)) = parse_section_header(line) {
1105 if let Some(section) = current.take() {
1106 view.sections.push(section);
1107 }
1108 saw_section = true;
1109 let mut section = GuideSection::new(title, kind);
1110 if let Some(body) = body {
1111 section.paragraphs.push(body);
1112 }
1113 current = Some(section);
1114 continue;
1115 }
1116
1117 if current
1118 .as_ref()
1119 .is_some_and(|section| line_belongs_to_epilogue(section.kind, line))
1120 {
1121 if let Some(section) = current.take() {
1122 view.sections.push(section);
1123 }
1124 view.epilogue.push(line.to_string());
1125 continue;
1126 }
1127
1128 if let Some(section) = current.as_mut() {
1129 parse_section_line(section, line);
1130 } else if !line.is_empty() {
1131 if saw_section {
1132 view.epilogue.push(line.to_string());
1133 } else {
1134 view.preamble.push(line.to_string());
1135 }
1136 }
1137 }
1138
1139 if let Some(section) = current {
1140 view.sections.push(section);
1141 }
1142
1143 repartition_builtin_sections(view)
1144}
1145
1146fn line_belongs_to_epilogue(kind: GuideSectionKind, line: &str) -> bool {
1147 if line.trim().is_empty() {
1148 return false;
1149 }
1150
1151 matches!(
1152 kind,
1153 GuideSectionKind::Commands | GuideSectionKind::Options | GuideSectionKind::Arguments
1154 ) && !line.starts_with(' ')
1155}
1156
1157fn parse_section_header(line: &str) -> Option<(String, GuideSectionKind, Option<String>)> {
1158 if let Some(usage) = line.strip_prefix("Usage:") {
1159 return Some((
1160 "Usage".to_string(),
1161 GuideSectionKind::Usage,
1162 Some(usage.trim().to_string()),
1163 ));
1164 }
1165
1166 let (title, kind) = match line {
1167 "Commands:" => ("Commands".to_string(), GuideSectionKind::Commands),
1168 "Options:" => ("Options".to_string(), GuideSectionKind::Options),
1169 "Arguments:" => ("Arguments".to_string(), GuideSectionKind::Arguments),
1170 "Common Invocation Options:" => (
1171 "Common Invocation Options".to_string(),
1172 GuideSectionKind::CommonInvocationOptions,
1173 ),
1174 "Notes:" => ("Notes".to_string(), GuideSectionKind::Notes),
1175 _ if !line.starts_with(' ') && line.ends_with(':') => (
1176 line.trim_end_matches(':').trim().to_string(),
1177 GuideSectionKind::Custom,
1178 ),
1179 _ => return None,
1180 };
1181
1182 Some((title, kind, None))
1183}
1184
1185fn parse_section_line(section: &mut GuideSection, line: &str) {
1186 if line.trim().is_empty() {
1187 return;
1188 }
1189
1190 if matches!(
1191 section.kind,
1192 GuideSectionKind::Commands
1193 | GuideSectionKind::Options
1194 | GuideSectionKind::Arguments
1195 | GuideSectionKind::CommonInvocationOptions
1196 ) {
1197 let indent_len = line.len().saturating_sub(line.trim_start().len());
1198 let (_, rest) = line.split_at(indent_len);
1199 let split = help_description_split(section.kind, rest).unwrap_or(rest.len());
1200 let (head, tail) = rest.split_at(split);
1201 let display_indent = Some(" ".repeat(indent_len));
1202 let display_gap = (!tail.is_empty()).then(|| {
1203 tail.chars()
1204 .take_while(|ch| ch.is_whitespace())
1205 .collect::<String>()
1206 });
1207 section.entries.push(GuideEntry {
1208 name: head.trim().to_string(),
1209 short_help: tail.trim().to_string(),
1210 display_indent,
1211 display_gap,
1212 });
1213 return;
1214 }
1215
1216 section.paragraphs.push(line.to_string());
1217}
1218
1219fn repartition_builtin_sections(mut view: GuideView) -> GuideView {
1220 let sections = std::mem::take(&mut view.sections);
1221 for section in sections {
1222 match section.kind {
1223 GuideSectionKind::Usage => view.usage.extend(section.paragraphs),
1224 GuideSectionKind::Commands => view.commands.extend(section.entries),
1225 GuideSectionKind::Arguments => view.arguments.extend(section.entries),
1226 GuideSectionKind::Options => view.options.extend(section.entries),
1227 GuideSectionKind::CommonInvocationOptions => {
1228 view.common_invocation_options.extend(section.entries);
1229 }
1230 GuideSectionKind::Notes => view.notes.extend(section.paragraphs),
1231 GuideSectionKind::Custom => view.sections.push(section),
1232 }
1233 }
1234 view
1235}
1236
1237fn help_description_split(kind: GuideSectionKind, line: &str) -> Option<usize> {
1238 let mut saw_non_whitespace = false;
1239 let mut run_start = None;
1240 let mut run_len = 0usize;
1241
1242 for (idx, ch) in line.char_indices() {
1243 if ch.is_whitespace() {
1244 if saw_non_whitespace {
1245 run_start.get_or_insert(idx);
1246 run_len += 1;
1247 }
1248 continue;
1249 }
1250
1251 if saw_non_whitespace && run_len >= 2 {
1252 return run_start;
1253 }
1254
1255 saw_non_whitespace = true;
1256 run_start = None;
1257 run_len = 0;
1258 }
1259
1260 if matches!(
1261 kind,
1262 GuideSectionKind::Commands | GuideSectionKind::Arguments
1263 ) {
1264 return line.find(char::is_whitespace);
1265 }
1266
1267 None
1268}
1269
1270#[cfg(test)]
1271mod tests;