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    ///
146    /// # Examples
147    ///
148    /// ```
149    /// use osp_cli::guide::GuideView;
150    ///
151    /// let guide = GuideView::from_text("Usage: osp theme <COMMAND>\n\nCommands:\n  list  Show\n");
152    ///
153    /// assert_eq!(guide.usage, vec!["osp theme <COMMAND>".to_string()]);
154    /// assert_eq!(guide.commands[0].name, "list");
155    /// assert_eq!(guide.commands[0].short_help, "Show");
156    /// ```
157    pub fn from_text(help_text: &str) -> Self {
158        parse_help_view(help_text)
159    }
160
161    /// Builds a guide view from a command definition.
162    ///
163    /// # Examples
164    ///
165    /// ```
166    /// use osp_cli::core::command_def::CommandDef;
167    /// use osp_cli::guide::GuideView;
168    ///
169    /// let command = CommandDef::new("theme")
170    ///     .about("Inspect themes")
171    ///     .subcommand(CommandDef::new("show").about("Show available themes"));
172    /// let guide = GuideView::from_command_def(&command);
173    ///
174    /// assert_eq!(guide.usage, vec!["theme <COMMAND>".to_string()]);
175    /// assert_eq!(guide.commands[0].name, "show");
176    /// assert!(guide.arguments.is_empty());
177    /// assert!(guide.options.is_empty());
178    /// ```
179    pub fn from_command_def(command: &CommandDef) -> Self {
180        guide_view_from_command_def(command)
181    }
182
183    /// Converts the guide into row output with a guide sidecar document.
184    ///
185    /// # Examples
186    ///
187    /// ```
188    /// use osp_cli::core::output_model::OutputDocumentKind;
189    /// use osp_cli::guide::GuideView;
190    ///
191    /// let guide = GuideView {
192    ///     usage: vec!["theme show".to_string()],
193    ///     ..GuideView::default()
194    /// };
195    /// let output = guide.to_output_result();
196    ///
197    /// assert_eq!(output.document.as_ref().map(|doc| doc.kind), Some(OutputDocumentKind::Guide));
198    /// assert_eq!(output.meta.render_recommendation.is_some(), true);
199    /// let rows = output.as_rows().expect("guide output should keep row projection");
200    /// assert_eq!(rows.len(), 1);
201    /// assert_eq!(rows[0]["usage"][0], "theme show");
202    /// ```
203    pub fn to_output_result(&self) -> OutputResult {
204        // Keep the semantic row form for DSL/history/cache, but attach the
205        // first-class guide payload so renderers do not have to reconstruct it
206        // from rows when no structural stages have destroyed that intent.
207        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    /// Serializes the guide to its JSON object form.
215    ///
216    /// # Examples
217    ///
218    /// ```
219    /// use osp_cli::guide::GuideView;
220    ///
221    /// let guide = GuideView::from_text("Usage: osp history <COMMAND>\n\nCommands:\n  list\n");
222    /// let value = guide.to_json_value();
223    ///
224    /// assert_eq!(value["usage"][0], "osp history <COMMAND>");
225    /// assert_eq!(value["commands"][0]["name"], "list");
226    /// assert!(value.get("sections").is_none());
227    /// ```
228    pub fn to_json_value(&self) -> Value {
229        Value::Object(self.to_row())
230    }
231
232    /// Attempts to recover a guide view from structured output.
233    ///
234    /// A carried semantic document is authoritative. When `output.document` is
235    /// present, this function only attempts to restore from that document and
236    /// does not silently fall back to the row projection.
237    pub fn try_from_output_result(output: &OutputResult) -> Option<Self> {
238        // A carried semantic document is authoritative. If the canonical JSON
239        // no longer restores as a guide after DSL, do not silently guess from
240        // the row projection and pretend the payload is still semantic guide
241        // content.
242        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    /// Renders the guide as Markdown using the default width policy.
254    ///
255    /// # Examples
256    ///
257    /// ```
258    /// use osp_cli::guide::GuideView;
259    ///
260    /// let guide = GuideView {
261    ///     usage: vec!["theme show".to_string()],
262    ///     commands: vec![osp_cli::guide::GuideEntry {
263    ///         name: "list".to_string(),
264    ///         short_help: "List themes".to_string(),
265    ///         display_indent: None,
266    ///         display_gap: None,
267    ///     }],
268    ///     ..GuideView::default()
269    /// };
270    ///
271    /// let markdown = guide.to_markdown();
272    ///
273    /// assert!(markdown.contains("## Usage"));
274    /// assert!(markdown.contains("theme show"));
275    /// assert!(markdown.contains("- `list` List themes"));
276    /// ```
277    pub fn to_markdown(&self) -> String {
278        self.to_markdown_with_width(None)
279    }
280
281    /// Renders the guide as Markdown using an optional target width.
282    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    /// Flattens the guide into value-oriented text lines.
287    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, &section.paragraphs);
330            append_value_entries(&mut lines, &section.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    /// Appends another guide view into this one, preserving section order.
342    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        // There are two valid guide representations:
515        //
516        // - ordered sections are authoritative when custom/non-canonical
517        //   sections are interleaved with builtin ones (for example intro
518        //   payloads)
519        // - canonical buckets are authoritative when the payload only carries
520        //   builtin guide sections and those sections are merely structural
521        //   carriers for DSL addressing
522        //
523        // Restore must preserve authored mixed/custom section order, but it may
524        // collapse canonical-only section lists back into the dedicated buckets
525        // so ordinary help payloads keep their stable semantic shape.
526        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        // Canonical sections normally mirror the dedicated top-level buckets.
649        // If the bucket is empty but the section carries content, the section
650        // list is the authoritative authored shape and later lowering must keep
651        // that order instead of discarding the canonical section as a duplicate.
652        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    /// Creates a new guide section with a title and canonical kind.
843    ///
844    /// # Examples
845    ///
846    /// ```
847    /// use osp_cli::guide::{GuideSection, GuideSectionKind};
848    ///
849    /// let section = GuideSection::new("Notes", GuideSectionKind::Notes)
850    ///     .paragraph("first")
851    ///     .entry("show", "Display");
852    ///
853    /// assert_eq!(section.paragraphs, vec!["first".to_string()]);
854    /// assert_eq!(section.entries[0].name, "show");
855    /// assert_eq!(section.entries[0].short_help, "Display");
856    /// ```
857    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    /// Appends a paragraph to the section.
868    pub fn paragraph(mut self, text: impl Into<String>) -> Self {
869        self.paragraphs.push(text.into());
870        self
871    }
872
873    /// Attaches semantic data to the section.
874    ///
875    /// Renderers may choose the best presentation for this payload instead of
876    /// showing the authoring JSON literally.
877    pub fn data(mut self, value: Value) -> Self {
878        self.data = Some(value);
879        self
880    }
881
882    /// Appends a named entry row to the section.
883    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    /// Returns the stable string form used in serialized guide payloads.
896    ///
897    /// # Examples
898    ///
899    /// ```
900    /// use osp_cli::guide::GuideSectionKind;
901    ///
902    /// assert_eq!(GuideSectionKind::Commands.as_str(), "commands");
903    /// assert_eq!(
904    ///     GuideSectionKind::CommonInvocationOptions.as_str(),
905    ///     "common_invocation_options"
906    /// );
907    /// ```
908    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;