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}