Skip to main content

osp_cli/ui/format/
message.rs

1use crate::ui::document::{
2    Block, CodeBlock, Document, JsonBlock, LineBlock, LinePart, PanelBlock, PanelRules,
3};
4use crate::ui::style::StyleToken;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum MessageKind {
8    Error,
9    Warning,
10    Success,
11    Info,
12    Trace,
13}
14
15impl MessageKind {
16    pub fn as_label(self) -> &'static str {
17        match self {
18            MessageKind::Error => "error",
19            MessageKind::Warning => "warning",
20            MessageKind::Success => "success",
21            MessageKind::Info => "info",
22            MessageKind::Trace => "trace",
23        }
24    }
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum MessageRules {
29    None,
30    Top,
31    Bottom,
32    Both,
33}
34
35impl MessageRules {
36    fn to_panel_rules(self) -> PanelRules {
37        match self {
38            MessageRules::None => PanelRules::None,
39            MessageRules::Top => PanelRules::Top,
40            MessageRules::Bottom => PanelRules::Bottom,
41            MessageRules::Both => PanelRules::Both,
42        }
43    }
44}
45
46#[derive(Debug, Clone)]
47pub struct MessageOptions {
48    pub rules: MessageRules,
49    pub kind: MessageKind,
50    pub title: Option<String>,
51}
52
53impl Default for MessageOptions {
54    fn default() -> Self {
55        Self {
56            rules: MessageRules::Both,
57            kind: MessageKind::Info,
58            title: None,
59        }
60    }
61}
62
63#[derive(Debug, Clone)]
64pub enum MessageContent {
65    Text(String),
66    Json(serde_json::Value),
67    Document(Document),
68    Code {
69        code: String,
70        language: Option<String>,
71    },
72}
73
74impl From<&str> for MessageContent {
75    fn from(value: &str) -> Self {
76        Self::Text(value.to_string())
77    }
78}
79
80impl From<String> for MessageContent {
81    fn from(value: String) -> Self {
82        Self::Text(value)
83    }
84}
85
86impl From<serde_json::Value> for MessageContent {
87    fn from(value: serde_json::Value) -> Self {
88        Self::Json(value)
89    }
90}
91
92impl From<Document> for MessageContent {
93    fn from(value: Document) -> Self {
94        Self::Document(value)
95    }
96}
97
98pub struct MessageFormatter;
99
100impl MessageFormatter {
101    pub fn build(content: impl Into<MessageContent>, options: MessageOptions) -> Document {
102        let content = content.into();
103
104        if matches!(options.rules, MessageRules::None) {
105            return build_flat_message(content, &options);
106        }
107
108        let title = options
109            .title
110            .as_ref()
111            .cloned()
112            .or_else(|| Some(options.kind.as_label().to_uppercase()));
113        let body = normalize_content_to_document(content);
114        Document {
115            blocks: vec![Block::Panel(PanelBlock {
116                title,
117                body,
118                rules: options.rules.to_panel_rules(),
119                kind: Some(options.kind.as_label().to_string()),
120                border_token: Some(kind_border_token(options.kind)),
121                title_token: Some(kind_title_token(options.kind)),
122            })],
123        }
124    }
125}
126
127fn build_flat_message(content: MessageContent, options: &MessageOptions) -> Document {
128    match content {
129        MessageContent::Json(value) => Document {
130            blocks: vec![Block::Json(JsonBlock { payload: value })],
131        },
132        MessageContent::Document(document) => document,
133        MessageContent::Code { code, language } => Document {
134            blocks: vec![Block::Code(CodeBlock { code, language })],
135        },
136        MessageContent::Text(text) => {
137            let mut output = Vec::new();
138            for (index, line) in trim_blank_lines(text.lines()).into_iter().enumerate() {
139                let text = if index == 0 {
140                    if let Some(title) = options.title.as_deref() {
141                        format!("{}: {line}", title.to_uppercase())
142                    } else {
143                        line.to_string()
144                    }
145                } else {
146                    line.to_string()
147                };
148
149                output.push(Block::Line(LineBlock {
150                    parts: vec![LinePart { text, token: None }],
151                }));
152            }
153            Document { blocks: output }
154        }
155    }
156}
157
158fn normalize_content_to_document(content: MessageContent) -> Document {
159    match content {
160        MessageContent::Json(value) => Document {
161            blocks: vec![Block::Json(JsonBlock { payload: value })],
162        },
163        MessageContent::Document(document) => document,
164        MessageContent::Code { code, language } => Document {
165            blocks: vec![Block::Code(CodeBlock { code, language })],
166        },
167        MessageContent::Text(text) => Document {
168            blocks: trim_blank_lines(text.lines())
169                .into_iter()
170                .map(|line| {
171                    Block::Line(LineBlock {
172                        parts: vec![LinePart {
173                            text: line.to_string(),
174                            token: None,
175                        }],
176                    })
177                })
178                .collect(),
179        },
180    }
181}
182
183fn trim_blank_lines<'a>(lines: impl IntoIterator<Item = &'a str>) -> Vec<&'a str> {
184    let mut values = lines.into_iter().collect::<Vec<&str>>();
185    while values.first().is_some_and(|line| line.trim().is_empty()) {
186        values.remove(0);
187    }
188    while values.last().is_some_and(|line| line.trim().is_empty()) {
189        values.pop();
190    }
191    values
192}
193
194fn kind_border_token(kind: MessageKind) -> StyleToken {
195    match kind {
196        MessageKind::Error => StyleToken::MessageError,
197        MessageKind::Warning => StyleToken::MessageWarning,
198        MessageKind::Success => StyleToken::MessageSuccess,
199        MessageKind::Info => StyleToken::MessageInfo,
200        MessageKind::Trace => StyleToken::MessageTrace,
201    }
202}
203
204fn kind_title_token(kind: MessageKind) -> StyleToken {
205    kind_border_token(kind)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::{MessageContent, MessageFormatter, MessageKind, MessageOptions, MessageRules};
211    use crate::ui::document::{Block, Document, LineBlock, LinePart, PanelRules};
212    use crate::ui::style::StyleToken;
213    use serde_json::json;
214
215    #[test]
216    fn message_rules_none_yields_lines_without_panel() {
217        let doc = MessageFormatter::build(
218            "hello\nworld",
219            MessageOptions {
220                rules: MessageRules::None,
221                kind: MessageKind::Info,
222                title: Some("info".to_string()),
223            },
224        );
225
226        assert!(matches!(doc.blocks[0], Block::Line(_)));
227        assert!(matches!(doc.blocks[1], Block::Line(_)));
228    }
229
230    #[test]
231    fn message_rules_both_wrap_in_panel() {
232        let doc = MessageFormatter::build(
233            MessageContent::Json(serde_json::json!({"ok": true})),
234            MessageOptions::default(),
235        );
236
237        assert!(matches!(doc.blocks[0], Block::Panel(_)));
238    }
239
240    #[test]
241    fn flat_text_trims_blank_lines_and_uses_uppercase_title_prefix() {
242        let doc = MessageFormatter::build(
243            "\n\nhello\nworld\n\n",
244            MessageOptions {
245                rules: MessageRules::None,
246                kind: MessageKind::Warning,
247                title: Some("warning".to_string()),
248            },
249        );
250
251        let Block::Line(first) = &doc.blocks[0] else {
252            panic!("expected first block to be a line");
253        };
254        assert_eq!(first.parts[0].text, "WARNING: hello");
255        let Block::Line(second) = &doc.blocks[1] else {
256            panic!("expected second block to be a line");
257        };
258        assert_eq!(second.parts[0].text, "world");
259    }
260
261    #[test]
262    fn flat_message_preserves_json_code_and_document_blocks() {
263        let json_doc = MessageFormatter::build(
264            MessageContent::Json(json!({"ok": true})),
265            MessageOptions {
266                rules: MessageRules::None,
267                kind: MessageKind::Info,
268                title: None,
269            },
270        );
271        assert!(matches!(json_doc.blocks[0], Block::Json(_)));
272
273        let code_doc = MessageFormatter::build(
274            MessageContent::Code {
275                code: "ldap user oistes".to_string(),
276                language: Some("bash".to_string()),
277            },
278            MessageOptions {
279                rules: MessageRules::None,
280                kind: MessageKind::Trace,
281                title: None,
282            },
283        );
284        assert!(matches!(code_doc.blocks[0], Block::Code(_)));
285
286        let inner = Document {
287            blocks: vec![Block::Line(LineBlock {
288                parts: vec![LinePart {
289                    text: "nested".to_string(),
290                    token: None,
291                }],
292            })],
293        };
294        let nested = MessageFormatter::build(
295            MessageContent::Document(inner.clone()),
296            MessageOptions {
297                rules: MessageRules::None,
298                kind: MessageKind::Info,
299                title: None,
300            },
301        );
302        assert_eq!(nested.blocks.len(), inner.blocks.len());
303    }
304
305    #[test]
306    fn panel_message_uses_kind_tokens_and_default_title() {
307        let doc = MessageFormatter::build(
308            "failed",
309            MessageOptions {
310                rules: MessageRules::Top,
311                kind: MessageKind::Error,
312                title: None,
313            },
314        );
315
316        let Block::Panel(panel) = &doc.blocks[0] else {
317            panic!("expected panel block");
318        };
319        assert_eq!(panel.title.as_deref(), Some("ERROR"));
320        assert_eq!(panel.kind.as_deref(), Some("error"));
321        assert_eq!(panel.border_token, Some(StyleToken::MessageError));
322        assert_eq!(panel.title_token, Some(StyleToken::MessageError));
323    }
324
325    #[test]
326    fn message_kind_labels_and_rule_mapping_cover_all_variants() {
327        assert_eq!(MessageKind::Success.as_label(), "success");
328        assert_eq!(MessageKind::Trace.as_label(), "trace");
329
330        let top = MessageFormatter::build(
331            "hello",
332            MessageOptions {
333                rules: MessageRules::Top,
334                kind: MessageKind::Info,
335                title: Some("notice".to_string()),
336            },
337        );
338        let Block::Panel(top_panel) = &top.blocks[0] else {
339            panic!("expected panel block");
340        };
341        assert_eq!(top_panel.rules, PanelRules::Top);
342        assert_eq!(top_panel.title.as_deref(), Some("notice"));
343
344        let bottom = MessageFormatter::build(
345            "hello",
346            MessageOptions {
347                rules: MessageRules::Bottom,
348                kind: MessageKind::Success,
349                title: None,
350            },
351        );
352        let Block::Panel(bottom_panel) = &bottom.blocks[0] else {
353            panic!("expected panel block");
354        };
355        assert_eq!(bottom_panel.rules, PanelRules::Bottom);
356        assert_eq!(bottom_panel.title.as_deref(), Some("SUCCESS"));
357    }
358
359    #[test]
360    fn blank_text_normalizes_to_empty_document() {
361        let doc = MessageFormatter::build(
362            "\n \n\t\n",
363            MessageOptions {
364                rules: MessageRules::None,
365                kind: MessageKind::Info,
366                title: None,
367            },
368        );
369
370        assert!(doc.blocks.is_empty());
371    }
372}