osp_cli/ui/format/
help.rs1use 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(§ion.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}