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 serde::{Deserialize, Serialize};
35use serde_json::{Map, Value, json};
36
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
38pub(crate) enum HelpLevel {
39    None,
40    Tiny,
41    #[default]
42    Normal,
43    Verbose,
44}
45
46impl HelpLevel {
47    pub(crate) fn parse(value: &str) -> Option<Self> {
48        match value.trim().to_ascii_lowercase().as_str() {
49            "none" | "off" => Some(Self::None),
50            "tiny" => Some(Self::Tiny),
51            "normal" => Some(Self::Normal),
52            "verbose" => Some(Self::Verbose),
53            _ => None,
54        }
55    }
56}
57
58/// Structured help/guide payload shared by the CLI, REPL, renderers, and
59/// semantic output pipeline.
60///
61/// Canonical help sections such as usage, commands, and options are exposed as
62/// dedicated buckets for ergonomic access. The generic [`GuideView::sections`]
63/// list exists to preserve authored section order during serialization and
64/// transforms, including canonical sections that were authored inline with
65/// custom content. Restore logic may backfill the dedicated buckets from those
66/// canonical sections, but renderers and serializers treat the ordered section
67/// list as authoritative whenever it already carries that content.
68///
69/// Public API note: this is intentionally an open semantic DTO. Callers may
70/// compose it directly for bespoke help payloads, while common generation paths
71/// are exposed as factory methods.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
73#[serde(default)]
74pub struct GuideView {
75    /// Introductory paragraphs shown before structured sections.
76    pub preamble: Vec<String>,
77    /// Extra sections preserved outside the canonical buckets.
78    pub sections: Vec<GuideSection>,
79    /// Closing paragraphs shown after structured sections.
80    pub epilogue: Vec<String>,
81    /// Canonical usage synopsis lines.
82    pub usage: Vec<String>,
83    /// Canonical command-entry bucket.
84    pub commands: Vec<GuideEntry>,
85    /// Canonical positional-argument bucket.
86    pub arguments: Vec<GuideEntry>,
87    /// Canonical option/flag bucket.
88    pub options: Vec<GuideEntry>,
89    /// Canonical shared invocation-option bucket.
90    pub common_invocation_options: Vec<GuideEntry>,
91    /// Canonical note paragraphs.
92    pub notes: Vec<String>,
93}
94
95/// One named row within a guide section or canonical bucket.
96///
97/// The serialized form intentionally keeps only semantic content. Display-only
98/// spacing overrides are carried separately so renderers can adjust layout
99/// without affecting the semantic payload used by DSL, cache, or export flows.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
101#[serde(default)]
102pub struct GuideEntry {
103    /// Stable label for the entry.
104    pub name: String,
105    /// Short explanatory text paired with the label.
106    pub short_help: String,
107    /// Presentation-only indentation override.
108    #[serde(skip)]
109    pub display_indent: Option<String>,
110    /// Presentation-only spacing override between label and description.
111    #[serde(skip)]
112    pub display_gap: Option<String>,
113}
114
115/// One logical section within a [`GuideView`].
116///
117/// Custom sections live here directly. Canonical sections may also be
118/// represented here when authored inline with custom content. Restore logic may
119/// mirror canonical sections into the dedicated [`GuideView`] buckets for
120/// ergonomic access, but it should not reorder or delete the authored section
121/// list.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
123#[serde(default)]
124pub struct GuideSection {
125    /// User-facing section heading.
126    pub title: String,
127    /// Semantic kind used for normalization and rendering policy.
128    pub kind: GuideSectionKind,
129    /// Paragraph content rendered before any entries.
130    pub paragraphs: Vec<String>,
131    /// Structured rows rendered within the section.
132    pub entries: Vec<GuideEntry>,
133    /// Arbitrary semantic data rendered through the normal value/document path.
134    ///
135    /// Markdown template imports use this for fenced `osp` blocks so authors
136    /// can embed structured data without forcing the final output to be literal
137    /// source JSON.
138    pub data: Option<Value>,
139}
140
141/// Canonical section kinds used by structured help output.
142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "snake_case")]
144pub enum GuideSectionKind {
145    /// Usage synopsis content.
146    Usage,
147    /// Command listing content.
148    Commands,
149    /// Option and flag content.
150    Options,
151    /// Positional argument content.
152    Arguments,
153    /// Shared invocation option content.
154    CommonInvocationOptions,
155    /// Free-form note content.
156    Notes,
157    /// Any section outside the built-in guide categories.
158    #[default]
159    Custom,
160}
161
162impl GuideView {
163    /// Parses plain help text into a structured guide view.
164    ///
165    /// # Examples
166    ///
167    /// ```
168    /// use osp_cli::guide::GuideView;
169    ///
170    /// let guide = GuideView::from_text("Usage: osp theme <COMMAND>\n\nCommands:\n  list  Show\n");
171    ///
172    /// assert_eq!(guide.usage, vec!["osp theme <COMMAND>".to_string()]);
173    /// assert_eq!(guide.commands[0].name, "list");
174    /// assert_eq!(guide.commands[0].short_help, "Show");
175    /// ```
176    pub fn from_text(help_text: &str) -> Self {
177        parse_help_view(help_text)
178    }
179
180    /// Builds a guide view from a command definition.
181    ///
182    /// # Examples
183    ///
184    /// ```
185    /// use osp_cli::core::command_def::CommandDef;
186    /// use osp_cli::guide::GuideView;
187    ///
188    /// let command = CommandDef::new("theme")
189    ///     .about("Inspect themes")
190    ///     .subcommand(CommandDef::new("show").about("Show available themes"));
191    /// let guide = GuideView::from_command_def(&command);
192    ///
193    /// assert_eq!(guide.usage, vec!["theme <COMMAND>".to_string()]);
194    /// assert_eq!(guide.commands[0].name, "show");
195    /// assert!(guide.arguments.is_empty());
196    /// assert!(guide.options.is_empty());
197    /// ```
198    pub fn from_command_def(command: &CommandDef) -> Self {
199        guide_view_from_command_def(command)
200    }
201
202    /// Converts the guide into row output with a guide sidecar document.
203    ///
204    /// # Examples
205    ///
206    /// ```
207    /// use osp_cli::core::output_model::OutputDocumentKind;
208    /// use osp_cli::guide::GuideView;
209    ///
210    /// let guide = GuideView {
211    ///     usage: vec!["theme show".to_string()],
212    ///     ..GuideView::default()
213    /// };
214    /// let output = guide.to_output_result();
215    ///
216    /// assert_eq!(output.document.as_ref().map(|doc| doc.kind), Some(OutputDocumentKind::Guide));
217    /// assert_eq!(output.meta.render_recommendation.is_some(), true);
218    /// let rows = output.as_rows().expect("guide output should keep row projection");
219    /// assert_eq!(rows.len(), 1);
220    /// assert_eq!(rows[0]["usage"][0], "theme show");
221    /// ```
222    pub fn to_output_result(&self) -> OutputResult {
223        // Keep the semantic row form for DSL/history/cache, but attach the
224        // first-class guide payload so renderers do not have to reconstruct it
225        // from rows when no structural stages have destroyed that intent.
226        let mut output = OutputResult::from_rows(vec![self.to_row()]).with_document(
227            OutputDocument::new(OutputDocumentKind::Guide, self.to_json_value()),
228        );
229        output.meta.render_recommendation = Some(RenderRecommendation::Guide);
230        output
231    }
232
233    /// Serializes the guide to its JSON object form.
234    ///
235    /// # Examples
236    ///
237    /// ```
238    /// use osp_cli::guide::GuideView;
239    ///
240    /// let guide = GuideView::from_text("Usage: osp history <COMMAND>\n\nCommands:\n  list\n");
241    /// let value = guide.to_json_value();
242    ///
243    /// assert_eq!(value["usage"][0], "osp history <COMMAND>");
244    /// assert_eq!(value["commands"][0]["name"], "list");
245    /// assert!(value.get("sections").is_none());
246    /// ```
247    pub fn to_json_value(&self) -> Value {
248        Value::Object(self.to_row())
249    }
250
251    /// Attempts to recover a guide view from structured output.
252    ///
253    /// A carried semantic document is authoritative. When `output.document` is
254    /// present, this function only attempts to restore from that document and
255    /// does not silently fall back to the row projection.
256    pub fn try_from_output_result(output: &OutputResult) -> Option<Self> {
257        // A carried semantic document is authoritative. If the canonical JSON
258        // no longer restores as a guide after DSL, do not silently guess from
259        // the row projection and pretend the payload is still semantic guide
260        // content.
261        if let Some(document) = output.document.as_ref() {
262            return Self::try_from_output_document(document);
263        }
264
265        let rows = match &output.items {
266            OutputItems::Rows(rows) if rows.len() == 1 => rows,
267            _ => return None,
268        };
269        Self::try_from_row(&rows[0])
270    }
271
272    /// Attempts to recover a guide from the row projection even when a carried
273    /// semantic document is no longer restorable after DSL narrowing.
274    pub(crate) fn try_from_row_projection(output: &OutputResult) -> Option<Self> {
275        let rows = match &output.items {
276            OutputItems::Rows(rows) if rows.len() == 1 => rows,
277            _ => return None,
278        };
279        Self::try_from_row(&rows[0])
280    }
281
282    /// Renders the guide as Markdown using the default width policy.
283    ///
284    /// # Examples
285    ///
286    /// ```
287    /// use osp_cli::guide::GuideView;
288    ///
289    /// let guide = GuideView {
290    ///     usage: vec!["theme show".to_string()],
291    ///     commands: vec![osp_cli::guide::GuideEntry {
292    ///         name: "list".to_string(),
293    ///         short_help: "List themes".to_string(),
294    ///         display_indent: None,
295    ///         display_gap: None,
296    ///     }],
297    ///     ..GuideView::default()
298    /// };
299    ///
300    /// let markdown = guide.to_markdown();
301    ///
302    /// assert!(markdown.contains("## Usage"));
303    /// assert!(markdown.contains("theme show"));
304    /// assert!(markdown.contains("- `list` List themes"));
305    /// ```
306    pub fn to_markdown(&self) -> String {
307        self.to_markdown_with_width(None)
308    }
309
310    /// Renders the guide as Markdown using an optional target width.
311    pub fn to_markdown_with_width(&self, width: Option<usize>) -> String {
312        let mut settings = crate::ui::RenderSettings {
313            format: crate::core::output::OutputFormat::Markdown,
314            format_explicit: true,
315            ..crate::ui::RenderSettings::default()
316        };
317        settings.width = width;
318        crate::ui::render_structured_output_with_source_guide(
319            &self.to_output_result(),
320            Some(self),
321            &settings,
322            crate::ui::HelpLayout::Full,
323        )
324    }
325
326    /// Flattens the guide into value-oriented text lines.
327    pub fn to_value_lines(&self) -> Vec<String> {
328        let normalized = Self::normalize_restored_sections(self.clone());
329        let mut lines = Vec::new();
330        let use_ordered_sections = normalized.uses_ordered_section_representation();
331
332        append_value_paragraphs(&mut lines, &normalized.preamble);
333        if !(use_ordered_sections
334            && normalized.has_canonical_builtin_section_kind(GuideSectionKind::Usage))
335        {
336            append_value_paragraphs(&mut lines, &normalized.usage);
337        }
338        if !(use_ordered_sections
339            && normalized.has_canonical_builtin_section_kind(GuideSectionKind::Commands))
340        {
341            append_value_entries(&mut lines, &normalized.commands);
342        }
343        if !(use_ordered_sections
344            && normalized.has_canonical_builtin_section_kind(GuideSectionKind::Arguments))
345        {
346            append_value_entries(&mut lines, &normalized.arguments);
347        }
348        if !(use_ordered_sections
349            && normalized.has_canonical_builtin_section_kind(GuideSectionKind::Options))
350        {
351            append_value_entries(&mut lines, &normalized.options);
352        }
353        if !(use_ordered_sections
354            && normalized
355                .has_canonical_builtin_section_kind(GuideSectionKind::CommonInvocationOptions))
356        {
357            append_value_entries(&mut lines, &normalized.common_invocation_options);
358        }
359        if !(use_ordered_sections
360            && normalized.has_canonical_builtin_section_kind(GuideSectionKind::Notes))
361        {
362            append_value_paragraphs(&mut lines, &normalized.notes);
363        }
364
365        for section in &normalized.sections {
366            if !use_ordered_sections && section.is_canonical_builtin_section() {
367                continue;
368            }
369            append_value_paragraphs(&mut lines, &section.paragraphs);
370            append_value_entries(&mut lines, &section.entries);
371            if let Some(data) = section.data.as_ref() {
372                append_value_data(&mut lines, data);
373            }
374        }
375
376        append_value_paragraphs(&mut lines, &normalized.epilogue);
377
378        lines
379    }
380
381    /// Appends another guide view into this one, preserving section order.
382    pub fn merge(&mut self, mut other: GuideView) {
383        self.preamble.append(&mut other.preamble);
384        self.usage.append(&mut other.usage);
385        self.commands.append(&mut other.commands);
386        self.arguments.append(&mut other.arguments);
387        self.options.append(&mut other.options);
388        self.common_invocation_options
389            .append(&mut other.common_invocation_options);
390        self.notes.append(&mut other.notes);
391        self.sections.append(&mut other.sections);
392        self.epilogue.append(&mut other.epilogue);
393    }
394
395    pub(crate) fn filtered_for_help_level(&self, level: HelpLevel) -> Self {
396        let mut filtered = self.clone();
397        filtered.usage = if level >= HelpLevel::Tiny {
398            self.usage.clone()
399        } else {
400            Vec::new()
401        };
402        filtered.commands = if level >= HelpLevel::Normal {
403            self.commands.clone()
404        } else {
405            Vec::new()
406        };
407        filtered.arguments = if level >= HelpLevel::Normal {
408            self.arguments.clone()
409        } else {
410            Vec::new()
411        };
412        filtered.options = if level >= HelpLevel::Normal {
413            self.options.clone()
414        } else {
415            Vec::new()
416        };
417        filtered.common_invocation_options = if level >= HelpLevel::Verbose {
418            self.common_invocation_options.clone()
419        } else {
420            Vec::new()
421        };
422        filtered.notes = if level >= HelpLevel::Normal {
423            self.notes.clone()
424        } else {
425            Vec::new()
426        };
427        filtered.sections = self
428            .sections
429            .iter()
430            .filter(|section| level >= section.kind.min_help_level())
431            .cloned()
432            .collect();
433        filtered
434    }
435}
436
437impl GuideView {
438    fn try_from_output_document(document: &OutputDocument) -> Option<Self> {
439        match document.kind {
440            OutputDocumentKind::Guide => {
441                let view = Self::normalize_restored_sections(
442                    serde_json::from_value(document.value.clone()).ok()?,
443                );
444                view.is_semantically_valid().then_some(view)
445            }
446        }
447    }
448
449    fn is_semantically_valid(&self) -> bool {
450        let entries_are_valid =
451            |entries: &[GuideEntry]| entries.iter().all(GuideEntry::is_semantically_valid);
452        let sections_are_valid = self
453            .sections
454            .iter()
455            .all(GuideSection::is_semantically_valid);
456        let has_content = !self.preamble.is_empty()
457            || !self.epilogue.is_empty()
458            || !self.usage.is_empty()
459            || !self.notes.is_empty()
460            || !self.commands.is_empty()
461            || !self.arguments.is_empty()
462            || !self.options.is_empty()
463            || !self.common_invocation_options.is_empty()
464            || !self.sections.is_empty();
465
466        has_content
467            && entries_are_valid(&self.commands)
468            && entries_are_valid(&self.arguments)
469            && entries_are_valid(&self.options)
470            && entries_are_valid(&self.common_invocation_options)
471            && sections_are_valid
472    }
473
474    fn to_row(&self) -> Map<String, Value> {
475        let mut row = Map::new();
476        let use_ordered_sections = self.uses_ordered_section_representation();
477
478        if !self.preamble.is_empty() {
479            row.insert("preamble".to_string(), string_array(&self.preamble));
480        }
481
482        if !(self.usage.is_empty()
483            || use_ordered_sections
484                && self.has_canonical_builtin_section_kind(GuideSectionKind::Usage))
485        {
486            row.insert("usage".to_string(), string_array(&self.usage));
487        }
488        if !(self.commands.is_empty()
489            || use_ordered_sections
490                && self.has_canonical_builtin_section_kind(GuideSectionKind::Commands))
491        {
492            row.insert("commands".to_string(), payload_entry_array(&self.commands));
493        }
494        if !(self.arguments.is_empty()
495            || use_ordered_sections
496                && self.has_canonical_builtin_section_kind(GuideSectionKind::Arguments))
497        {
498            row.insert(
499                "arguments".to_string(),
500                payload_entry_array(&self.arguments),
501            );
502        }
503        if !(self.options.is_empty()
504            || use_ordered_sections
505                && self.has_canonical_builtin_section_kind(GuideSectionKind::Options))
506        {
507            row.insert("options".to_string(), payload_entry_array(&self.options));
508        }
509        if !(self.common_invocation_options.is_empty()
510            || use_ordered_sections
511                && self
512                    .has_canonical_builtin_section_kind(GuideSectionKind::CommonInvocationOptions))
513        {
514            row.insert(
515                "common_invocation_options".to_string(),
516                payload_entry_array(&self.common_invocation_options),
517            );
518        }
519        if !(self.notes.is_empty()
520            || use_ordered_sections
521                && self.has_canonical_builtin_section_kind(GuideSectionKind::Notes))
522        {
523            row.insert("notes".to_string(), string_array(&self.notes));
524        }
525        if !self.sections.is_empty() {
526            row.insert(
527                "sections".to_string(),
528                Value::Array(self.sections.iter().map(GuideSection::to_value).collect()),
529            );
530        }
531        if !self.epilogue.is_empty() {
532            row.insert("epilogue".to_string(), string_array(&self.epilogue));
533        }
534
535        row
536    }
537
538    fn try_from_row(row: &Map<String, Value>) -> Option<Self> {
539        let view = Self::normalize_restored_sections(Self {
540            preamble: row_string_array(row.get("preamble"))?,
541            usage: row_string_array(row.get("usage"))?,
542            commands: payload_entries(row.get("commands"))?,
543            arguments: payload_entries(row.get("arguments"))?,
544            options: payload_entries(row.get("options"))?,
545            common_invocation_options: payload_entries(row.get("common_invocation_options"))?,
546            notes: row_string_array(row.get("notes"))?,
547            sections: payload_sections(row.get("sections"))?,
548            epilogue: row_string_array(row.get("epilogue"))?,
549        });
550        view.is_semantically_valid().then_some(view)
551    }
552
553    fn normalize_restored_sections(mut view: Self) -> Self {
554        // There are two valid guide representations:
555        //
556        // - ordered sections are authoritative when custom/non-canonical
557        //   sections are interleaved with builtin ones (for example intro
558        //   payloads)
559        // - canonical buckets are authoritative when the payload only carries
560        //   builtin guide sections and those sections are merely structural
561        //   carriers for DSL addressing
562        //
563        // Restore must preserve authored mixed/custom section order, but it may
564        // collapse canonical-only section lists back into the dedicated buckets
565        // so ordinary help payloads keep their stable semantic shape.
566        let use_ordered_sections = view.uses_ordered_section_representation();
567        let has_custom_sections = view
568            .sections
569            .iter()
570            .any(|section| !section.is_canonical_builtin_section());
571        let mut canonical_usage = Vec::new();
572        let mut canonical_commands = Vec::new();
573        let mut canonical_arguments = Vec::new();
574        let mut canonical_options = Vec::new();
575        let mut canonical_common_invocation_options = Vec::new();
576        let mut canonical_notes = Vec::new();
577
578        for section in &view.sections {
579            if !section.is_canonical_builtin_section() {
580                continue;
581            }
582
583            match section.kind {
584                GuideSectionKind::Usage => {
585                    canonical_usage.extend(section.paragraphs.iter().cloned())
586                }
587                GuideSectionKind::Commands => {
588                    canonical_commands.extend(section.entries.iter().cloned());
589                }
590                GuideSectionKind::Arguments => {
591                    canonical_arguments.extend(section.entries.iter().cloned());
592                }
593                GuideSectionKind::Options => {
594                    canonical_options.extend(section.entries.iter().cloned())
595                }
596                GuideSectionKind::CommonInvocationOptions => {
597                    canonical_common_invocation_options.extend(section.entries.iter().cloned());
598                }
599                GuideSectionKind::Notes => {
600                    canonical_notes.extend(section.paragraphs.iter().cloned())
601                }
602                GuideSectionKind::Custom => {}
603            }
604        }
605
606        if !use_ordered_sections || !has_custom_sections {
607            if view.has_canonical_builtin_section_kind(GuideSectionKind::Usage)
608                || view.usage.is_empty() && !canonical_usage.is_empty()
609            {
610                view.usage = canonical_usage;
611            }
612
613            if view.has_canonical_builtin_section_kind(GuideSectionKind::Commands)
614                || view.commands.is_empty() && !canonical_commands.is_empty()
615            {
616                view.commands = canonical_commands;
617            }
618
619            if view.has_canonical_builtin_section_kind(GuideSectionKind::Arguments)
620                || view.arguments.is_empty() && !canonical_arguments.is_empty()
621            {
622                view.arguments = canonical_arguments;
623            }
624
625            if view.has_canonical_builtin_section_kind(GuideSectionKind::Options)
626                || view.options.is_empty() && !canonical_options.is_empty()
627            {
628                view.options = canonical_options;
629            }
630
631            if view.has_canonical_builtin_section_kind(GuideSectionKind::CommonInvocationOptions)
632                || view.common_invocation_options.is_empty()
633                    && !canonical_common_invocation_options.is_empty()
634            {
635                view.common_invocation_options = canonical_common_invocation_options;
636            }
637
638            if view.has_canonical_builtin_section_kind(GuideSectionKind::Notes)
639                || view.notes.is_empty() && !canonical_notes.is_empty()
640            {
641                view.notes = canonical_notes;
642            }
643
644            view.sections
645                .retain(|section| !section.is_canonical_builtin_section());
646        } else {
647            if view.usage.is_empty() && !canonical_usage.is_empty() {
648                view.usage = canonical_usage;
649            }
650            if view.commands.is_empty() && !canonical_commands.is_empty() {
651                view.commands = canonical_commands;
652            }
653            if view.arguments.is_empty() && !canonical_arguments.is_empty() {
654                view.arguments = canonical_arguments;
655            }
656            if view.options.is_empty() && !canonical_options.is_empty() {
657                view.options = canonical_options;
658            }
659            if view.common_invocation_options.is_empty()
660                && !canonical_common_invocation_options.is_empty()
661            {
662                view.common_invocation_options = canonical_common_invocation_options;
663            }
664            if view.notes.is_empty() && !canonical_notes.is_empty() {
665                view.notes = canonical_notes;
666            }
667        }
668        view
669    }
670
671    pub(crate) fn has_canonical_builtin_section_kind(&self, kind: GuideSectionKind) -> bool {
672        self.sections
673            .iter()
674            .any(|section| section.kind == kind && section.is_canonical_builtin_section())
675    }
676
677    pub(crate) fn uses_ordered_section_representation(&self) -> bool {
678        self.sections.iter().any(|section| {
679            !section.is_canonical_builtin_section()
680                || canonical_section_owns_ordered_content(self, section)
681        })
682    }
683}
684
685fn canonical_section_owns_ordered_content(view: &GuideView, section: &GuideSection) -> bool {
686    let has_data = !matches!(section.data, None | Some(Value::Null));
687    (match section.kind {
688        // Canonical sections normally mirror the dedicated top-level buckets.
689        // If the bucket is empty but the section carries content, the section
690        // list is the authoritative authored shape and later lowering must keep
691        // that order instead of discarding the canonical section as a duplicate.
692        GuideSectionKind::Usage => !section.paragraphs.is_empty() && view.usage.is_empty(),
693        GuideSectionKind::Commands => !section.entries.is_empty() && view.commands.is_empty(),
694        GuideSectionKind::Arguments => !section.entries.is_empty() && view.arguments.is_empty(),
695        GuideSectionKind::Options => !section.entries.is_empty() && view.options.is_empty(),
696        GuideSectionKind::CommonInvocationOptions => {
697            !section.entries.is_empty() && view.common_invocation_options.is_empty()
698        }
699        GuideSectionKind::Notes => !section.paragraphs.is_empty() && view.notes.is_empty(),
700        GuideSectionKind::Custom => false,
701    }) || has_data
702}
703
704impl GuideEntry {
705    fn is_semantically_valid(&self) -> bool {
706        !self.name.is_empty() || !self.short_help.is_empty()
707    }
708}
709
710impl GuideSection {
711    fn is_semantically_valid(&self) -> bool {
712        let has_data = !matches!(self.data, None | Some(Value::Null));
713        let has_content =
714            !self.title.is_empty() || !self.paragraphs.is_empty() || !self.entries.is_empty();
715        (has_content || has_data) && self.entries.iter().all(GuideEntry::is_semantically_valid)
716    }
717
718    pub(crate) fn is_canonical_builtin_section(&self) -> bool {
719        let expected = match self.kind {
720            GuideSectionKind::Usage => "Usage",
721            GuideSectionKind::Commands => "Commands",
722            GuideSectionKind::Arguments => "Arguments",
723            GuideSectionKind::Options => "Options",
724            GuideSectionKind::CommonInvocationOptions => "Common Invocation Options",
725            GuideSectionKind::Notes => "Notes",
726            GuideSectionKind::Custom => return false,
727        };
728
729        self.title.trim().eq_ignore_ascii_case(expected)
730    }
731}
732
733fn append_value_paragraphs(lines: &mut Vec<String>, paragraphs: &[String]) {
734    if paragraphs.is_empty() {
735        return;
736    }
737    if !lines.is_empty() {
738        lines.push(String::new());
739    }
740    lines.extend(paragraphs.iter().cloned());
741}
742
743fn append_value_entries(lines: &mut Vec<String>, entries: &[GuideEntry]) {
744    let values = entries
745        .iter()
746        .filter_map(value_line_for_entry)
747        .collect::<Vec<_>>();
748
749    if values.is_empty() {
750        return;
751    }
752    if !lines.is_empty() {
753        lines.push(String::new());
754    }
755    lines.extend(values);
756}
757
758fn append_value_data(lines: &mut Vec<String>, data: &Value) {
759    let values = data_value_lines(data);
760    if values.is_empty() {
761        return;
762    }
763    if !lines.is_empty() {
764        lines.push(String::new());
765    }
766    lines.extend(values);
767}
768
769fn data_value_lines(value: &Value) -> Vec<String> {
770    if let Some(entries) = payload_entry_array_as_entries(value) {
771        return entries.iter().filter_map(value_line_for_entry).collect();
772    }
773
774    match value {
775        Value::Null => Vec::new(),
776        Value::Array(items) => items.iter().flat_map(data_value_lines).collect(),
777        Value::Object(map) => map
778            .values()
779            .filter(|value| !value.is_null())
780            .map(guide_value_to_display)
781            .collect(),
782        scalar => vec![guide_value_to_display(scalar)],
783    }
784}
785
786fn payload_entry_array_as_entries(value: &Value) -> Option<Vec<GuideEntry>> {
787    let Value::Array(items) = value else {
788        return None;
789    };
790
791    items.iter().map(payload_entry_value_as_entry).collect()
792}
793
794fn payload_entry_value_as_entry(value: &Value) -> Option<GuideEntry> {
795    let Value::Object(map) = value else {
796        return None;
797    };
798    if map.keys().any(|key| key != "name" && key != "short_help") {
799        return None;
800    }
801
802    Some(GuideEntry {
803        name: map.get("name")?.as_str()?.to_string(),
804        short_help: map
805            .get("short_help")
806            .and_then(Value::as_str)
807            .unwrap_or_default()
808            .to_string(),
809        display_indent: None,
810        display_gap: None,
811    })
812}
813
814fn guide_value_to_display(value: &Value) -> String {
815    match value {
816        Value::Null => "null".to_string(),
817        Value::Bool(value) => value.to_string().to_ascii_lowercase(),
818        Value::Number(value) => value.to_string(),
819        Value::String(value) => value.clone(),
820        Value::Array(values) => values
821            .iter()
822            .map(guide_value_to_display)
823            .collect::<Vec<_>>()
824            .join(", "),
825        Value::Object(map) => {
826            if map.is_empty() {
827                return "{}".to_string();
828            }
829            let mut keys = map.keys().collect::<Vec<_>>();
830            keys.sort();
831            let preview = keys
832                .into_iter()
833                .take(3)
834                .cloned()
835                .collect::<Vec<_>>()
836                .join(", ");
837            if map.len() > 3 {
838                format!("{{{preview}, ...}}")
839            } else {
840                format!("{{{preview}}}")
841            }
842        }
843    }
844}
845
846fn value_line_for_entry(entry: &GuideEntry) -> Option<String> {
847    if !entry.short_help.trim().is_empty() {
848        return Some(entry.short_help.clone());
849    }
850    if !entry.name.trim().is_empty() {
851        return Some(entry.name.clone());
852    }
853    None
854}
855
856impl GuideSection {
857    fn to_value(&self) -> Value {
858        let mut section = Map::new();
859        section.insert("title".to_string(), Value::String(self.title.clone()));
860        section.insert(
861            "kind".to_string(),
862            Value::String(self.kind.as_str().to_string()),
863        );
864        section.insert("paragraphs".to_string(), string_array(&self.paragraphs));
865        section.insert(
866            "entries".to_string(),
867            Value::Array(
868                self.entries
869                    .iter()
870                    .map(payload_entry_value)
871                    .collect::<Vec<_>>(),
872            ),
873        );
874        if let Some(data) = self.data.as_ref() {
875            section.insert("data".to_string(), data.clone());
876        }
877        Value::Object(section)
878    }
879}
880
881impl GuideSection {
882    /// Creates a new guide section with a title and canonical kind.
883    ///
884    /// # Examples
885    ///
886    /// ```
887    /// use osp_cli::guide::{GuideSection, GuideSectionKind};
888    ///
889    /// let section = GuideSection::new("Notes", GuideSectionKind::Notes)
890    ///     .paragraph("first")
891    ///     .entry("show", "Display");
892    ///
893    /// assert_eq!(section.paragraphs, vec!["first".to_string()]);
894    /// assert_eq!(section.entries[0].name, "show");
895    /// assert_eq!(section.entries[0].short_help, "Display");
896    /// ```
897    pub fn new(title: impl Into<String>, kind: GuideSectionKind) -> Self {
898        Self {
899            title: title.into(),
900            kind,
901            paragraphs: Vec::new(),
902            entries: Vec::new(),
903            data: None,
904        }
905    }
906
907    /// Appends a paragraph to the section.
908    pub fn paragraph(mut self, text: impl Into<String>) -> Self {
909        self.paragraphs.push(text.into());
910        self
911    }
912
913    /// Attaches semantic data to the section.
914    ///
915    /// Renderers may choose the best presentation for this payload instead of
916    /// showing the authoring JSON literally.
917    pub fn data(mut self, value: Value) -> Self {
918        self.data = Some(value);
919        self
920    }
921
922    /// Appends a named entry row to the section.
923    pub fn entry(mut self, name: impl Into<String>, short_help: impl Into<String>) -> Self {
924        self.entries.push(GuideEntry {
925            name: name.into(),
926            short_help: short_help.into(),
927            display_indent: None,
928            display_gap: None,
929        });
930        self
931    }
932}
933
934impl GuideSectionKind {
935    /// Returns the stable string form used in serialized guide payloads.
936    ///
937    /// # Examples
938    ///
939    /// ```
940    /// use osp_cli::guide::GuideSectionKind;
941    ///
942    /// assert_eq!(GuideSectionKind::Commands.as_str(), "commands");
943    /// assert_eq!(
944    ///     GuideSectionKind::CommonInvocationOptions.as_str(),
945    ///     "common_invocation_options"
946    /// );
947    /// ```
948    pub fn as_str(self) -> &'static str {
949        match self {
950            GuideSectionKind::Usage => "usage",
951            GuideSectionKind::Commands => "commands",
952            GuideSectionKind::Options => "options",
953            GuideSectionKind::Arguments => "arguments",
954            GuideSectionKind::CommonInvocationOptions => "common_invocation_options",
955            GuideSectionKind::Notes => "notes",
956            GuideSectionKind::Custom => "custom",
957        }
958    }
959
960    pub(crate) fn min_help_level(self) -> HelpLevel {
961        match self {
962            GuideSectionKind::Usage => HelpLevel::Tiny,
963            GuideSectionKind::CommonInvocationOptions => HelpLevel::Verbose,
964            GuideSectionKind::Commands
965            | GuideSectionKind::Options
966            | GuideSectionKind::Arguments
967            | GuideSectionKind::Notes
968            | GuideSectionKind::Custom => HelpLevel::Normal,
969        }
970    }
971}
972
973fn string_array(values: &[String]) -> Value {
974    Value::Array(
975        values
976            .iter()
977            .map(|value| Value::String(value.trim().to_string()))
978            .collect(),
979    )
980}
981
982fn row_string_array(value: Option<&Value>) -> Option<Vec<String>> {
983    let Some(value) = value else {
984        return Some(Vec::new());
985    };
986    let Value::Array(values) = value else {
987        return None;
988    };
989    values
990        .iter()
991        .map(|value| value.as_str().map(ToOwned::to_owned))
992        .collect()
993}
994
995fn payload_entry_value(entry: &GuideEntry) -> Value {
996    json!({
997        "name": entry.name,
998        "short_help": entry.short_help,
999    })
1000}
1001
1002fn payload_entry_array(entries: &[GuideEntry]) -> Value {
1003    Value::Array(entries.iter().map(payload_entry_value).collect())
1004}
1005
1006fn payload_entries(value: Option<&Value>) -> Option<Vec<GuideEntry>> {
1007    let Some(value) = value else {
1008        return Some(Vec::new());
1009    };
1010    let Value::Array(entries) = value else {
1011        return None;
1012    };
1013
1014    let mut out = Vec::new();
1015    for entry in entries {
1016        let Value::Object(entry) = entry else {
1017            return None;
1018        };
1019        let name = entry
1020            .get("name")
1021            .and_then(Value::as_str)
1022            .unwrap_or_default()
1023            .to_string();
1024        let short_help = entry
1025            .get("short_help")
1026            .or_else(|| entry.get("summary"))
1027            .and_then(Value::as_str)
1028            .unwrap_or_default()
1029            .to_string();
1030        out.push(GuideEntry {
1031            name,
1032            short_help,
1033            display_indent: None,
1034            display_gap: None,
1035        });
1036    }
1037    Some(out)
1038}
1039
1040fn payload_sections(value: Option<&Value>) -> Option<Vec<GuideSection>> {
1041    let Some(value) = value else {
1042        return Some(Vec::new());
1043    };
1044    let Value::Array(sections) = value else {
1045        return None;
1046    };
1047
1048    let mut out = Vec::new();
1049    for section in sections {
1050        let Value::Object(section) = section else {
1051            return None;
1052        };
1053        let title = section.get("title")?.as_str()?.to_string();
1054        let kind = match section
1055            .get("kind")
1056            .and_then(Value::as_str)
1057            .unwrap_or("custom")
1058        {
1059            "custom" => GuideSectionKind::Custom,
1060            "notes" => GuideSectionKind::Notes,
1061            "usage" => GuideSectionKind::Usage,
1062            "commands" => GuideSectionKind::Commands,
1063            "arguments" => GuideSectionKind::Arguments,
1064            "options" => GuideSectionKind::Options,
1065            "common_invocation_options" => GuideSectionKind::CommonInvocationOptions,
1066            _ => return None,
1067        };
1068        out.push(GuideSection {
1069            title,
1070            kind,
1071            paragraphs: row_string_array(section.get("paragraphs"))?,
1072            entries: payload_entries(section.get("entries"))?,
1073            data: section.get("data").cloned(),
1074        });
1075    }
1076    Some(out)
1077}
1078
1079fn guide_view_from_command_def(command: &CommandDef) -> GuideView {
1080    let usage = command
1081        .usage
1082        .clone()
1083        .or_else(|| default_usage(command))
1084        .map(|usage| vec![usage])
1085        .unwrap_or_default();
1086
1087    let visible_subcommands = command
1088        .subcommands
1089        .iter()
1090        .filter(|subcommand| !subcommand.hidden)
1091        .collect::<Vec<_>>();
1092    let commands = visible_subcommands
1093        .into_iter()
1094        .map(|subcommand| GuideEntry {
1095            name: subcommand.name.clone(),
1096            short_help: subcommand.about.clone().unwrap_or_default(),
1097            display_indent: None,
1098            display_gap: None,
1099        })
1100        .collect();
1101
1102    let visible_args = command
1103        .args
1104        .iter()
1105        .filter(|arg| !arg.id.is_empty())
1106        .collect::<Vec<_>>();
1107    let arguments = visible_args
1108        .into_iter()
1109        .map(|arg| GuideEntry {
1110            name: arg_label(arg),
1111            short_help: arg.help.clone().unwrap_or_default(),
1112            display_indent: None,
1113            display_gap: None,
1114        })
1115        .collect();
1116
1117    let visible_flags = command
1118        .flags
1119        .iter()
1120        .filter(|flag| !flag.hidden)
1121        .collect::<Vec<_>>();
1122    let options = visible_flags
1123        .into_iter()
1124        .map(|flag| GuideEntry {
1125            name: flag_label(flag),
1126            short_help: flag.help.clone().unwrap_or_default(),
1127            display_indent: Some(if flag.short.is_some() {
1128                "  ".to_string()
1129            } else {
1130                "      ".to_string()
1131            }),
1132            display_gap: None,
1133        })
1134        .collect();
1135
1136    let preamble = command
1137        .before_help
1138        .iter()
1139        .flat_map(|text| text.lines().map(ToString::to_string))
1140        .collect();
1141    let epilogue = command
1142        .after_help
1143        .iter()
1144        .flat_map(|text| text.lines().map(ToString::to_string))
1145        .collect();
1146
1147    GuideView {
1148        preamble,
1149        sections: Vec::new(),
1150        epilogue,
1151        usage,
1152        commands,
1153        arguments,
1154        options,
1155        common_invocation_options: Vec::new(),
1156        notes: Vec::new(),
1157    }
1158}
1159
1160fn default_usage(command: &CommandDef) -> Option<String> {
1161    if command.name.trim().is_empty() {
1162        return None;
1163    }
1164
1165    let mut parts = vec![command.name.clone()];
1166    if !command
1167        .flags
1168        .iter()
1169        .filter(|flag| !flag.hidden)
1170        .collect::<Vec<_>>()
1171        .is_empty()
1172    {
1173        parts.push("[OPTIONS]".to_string());
1174    }
1175    for arg in command.args.iter().filter(|arg| !arg.id.is_empty()) {
1176        let label = arg_label(arg);
1177        if arg.required {
1178            parts.push(label);
1179        } else {
1180            parts.push(format!("[{label}]"));
1181        }
1182    }
1183    if !command
1184        .subcommands
1185        .iter()
1186        .filter(|subcommand| !subcommand.hidden)
1187        .collect::<Vec<_>>()
1188        .is_empty()
1189    {
1190        parts.push("<COMMAND>".to_string());
1191    }
1192    Some(parts.join(" "))
1193}
1194
1195fn arg_label(arg: &ArgDef) -> String {
1196    arg.value_name.clone().unwrap_or_else(|| arg.id.clone())
1197}
1198
1199fn flag_label(flag: &FlagDef) -> String {
1200    let mut labels = Vec::new();
1201    if let Some(short) = flag.short {
1202        labels.push(format!("-{short}"));
1203    }
1204    if let Some(long) = flag.long.as_deref() {
1205        labels.push(format!("--{long}"));
1206    }
1207    if flag.takes_value
1208        && let Some(value_name) = flag.value_name.as_deref()
1209    {
1210        labels.push(format!("<{value_name}>"));
1211    }
1212    labels.join(", ")
1213}
1214
1215fn parse_help_view(help_text: &str) -> GuideView {
1216    let mut view = GuideView::default();
1217    let mut current: Option<GuideSection> = None;
1218    let mut saw_section = false;
1219
1220    for raw_line in help_text.lines() {
1221        let line = raw_line.trim_end();
1222        if let Some((title, kind, body)) = parse_section_header(line) {
1223            if let Some(section) = current.take() {
1224                view.sections.push(section);
1225            }
1226            saw_section = true;
1227            let mut section = GuideSection::new(title, kind);
1228            if let Some(body) = body {
1229                section.paragraphs.push(body);
1230            }
1231            current = Some(section);
1232            continue;
1233        }
1234
1235        if current
1236            .as_ref()
1237            .is_some_and(|section| line_belongs_to_epilogue(section.kind, line))
1238        {
1239            if let Some(section) = current.take() {
1240                view.sections.push(section);
1241            }
1242            view.epilogue.push(line.to_string());
1243            continue;
1244        }
1245
1246        if let Some(section) = current.as_mut() {
1247            parse_section_line(section, line);
1248        } else if !line.is_empty() {
1249            if saw_section {
1250                view.epilogue.push(line.to_string());
1251            } else {
1252                view.preamble.push(line.to_string());
1253            }
1254        }
1255    }
1256
1257    if let Some(section) = current {
1258        view.sections.push(section);
1259    }
1260
1261    repartition_builtin_sections(view)
1262}
1263
1264fn line_belongs_to_epilogue(kind: GuideSectionKind, line: &str) -> bool {
1265    if line.trim().is_empty() {
1266        return false;
1267    }
1268
1269    matches!(
1270        kind,
1271        GuideSectionKind::Commands | GuideSectionKind::Options | GuideSectionKind::Arguments
1272    ) && !line.starts_with(' ')
1273}
1274
1275fn parse_section_header(line: &str) -> Option<(String, GuideSectionKind, Option<String>)> {
1276    if let Some(usage) = line.strip_prefix("Usage:") {
1277        return Some((
1278            "Usage".to_string(),
1279            GuideSectionKind::Usage,
1280            Some(usage.trim().to_string()),
1281        ));
1282    }
1283
1284    let (title, kind) = match line {
1285        "Commands:" => ("Commands".to_string(), GuideSectionKind::Commands),
1286        "Options:" => ("Options".to_string(), GuideSectionKind::Options),
1287        "Arguments:" => ("Arguments".to_string(), GuideSectionKind::Arguments),
1288        "Common Invocation Options:" => (
1289            "Common Invocation Options".to_string(),
1290            GuideSectionKind::CommonInvocationOptions,
1291        ),
1292        "Notes:" => ("Notes".to_string(), GuideSectionKind::Notes),
1293        _ if !line.starts_with(' ') && line.ends_with(':') => (
1294            line.trim_end_matches(':').trim().to_string(),
1295            GuideSectionKind::Custom,
1296        ),
1297        _ => return None,
1298    };
1299
1300    Some((title, kind, None))
1301}
1302
1303fn parse_section_line(section: &mut GuideSection, line: &str) {
1304    if line.trim().is_empty() {
1305        return;
1306    }
1307
1308    if matches!(
1309        section.kind,
1310        GuideSectionKind::Commands
1311            | GuideSectionKind::Options
1312            | GuideSectionKind::Arguments
1313            | GuideSectionKind::CommonInvocationOptions
1314    ) {
1315        let indent_len = line.len().saturating_sub(line.trim_start().len());
1316        let (_, rest) = line.split_at(indent_len);
1317        let split = help_description_split(section.kind, rest).unwrap_or(rest.len());
1318        let (head, tail) = rest.split_at(split);
1319        let display_indent = Some(" ".repeat(indent_len));
1320        let display_gap = (!tail.is_empty()).then(|| {
1321            tail.chars()
1322                .take_while(|ch| ch.is_whitespace())
1323                .collect::<String>()
1324        });
1325        section.entries.push(GuideEntry {
1326            name: head.trim().to_string(),
1327            short_help: tail.trim().to_string(),
1328            display_indent,
1329            display_gap,
1330        });
1331        return;
1332    }
1333
1334    section.paragraphs.push(line.to_string());
1335}
1336
1337fn repartition_builtin_sections(mut view: GuideView) -> GuideView {
1338    let sections = std::mem::take(&mut view.sections);
1339    for section in sections {
1340        match section.kind {
1341            GuideSectionKind::Usage => view.usage.extend(section.paragraphs),
1342            GuideSectionKind::Commands => view.commands.extend(section.entries),
1343            GuideSectionKind::Arguments => view.arguments.extend(section.entries),
1344            GuideSectionKind::Options => view.options.extend(section.entries),
1345            GuideSectionKind::CommonInvocationOptions => {
1346                view.common_invocation_options.extend(section.entries);
1347            }
1348            GuideSectionKind::Notes => view.notes.extend(section.paragraphs),
1349            GuideSectionKind::Custom => view.sections.push(section),
1350        }
1351    }
1352    view
1353}
1354
1355fn help_description_split(kind: GuideSectionKind, line: &str) -> Option<usize> {
1356    let mut saw_non_whitespace = false;
1357    let mut run_start = None;
1358    let mut run_len = 0usize;
1359
1360    for (idx, ch) in line.char_indices() {
1361        if ch.is_whitespace() {
1362            if saw_non_whitespace {
1363                run_start.get_or_insert(idx);
1364                run_len += 1;
1365            }
1366            continue;
1367        }
1368
1369        if saw_non_whitespace && run_len >= 2 {
1370            return run_start;
1371        }
1372
1373        saw_non_whitespace = true;
1374        run_start = None;
1375        run_len = 0;
1376    }
1377
1378    if matches!(
1379        kind,
1380        GuideSectionKind::Commands | GuideSectionKind::Arguments
1381    ) {
1382        return line.find(char::is_whitespace);
1383    }
1384
1385    None
1386}
1387
1388#[cfg(test)]
1389mod tests;