1use crate::ui::document::{Block, Document, LineBlock, LinePart};
2use crate::ui::inline::render_inline;
3use crate::ui::renderer::render_document;
4use crate::ui::style::{StyleOverrides, StyleToken};
5use crate::ui::theme::ThemeDefinition;
6use crate::ui::{RenderBackend, ResolvedRenderSettings};
7
8pub use crate::ui::chrome::{
9 SectionFrameStyle, SectionRenderContext, SectionStyleTokens,
10 render_section_block_with_overrides,
11 render_section_block_with_overrides as render_section_block, render_section_divider,
12 render_section_divider_with_overrides,
13};
14
15const ORDERED_MESSAGE_LEVELS: [MessageLevel; 5] = [
16 MessageLevel::Error,
17 MessageLevel::Warning,
18 MessageLevel::Success,
19 MessageLevel::Info,
20 MessageLevel::Trace,
21];
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
24pub enum MessageLevel {
25 Error,
26 Warning,
27 Success,
28 Info,
29 Trace,
30}
31
32impl MessageLevel {
33 fn ordered() -> impl Iterator<Item = Self> {
34 ORDERED_MESSAGE_LEVELS.into_iter()
35 }
36
37 pub fn title(self) -> &'static str {
38 match self {
39 MessageLevel::Error => "Errors",
40 MessageLevel::Warning => "Warnings",
41 MessageLevel::Success => "Success",
42 MessageLevel::Info => "Info",
43 MessageLevel::Trace => "Trace",
44 }
45 }
46
47 fn as_rank(self) -> i8 {
48 match self {
49 MessageLevel::Error => 0,
50 MessageLevel::Warning => 1,
51 MessageLevel::Success => 2,
52 MessageLevel::Info => 3,
53 MessageLevel::Trace => 4,
54 }
55 }
56
57 pub fn as_env_str(self) -> &'static str {
58 match self {
59 MessageLevel::Error => "error",
60 MessageLevel::Warning => "warning",
61 MessageLevel::Success => "success",
62 MessageLevel::Info => "info",
63 MessageLevel::Trace => "trace",
64 }
65 }
66
67 fn from_rank(rank: i8) -> Self {
68 match rank {
69 i8::MIN..=0 => MessageLevel::Error,
70 1 => MessageLevel::Warning,
71 2 => MessageLevel::Success,
72 3 => MessageLevel::Info,
73 _ => MessageLevel::Trace,
74 }
75 }
76
77 fn style_token(self) -> StyleToken {
78 match self {
79 MessageLevel::Error => StyleToken::MessageError,
80 MessageLevel::Warning => StyleToken::MessageWarning,
81 MessageLevel::Success => StyleToken::MessageSuccess,
82 MessageLevel::Info => StyleToken::MessageInfo,
83 MessageLevel::Trace => StyleToken::MessageTrace,
84 }
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum MessageLayout {
90 Minimal,
91 Grouped,
92}
93
94impl MessageLayout {
95 pub fn parse(value: &str) -> Option<Self> {
96 match value.trim().to_ascii_lowercase().as_str() {
97 "minimal" => Some(Self::Minimal),
98 "grouped" => Some(Self::Grouped),
99 _ => None,
100 }
101 }
102}
103
104#[derive(Debug, Clone)]
105pub struct UiMessage {
106 pub level: MessageLevel,
107 pub text: String,
108}
109
110#[derive(Debug, Clone, Default)]
111pub struct MessageBuffer {
112 entries: Vec<UiMessage>,
113}
114
115#[derive(Debug, Clone)]
116pub struct GroupedRenderOptions<'a> {
117 pub max_level: MessageLevel,
118 pub color: bool,
119 pub unicode: bool,
120 pub width: Option<usize>,
121 pub theme: &'a ThemeDefinition,
122 pub layout: MessageLayout,
123 pub chrome_frame: SectionFrameStyle,
124 pub style_overrides: StyleOverrides,
125}
126
127impl MessageBuffer {
128 pub fn push<T: Into<String>>(&mut self, level: MessageLevel, text: T) {
129 self.entries.push(UiMessage {
130 level,
131 text: text.into(),
132 });
133 }
134
135 pub fn error<T: Into<String>>(&mut self, text: T) {
136 self.push(MessageLevel::Error, text);
137 }
138
139 pub fn warning<T: Into<String>>(&mut self, text: T) {
140 self.push(MessageLevel::Warning, text);
141 }
142
143 pub fn success<T: Into<String>>(&mut self, text: T) {
144 self.push(MessageLevel::Success, text);
145 }
146
147 pub fn info<T: Into<String>>(&mut self, text: T) {
148 self.push(MessageLevel::Info, text);
149 }
150
151 pub fn trace<T: Into<String>>(&mut self, text: T) {
152 self.push(MessageLevel::Trace, text);
153 }
154
155 pub fn is_empty(&self) -> bool {
156 self.entries.is_empty()
157 }
158
159 fn entries_for_level(&self, level: MessageLevel) -> impl Iterator<Item = &UiMessage> {
160 self.entries
161 .iter()
162 .filter(move |entry| entry.level == level)
163 }
164
165 pub fn render_grouped(&self, max_level: MessageLevel) -> String {
166 let theme = crate::ui::theme::resolve_theme("plain");
167 self.render_grouped_styled(
168 max_level,
169 false,
170 false,
171 None,
172 &theme,
173 MessageLayout::Grouped,
174 )
175 }
176
177 pub fn render_grouped_styled(
178 &self,
179 max_level: MessageLevel,
180 color: bool,
181 unicode: bool,
182 width: Option<usize>,
183 theme: &ThemeDefinition,
184 layout: MessageLayout,
185 ) -> String {
186 self.render_grouped_with_options(GroupedRenderOptions {
187 max_level,
188 color,
189 unicode,
190 width,
191 theme,
192 layout,
193 chrome_frame: default_message_chrome_frame(layout),
194 style_overrides: StyleOverrides::default(),
195 })
196 }
197
198 pub fn render_grouped_with_options(&self, options: GroupedRenderOptions<'_>) -> String {
199 if matches!(options.layout, MessageLayout::Grouped) {
200 return self.render_grouped_sections(&options);
201 }
202
203 let document = self.build_minimal_document(options.max_level);
204 if document.blocks.is_empty() {
205 return String::new();
206 }
207
208 let resolved = ResolvedRenderSettings {
209 backend: if options.color || options.unicode {
210 RenderBackend::Rich
211 } else {
212 RenderBackend::Plain
213 },
214 color: options.color,
215 unicode: options.unicode,
216 width: options.width,
217 margin: 0,
218 indent_size: 2,
219 short_list_max: 1,
220 medium_list_max: 5,
221 grid_padding: 4,
222 grid_columns: None,
223 column_weight: 3,
224 table_overflow: crate::ui::TableOverflow::Clip,
225 table_border: crate::ui::TableBorderStyle::Square,
226 theme_name: options.theme.id.clone(),
227 theme: options.theme.clone(),
228 style_overrides: options.style_overrides,
229 chrome_frame: options.chrome_frame,
230 };
231 render_document(&document, resolved)
232 }
233
234 fn render_grouped_sections(&self, options: &GroupedRenderOptions<'_>) -> String {
235 let mut sections = Vec::new();
236
237 for level in MessageLevel::ordered().filter(|level| *level <= options.max_level) {
238 let mut entries = self.entries_for_level(level).peekable();
239 if entries.peek().is_none() {
240 continue;
241 }
242
243 let body = entries
244 .map(|entry| {
245 render_inline(
246 &format!("- {}", entry.text),
247 options.color,
248 options.theme,
249 &options.style_overrides,
250 )
251 })
252 .collect::<Vec<_>>()
253 .join("\n");
254
255 sections.push(render_section_block_with_overrides(
256 level.title(),
257 &body,
258 options.chrome_frame,
259 options.unicode,
260 options.width,
261 SectionRenderContext {
262 color: options.color,
263 theme: options.theme,
264 style_overrides: &options.style_overrides,
265 },
266 SectionStyleTokens::same(level.style_token()),
267 ));
268 }
269
270 if sections.is_empty() {
271 return String::new();
272 }
273
274 let mut out = sections.join("\n\n");
275 if !out.ends_with('\n') {
276 out.push('\n');
277 }
278 out
279 }
280
281 fn build_minimal_document(&self, max_level: MessageLevel) -> Document {
282 let mut blocks = Vec::new();
283
284 for level in MessageLevel::ordered().filter(|level| *level <= max_level) {
285 for entry in self.entries_for_level(level) {
286 blocks.push(Block::Line(LineBlock {
287 parts: vec![
288 LinePart {
289 text: format!("{}: ", level.as_env_str()),
290 token: Some(level.style_token()),
291 },
292 LinePart {
293 text: entry.text.clone(),
294 token: None,
295 },
296 ],
297 }));
298 }
299 }
300
301 Document { blocks }
302 }
303}
304
305fn default_message_chrome_frame(layout: MessageLayout) -> SectionFrameStyle {
306 match layout {
307 MessageLayout::Minimal => SectionFrameStyle::None,
308 MessageLayout::Grouped => SectionFrameStyle::TopBottom,
309 }
310}
311
312pub fn adjust_verbosity(base: MessageLevel, verbose: u8, quiet: u8) -> MessageLevel {
313 let rank = base.as_rank() + verbose as i8 - quiet as i8;
314 MessageLevel::from_rank(rank)
315}
316
317#[cfg(test)]
318mod tests {
319 use super::{
320 GroupedRenderOptions, MessageBuffer, MessageLayout, MessageLevel, adjust_verbosity,
321 };
322 use crate::ui::chrome::SectionFrameStyle;
323
324 #[test]
325 fn default_success_hides_info_and_debug() {
326 let mut messages = MessageBuffer::default();
327 messages.error("bad");
328 messages.warning("careful");
329 messages.success("done");
330 messages.info("hint");
331 messages.trace("trace");
332
333 let rendered = messages.render_grouped(MessageLevel::Success);
334 assert!(rendered.contains("Errors"));
335 assert!(rendered.contains("Warnings"));
336 assert!(rendered.contains("Success"));
337 assert!(!rendered.contains("Info"));
338 assert!(!rendered.contains("Trace"));
339 }
340
341 #[test]
342 fn styled_render_uses_boxed_headers() {
343 let mut messages = MessageBuffer::default();
344 messages.error("bad");
345 let theme = crate::ui::theme::resolve_theme("rose-pine-moon");
346 let rendered = messages.render_grouped_styled(
347 MessageLevel::Error,
348 false,
349 true,
350 Some(24),
351 &theme,
352 MessageLayout::Grouped,
353 );
354 assert!(rendered.contains("─ Errors "));
355 assert!(
356 rendered
357 .lines()
358 .any(|line| line.trim().chars().all(|ch| ch == '─'))
359 );
360 }
361
362 #[test]
363 fn styled_render_color_toggle_controls_ansi() {
364 let mut messages = MessageBuffer::default();
365 messages.warning("careful");
366 let theme = crate::ui::theme::resolve_theme("rose-pine-moon");
367
368 let plain = messages.render_grouped_styled(
369 MessageLevel::Warning,
370 false,
371 false,
372 Some(28),
373 &theme,
374 MessageLayout::Grouped,
375 );
376 let colored = messages.render_grouped_styled(
377 MessageLevel::Warning,
378 true,
379 false,
380 Some(28),
381 &theme,
382 MessageLayout::Grouped,
383 );
384 assert!(!plain.contains("\x1b["));
385 assert!(colored.contains("\x1b["));
386 }
387
388 #[test]
389 fn minimal_render_flattens_messages_with_level_prefixes_unit() {
390 let mut messages = MessageBuffer::default();
391 messages.error("bad");
392 messages.warning("careful");
393 messages.info("hint");
394 let theme = crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME);
395
396 let rendered = messages.render_grouped_styled(
397 MessageLevel::Info,
398 false,
399 false,
400 Some(28),
401 &theme,
402 MessageLayout::Minimal,
403 );
404
405 assert!(rendered.contains("error: bad"));
406 assert!(rendered.contains("warning: careful"));
407 assert!(rendered.contains("info: hint"));
408 assert!(!rendered.contains("Errors"));
409 assert!(!rendered.contains("- bad"));
410 }
411
412 #[test]
413 fn minimal_render_matches_plain_snapshot_unit() {
414 let mut messages = MessageBuffer::default();
415 messages.error("bad");
416 messages.warning("careful");
417 messages.info("hint");
418 let theme = crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME);
419
420 let rendered = messages.render_grouped_styled(
421 MessageLevel::Info,
422 false,
423 false,
424 Some(18),
425 &theme,
426 MessageLayout::Minimal,
427 );
428
429 assert_eq!(rendered, "error: bad\nwarning: careful\ninfo: hint\n");
430 }
431
432 #[test]
433 fn grouped_render_matches_ascii_rule_snapshot_unit() {
434 let mut messages = MessageBuffer::default();
435 messages.error("bad");
436 messages.warning("careful");
437 let theme = crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME);
438
439 let rendered = messages.render_grouped_with_options(GroupedRenderOptions {
440 max_level: MessageLevel::Warning,
441 color: false,
442 unicode: false,
443 width: Some(18),
444 theme: &theme,
445 layout: MessageLayout::Grouped,
446 chrome_frame: SectionFrameStyle::TopBottom,
447 style_overrides: crate::ui::style::StyleOverrides::default(),
448 });
449
450 assert_eq!(
451 rendered,
452 "- Errors ---------\n- bad\n------------------\n\n- Warnings -------\n- careful\n------------------\n"
453 );
454 }
455
456 #[test]
457 fn verbosity_adjustment_clamps() {
458 assert_eq!(
459 adjust_verbosity(MessageLevel::Success, 1, 0),
460 MessageLevel::Info
461 );
462 assert_eq!(
463 adjust_verbosity(MessageLevel::Success, 2, 0),
464 MessageLevel::Trace
465 );
466 assert_eq!(
467 adjust_verbosity(MessageLevel::Success, 0, 9),
468 MessageLevel::Error
469 );
470 }
471
472 #[test]
473 fn message_level_helpers_cover_titles_env_and_rank_unit() {
474 assert_eq!(MessageLevel::Error.title(), "Errors");
475 assert_eq!(MessageLevel::Success.as_env_str(), "success");
476 assert_eq!(MessageLevel::from_rank(-1), MessageLevel::Error);
477 assert_eq!(MessageLevel::from_rank(1), MessageLevel::Warning);
478 assert_eq!(MessageLevel::from_rank(9), MessageLevel::Trace);
479 }
480
481 #[test]
482 fn message_layout_parser_and_buffer_helpers_cover_basic_paths_unit() {
483 assert_eq!(
484 MessageLayout::parse("grouped"),
485 Some(MessageLayout::Grouped)
486 );
487 assert_eq!(
488 MessageLayout::parse("minimal"),
489 Some(MessageLayout::Minimal)
490 );
491 assert_eq!(MessageLayout::parse("dense"), None);
492
493 let mut messages = MessageBuffer::default();
494 assert!(messages.is_empty());
495 messages.error("bad");
496 messages.success("ok");
497 messages.trace("trace");
498 assert!(!messages.is_empty());
499 assert!(
500 messages
501 .render_grouped(MessageLevel::Success)
502 .contains("Success")
503 );
504 }
505}