Skip to main content

osp_cli/ui/format/
help.rs

1use crate::ui::document::{Block, CodeBlock, Document, PanelBlock, PanelRules};
2use crate::ui::inline::line_from_inline;
3use crate::ui::style::StyleToken;
4
5pub fn build_help_document(raw: &str, title: Option<&str>) -> Document {
6    let sections = parse_sections(raw);
7    if sections.is_empty() {
8        return Document {
9            blocks: vec![Block::Line(line_from_inline(raw.trim()))],
10        };
11    }
12
13    let mut blocks = Vec::new();
14    for (index, section) in sections.iter().enumerate() {
15        if section.lines.is_empty() {
16            continue;
17        }
18        let panel_title = if index == 0 {
19            title
20                .map(|value| format!("{value} ยท {}", section.title))
21                .unwrap_or_else(|| section.title.clone())
22        } else {
23            section.title.clone()
24        };
25        let body = section_lines_to_document(&section.lines);
26        blocks.push(Block::Panel(PanelBlock {
27            title: Some(panel_title),
28            body,
29            rules: PanelRules::Top,
30            kind: Some("info".to_string()),
31            border_token: Some(StyleToken::PanelBorder),
32            title_token: Some(StyleToken::PanelTitle),
33        }));
34    }
35
36    Document { blocks }
37}
38
39#[derive(Debug, Clone)]
40struct HelpSection {
41    title: String,
42    lines: Vec<String>,
43}
44
45fn parse_sections(raw: &str) -> Vec<HelpSection> {
46    let mut sections = Vec::new();
47    let mut current = HelpSection {
48        title: "Overview".to_string(),
49        lines: Vec::new(),
50    };
51
52    for line in raw.lines() {
53        let trimmed = line.trim_end();
54        let title = parse_section_title(trimmed);
55        if let Some(title) = title {
56            if !current.lines.is_empty() {
57                sections.push(current);
58            }
59            current = HelpSection {
60                title,
61                lines: Vec::new(),
62            };
63            continue;
64        }
65        current.lines.push(trimmed.to_string());
66    }
67
68    if !current.lines.is_empty() {
69        sections.push(current);
70    }
71
72    sections
73}
74
75fn parse_section_title(line: &str) -> Option<String> {
76    let trimmed = line.trim();
77    if trimmed.is_empty() || !trimmed.ends_with(':') {
78        return None;
79    }
80    if trimmed.starts_with('-') || trimmed.starts_with('*') {
81        return None;
82    }
83    let head = trimmed.trim_end_matches(':').trim();
84    if head.is_empty() {
85        None
86    } else {
87        Some(head.to_string())
88    }
89}
90
91fn section_lines_to_document(lines: &[String]) -> Document {
92    let mut blocks = Vec::new();
93    let mut in_code = false;
94    let mut code_lang: Option<String> = None;
95    let mut code_lines: Vec<String> = Vec::new();
96
97    for line in lines {
98        let trimmed = line.trim_end();
99        if trimmed.trim_start().starts_with("```") {
100            if in_code {
101                blocks.push(Block::Code(CodeBlock {
102                    code: code_lines.join("\n"),
103                    language: code_lang.clone(),
104                }));
105                code_lines.clear();
106                in_code = false;
107                code_lang = None;
108            } else {
109                in_code = true;
110                let language = trimmed
111                    .trim_start()
112                    .trim_start_matches("```")
113                    .trim()
114                    .to_string();
115                if !language.is_empty() {
116                    code_lang = Some(language);
117                }
118            }
119            continue;
120        }
121
122        if in_code {
123            code_lines.push(trimmed.to_string());
124            continue;
125        }
126
127        if trimmed.trim().is_empty() {
128            continue;
129        }
130
131        blocks.push(Block::Line(line_from_inline(trimmed)));
132    }
133
134    if in_code && !code_lines.is_empty() {
135        blocks.push(Block::Code(CodeBlock {
136            code: code_lines.join("\n"),
137            language: code_lang,
138        }));
139    }
140
141    Document { blocks }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::build_help_document;
147    use crate::ui::document::Block;
148
149    #[test]
150    fn builds_panel_sections_from_help_text() {
151        let raw = "Summary:\nline one\n\nArguments:\n- a\n- b\n";
152        let doc = build_help_document(raw, Some("osp ldap user"));
153        assert_eq!(doc.blocks.len(), 2);
154        let Block::Panel(summary) = &doc.blocks[0] else {
155            panic!("expected panel");
156        };
157        assert!(
158            summary
159                .title
160                .as_ref()
161                .is_some_and(|value| value.contains("Summary"))
162        );
163    }
164
165    #[test]
166    fn keeps_fenced_code_as_code_block() {
167        let raw = "Examples:\n```bash\nosp ldap user oistes\n```";
168        let doc = build_help_document(raw, None);
169        let Block::Panel(panel) = &doc.blocks[0] else {
170            panic!("expected panel");
171        };
172        assert!(
173            panel
174                .body
175                .blocks
176                .iter()
177                .any(|block| matches!(block, Block::Code(_)))
178        );
179    }
180}