Skip to main content

osp_cli/guide/
mod.rs

1//! Structured help and guide payload model.
2//!
3//! This module exists so help, intro, and command-reference content can travel
4//! through the app as semantic data instead of ad hoc rendered strings.
5//!
6//! High level flow:
7//!
8//! - collect guide content from command definitions or parsed help text
9//! - keep it in [`crate::guide::GuideView`] form while other systems inspect,
10//!   filter, or render it
11//! - lower it later into rows, documents, or markdown as needed
12//!
13//! Contract:
14//!
15//! - guide data should stay semantic here
16//! - presentation-specific layout belongs in the UI layer
17//!
18//! Public API shape:
19//!
20//! - [`crate::guide::GuideView`] and related section/entry types stay
21//!   intentionally direct to compose because they are semantic payloads
22//! - common generation paths use factories like
23//!   [`crate::guide::GuideView::from_text`] and
24//!   [`crate::guide::GuideView::from_command_def`]
25//! - rendering/layout policy stays outside this module so the guide model
26//!   remains reusable
27
28pub(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/// Structured help/guide payload shared by the CLI, REPL, renderers, and
40/// semantic output pipeline.
41///
42/// Canonical help sections such as usage, commands, and options are exposed as
43/// dedicated buckets for ergonomic access. The generic [`GuideView::sections`]
44/// list exists to preserve authored section order during serialization and
45/// transforms, including canonical sections that were authored inline with
46/// custom content. Restore logic may backfill the dedicated buckets from those
47/// canonical sections, but renderers and serializers treat the ordered section
48/// list as authoritative whenever it already carries that content.
49///
50/// Public API note: this is intentionally an open semantic DTO. Callers may
51/// compose it directly for bespoke help payloads, while common generation paths
52/// are exposed as factory methods.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
54#[serde(default)]
55pub struct GuideView {
56    /// Introductory paragraphs shown before structured sections.
57    pub preamble: Vec<String>,
58    /// Extra sections preserved outside the canonical buckets.
59    pub sections: Vec<GuideSection>,
60    /// Closing paragraphs shown after structured sections.
61    pub epilogue: Vec<String>,
62    /// Canonical usage synopsis lines.
63    pub usage: Vec<String>,
64    /// Canonical command-entry bucket.
65    pub commands: Vec<GuideEntry>,
66    /// Canonical positional-argument bucket.
67    pub arguments: Vec<GuideEntry>,
68    /// Canonical option/flag bucket.
69    pub options: Vec<GuideEntry>,
70    /// Canonical shared invocation-option bucket.
71    pub common_invocation_options: Vec<GuideEntry>,
72    /// Canonical note paragraphs.
73    pub notes: Vec<String>,
74}
75
76/// One named row within a guide section or canonical bucket.
77///
78/// The serialized form intentionally keeps only semantic content. Display-only
79/// spacing overrides are carried separately so renderers can adjust layout
80/// without affecting the semantic payload used by DSL, cache, or export flows.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
82#[serde(default)]
83pub struct GuideEntry {
84    /// Stable label for the entry.
85    pub name: String,
86    /// Short explanatory text paired with the label.
87    pub short_help: String,
88    /// Presentation-only indentation override.
89    #[serde(skip)]
90    pub display_indent: Option<String>,
91    /// Presentation-only spacing override between label and description.
92    #[serde(skip)]
93    pub display_gap: Option<String>,
94}
95
96/// One logical section within a [`GuideView`].
97///
98/// Custom sections live here directly. Canonical sections may also be
99/// represented here when authored inline with custom content. Restore logic may
100/// mirror canonical sections into the dedicated [`GuideView`] buckets for
101/// ergonomic access, but it should not reorder or delete the authored section
102/// list.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
104#[serde(default)]
105pub struct GuideSection {
106    /// User-facing section heading.
107    pub title: String,
108    /// Semantic kind used for normalization and rendering policy.
109    pub kind: GuideSectionKind,
110    /// Paragraph content rendered before any entries.
111    pub paragraphs: Vec<String>,
112    /// Structured rows rendered within the section.
113    pub entries: Vec<GuideEntry>,
114    /// Arbitrary semantic data rendered through the normal value/document path.
115    ///
116    /// Markdown template imports use this for fenced `osp` blocks so authors
117    /// can embed structured data without forcing the final output to be literal
118    /// source JSON.
119    pub data: Option<Value>,
120}
121
122/// Canonical section kinds used by structured help output.
123#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum GuideSectionKind {
126    /// Usage synopsis content.
127    Usage,
128    /// Command listing content.
129    Commands,
130    /// Option and flag content.
131    Options,
132    /// Positional argument content.
133    Arguments,
134    /// Shared invocation option content.
135    CommonInvocationOptions,
136    /// Free-form note content.
137    Notes,
138    /// Any section outside the built-in guide categories.
139    #[default]
140    Custom,
141}
142
143impl GuideView {
144    /// Parses plain help text into a structured guide view.
145    pub fn from_text(help_text: &str) -> Self {
146        parse_help_view(help_text)
147    }
148
149    /// Builds a guide view from a command definition.
150    ///
151    /// # Examples
152    ///
153    /// ```
154    /// use osp_cli::core::command_def::CommandDef;
155    /// use osp_cli::guide::GuideView;
156    ///
157    /// let command = CommandDef::new("theme")
158    ///     .about("Inspect themes")
159    ///     .subcommand(CommandDef::new("show").about("Show available themes"));
160    /// let guide = GuideView::from_command_def(&command);
161    ///
162    /// assert_eq!(guide.usage, vec!["theme <COMMAND>".to_string()]);
163    /// assert_eq!(guide.commands[0].name, "show");
164    /// ```
165    pub fn from_command_def(command: &CommandDef) -> Self {
166        guide_view_from_command_def(command)
167    }
168
169    /// Converts the guide into row output with a guide sidecar document.
170    ///
171    /// # Examples
172    ///
173    /// ```
174    /// use osp_cli::core::output_model::OutputDocumentKind;
175    /// use osp_cli::guide::GuideView;
176    ///
177    /// let guide = GuideView {
178    ///     usage: vec!["theme show".to_string()],
179    ///     ..GuideView::default()
180    /// };
181    /// let output = guide.to_output_result();
182    ///
183    /// assert_eq!(output.document.as_ref().map(|doc| doc.kind), Some(OutputDocumentKind::Guide));
184    /// assert_eq!(output.meta.render_recommendation.is_some(), true);
185    /// ```
186    pub fn to_output_result(&self) -> OutputResult {
187        // Keep the semantic row form for DSL/history/cache, but attach the
188        // first-class guide payload so renderers do not have to reconstruct it
189        // from rows when no structural stages have destroyed that intent.
190        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    /// Serializes the guide to its JSON object form.
198    pub fn to_json_value(&self) -> Value {
199        Value::Object(self.to_row())
200    }
201
202    /// Attempts to recover a guide view from structured output.
203    ///
204    /// A carried semantic document is authoritative. When `output.document` is
205    /// present, this function only attempts to restore from that document and
206    /// does not silently fall back to the row projection.
207    pub fn try_from_output_result(output: &OutputResult) -> Option<Self> {
208        // A carried semantic document is authoritative. If the canonical JSON
209        // no longer restores as a guide after DSL, do not silently guess from
210        // the row projection and pretend the payload is still semantic guide
211        // content.
212        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    /// Renders the guide as Markdown using the default width policy.
224    ///
225    /// # Examples
226    ///
227    /// ```
228    /// use osp_cli::guide::GuideView;
229    ///
230    /// let guide = GuideView {
231    ///     usage: vec!["theme show".to_string()],
232    ///     ..GuideView::default()
233    /// };
234    ///
235    /// assert!(guide.to_markdown().contains("theme show"));
236    /// ```
237    pub fn to_markdown(&self) -> String {
238        self.to_markdown_with_width(None)
239    }
240
241    /// Renders the guide as Markdown using an optional target width.
242    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    /// Flattens the guide into value-oriented text lines.
247    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, &section.paragraphs);
290            append_value_entries(&mut lines, &section.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    /// Appends another guide view into this one, preserving section order.
302    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        // There are two valid guide representations:
475        //
476        // - ordered sections are authoritative when custom/non-canonical
477        //   sections are interleaved with builtin ones (for example intro
478        //   payloads)
479        // - canonical buckets are authoritative when the payload only carries
480        //   builtin guide sections and those sections are merely structural
481        //   carriers for DSL addressing
482        //
483        // Restore must preserve authored mixed/custom section order, but it may
484        // collapse canonical-only section lists back into the dedicated buckets
485        // so ordinary help payloads keep their stable semantic shape.
486        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    /// Creates a new guide section with a title and canonical kind.
779    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    /// Appends a paragraph to the section.
790    pub fn paragraph(mut self, text: impl Into<String>) -> Self {
791        self.paragraphs.push(text.into());
792        self
793    }
794
795    /// Attaches semantic data to the section.
796    ///
797    /// Renderers may choose the best presentation for this payload instead of
798    /// showing the authoring JSON literally.
799    pub fn data(mut self, value: Value) -> Self {
800        self.data = Some(value);
801        self
802    }
803
804    /// Appends a named entry row to the section.
805    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    /// Returns the stable string form used in serialized guide payloads.
818    ///
819    /// # Examples
820    ///
821    /// ```
822    /// use osp_cli::guide::GuideSectionKind;
823    ///
824    /// assert_eq!(GuideSectionKind::Commands.as_str(), "commands");
825    /// assert_eq!(
826    ///     GuideSectionKind::CommonInvocationOptions.as_str(),
827    ///     "common_invocation_options"
828    /// );
829    /// ```
830    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;