1use crate::ui::document::{Block, Document, LineBlock, LinePart};
16use crate::ui::inline::render_inline;
17use crate::ui::renderer::render_document;
18use crate::ui::style::{StyleOverrides, StyleToken};
19use crate::ui::theme::ThemeDefinition;
20use crate::ui::{RenderBackend, ResolvedRenderSettings};
21
22use crate::ui::chrome::{
23 SectionFrameStyle, SectionRenderContext, SectionStyleTokens,
24 render_section_block_with_overrides,
25};
26
27const ORDERED_MESSAGE_LEVELS: [MessageLevel; 5] = [
28 MessageLevel::Error,
29 MessageLevel::Warning,
30 MessageLevel::Success,
31 MessageLevel::Info,
32 MessageLevel::Trace,
33];
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
37pub enum MessageLevel {
38 Error,
40 Warning,
42 Success,
44 Info,
46 Trace,
48}
49
50impl MessageLevel {
51 pub fn parse(value: &str) -> Option<Self> {
64 match value.trim().to_ascii_lowercase().as_str() {
65 "error" => Some(MessageLevel::Error),
66 "warning" | "warn" => Some(MessageLevel::Warning),
67 "success" => Some(MessageLevel::Success),
68 "info" => Some(MessageLevel::Info),
69 "trace" => Some(MessageLevel::Trace),
70 _ => None,
71 }
72 }
73
74 fn ordered() -> impl Iterator<Item = Self> {
75 ORDERED_MESSAGE_LEVELS.into_iter()
76 }
77
78 pub fn title(self) -> &'static str {
80 match self {
81 MessageLevel::Error => "Errors",
82 MessageLevel::Warning => "Warnings",
83 MessageLevel::Success => "Success",
84 MessageLevel::Info => "Info",
85 MessageLevel::Trace => "Trace",
86 }
87 }
88
89 fn as_rank(self) -> i8 {
90 match self {
91 MessageLevel::Error => 0,
92 MessageLevel::Warning => 1,
93 MessageLevel::Success => 2,
94 MessageLevel::Info => 3,
95 MessageLevel::Trace => 4,
96 }
97 }
98
99 pub fn as_env_str(self) -> &'static str {
109 match self {
110 MessageLevel::Error => "error",
111 MessageLevel::Warning => "warning",
112 MessageLevel::Success => "success",
113 MessageLevel::Info => "info",
114 MessageLevel::Trace => "trace",
115 }
116 }
117
118 fn from_rank(rank: i8) -> Self {
119 match rank {
120 i8::MIN..=0 => MessageLevel::Error,
121 1 => MessageLevel::Warning,
122 2 => MessageLevel::Success,
123 3 => MessageLevel::Info,
124 _ => MessageLevel::Trace,
125 }
126 }
127
128 fn style_token(self) -> StyleToken {
129 match self {
130 MessageLevel::Error => StyleToken::MessageError,
131 MessageLevel::Warning => StyleToken::MessageWarning,
132 MessageLevel::Success => StyleToken::MessageSuccess,
133 MessageLevel::Info => StyleToken::MessageInfo,
134 MessageLevel::Trace => StyleToken::MessageTrace,
135 }
136 }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum MessageLayout {
142 Minimal,
144 Grouped,
146}
147
148impl MessageLayout {
149 pub fn parse(value: &str) -> Option<Self> {
161 match value.trim().to_ascii_lowercase().as_str() {
162 "minimal" => Some(Self::Minimal),
163 "grouped" => Some(Self::Grouped),
164 _ => None,
165 }
166 }
167}
168
169#[derive(Debug, Clone)]
171pub struct UiMessage {
172 pub level: MessageLevel,
174 pub text: String,
176}
177
178#[derive(Debug, Clone, Default)]
180pub struct MessageBuffer {
181 entries: Vec<UiMessage>,
182}
183
184#[derive(Debug, Clone)]
186pub struct GroupedRenderOptions<'a> {
187 pub max_level: MessageLevel,
189 pub color: bool,
191 pub unicode: bool,
193 pub width: Option<usize>,
195 pub theme: &'a ThemeDefinition,
197 pub layout: MessageLayout,
199 pub chrome_frame: SectionFrameStyle,
201 pub style_overrides: StyleOverrides,
203}
204
205impl MessageBuffer {
206 pub fn push<T: Into<String>>(&mut self, level: MessageLevel, text: T) {
208 self.entries.push(UiMessage {
209 level,
210 text: text.into(),
211 });
212 }
213
214 pub fn error<T: Into<String>>(&mut self, text: T) {
216 self.push(MessageLevel::Error, text);
217 }
218
219 pub fn warning<T: Into<String>>(&mut self, text: T) {
221 self.push(MessageLevel::Warning, text);
222 }
223
224 pub fn success<T: Into<String>>(&mut self, text: T) {
226 self.push(MessageLevel::Success, text);
227 }
228
229 pub fn info<T: Into<String>>(&mut self, text: T) {
231 self.push(MessageLevel::Info, text);
232 }
233
234 pub fn trace<T: Into<String>>(&mut self, text: T) {
236 self.push(MessageLevel::Trace, text);
237 }
238
239 pub fn is_empty(&self) -> bool {
241 self.entries.is_empty()
242 }
243
244 fn entries_for_level(&self, level: MessageLevel) -> impl Iterator<Item = &UiMessage> {
245 self.entries
246 .iter()
247 .filter(move |entry| entry.level == level)
248 }
249
250 pub fn render_grouped(&self, max_level: MessageLevel) -> String {
268 let theme = crate::ui::theme::resolve_theme("plain");
269 self.render_grouped_styled(
270 max_level,
271 false,
272 false,
273 None,
274 &theme,
275 MessageLayout::Grouped,
276 )
277 }
278
279 pub fn render_grouped_styled(
281 &self,
282 max_level: MessageLevel,
283 color: bool,
284 unicode: bool,
285 width: Option<usize>,
286 theme: &ThemeDefinition,
287 layout: MessageLayout,
288 ) -> String {
289 self.render_grouped_with_options(GroupedRenderOptions {
290 max_level,
291 color,
292 unicode,
293 width,
294 theme,
295 layout,
296 chrome_frame: default_message_chrome_frame(layout),
297 style_overrides: StyleOverrides::default(),
298 })
299 }
300
301 pub fn render_grouped_with_options(&self, options: GroupedRenderOptions<'_>) -> String {
303 if matches!(options.layout, MessageLayout::Grouped) {
304 return self.render_grouped_sections(&options);
305 }
306
307 let document = self.build_minimal_document(options.max_level);
308 if document.blocks.is_empty() {
309 return String::new();
310 }
311
312 let resolved = ResolvedRenderSettings {
313 backend: if options.color || options.unicode {
314 RenderBackend::Rich
315 } else {
316 RenderBackend::Plain
317 },
318 color: options.color,
319 unicode: options.unicode,
320 width: options.width,
321 margin: 0,
322 indent_size: 2,
323 short_list_max: 1,
324 medium_list_max: 5,
325 grid_padding: 4,
326 grid_columns: None,
327 column_weight: 3,
328 table_overflow: crate::ui::TableOverflow::Clip,
329 table_border: crate::ui::TableBorderStyle::Square,
330 help_table_border: crate::ui::TableBorderStyle::None,
331 theme_name: options.theme.id.clone(),
332 theme: options.theme.clone(),
333 style_overrides: options.style_overrides,
334 chrome_frame: options.chrome_frame,
335 };
336 render_document(&document, resolved)
337 }
338
339 fn render_grouped_sections(&self, options: &GroupedRenderOptions<'_>) -> String {
340 let mut sections = Vec::new();
341
342 for level in MessageLevel::ordered().filter(|level| *level <= options.max_level) {
343 let mut entries = self.entries_for_level(level).peekable();
344 if entries.peek().is_none() {
345 continue;
346 }
347
348 let body = entries
349 .map(|entry| {
350 render_inline(
351 &format!("- {}", entry.text),
352 options.color,
353 options.theme,
354 &options.style_overrides,
355 )
356 })
357 .collect::<Vec<_>>()
358 .join("\n");
359
360 sections.push(render_section_block_with_overrides(
361 level.title(),
362 &body,
363 options.chrome_frame,
364 options.unicode,
365 options.width,
366 SectionRenderContext {
367 color: options.color,
368 theme: options.theme,
369 style_overrides: &options.style_overrides,
370 },
371 SectionStyleTokens::same(level.style_token()),
372 ));
373 }
374
375 if sections.is_empty() {
376 return String::new();
377 }
378
379 let mut out = sections.join("\n\n");
380 if !out.ends_with('\n') {
381 out.push('\n');
382 }
383 out
384 }
385
386 fn build_minimal_document(&self, max_level: MessageLevel) -> Document {
387 let mut blocks = Vec::new();
388
389 for level in MessageLevel::ordered().filter(|level| *level <= max_level) {
390 for entry in self.entries_for_level(level) {
391 blocks.push(Block::Line(LineBlock {
392 parts: vec![
393 LinePart {
394 text: format!("{}: ", level.as_env_str()),
395 token: Some(level.style_token()),
396 },
397 LinePart {
398 text: entry.text.clone(),
399 token: None,
400 },
401 ],
402 }));
403 }
404 }
405
406 Document { blocks }
407 }
408}
409
410fn default_message_chrome_frame(layout: MessageLayout) -> SectionFrameStyle {
411 match layout {
412 MessageLayout::Minimal => SectionFrameStyle::None,
413 MessageLayout::Grouped => SectionFrameStyle::TopBottom,
414 }
415}
416
417pub fn adjust_verbosity(base: MessageLevel, verbose: u8, quiet: u8) -> MessageLevel {
428 let rank = base.as_rank() + verbose as i8 - quiet as i8;
429 MessageLevel::from_rank(rank)
430}
431
432#[cfg(test)]
433mod tests {
434 use super::{
435 GroupedRenderOptions, MessageBuffer, MessageLayout, MessageLevel, adjust_verbosity,
436 };
437 use crate::ui::chrome::SectionFrameStyle;
438
439 #[test]
440 fn default_success_hides_info_and_debug() {
441 let mut messages = MessageBuffer::default();
442 messages.error("bad");
443 messages.warning("careful");
444 messages.success("done");
445 messages.info("hint");
446 messages.trace("trace");
447
448 let rendered = messages.render_grouped(MessageLevel::Success);
449 assert!(rendered.contains("Errors"));
450 assert!(rendered.contains("Warnings"));
451 assert!(rendered.contains("Success"));
452 assert!(!rendered.contains("Info"));
453 assert!(!rendered.contains("Trace"));
454 }
455
456 #[test]
457 fn styled_render_uses_boxed_headers() {
458 let mut messages = MessageBuffer::default();
459 messages.error("bad");
460 let theme = crate::ui::theme::resolve_theme("rose-pine-moon");
461 let rendered = messages.render_grouped_styled(
462 MessageLevel::Error,
463 false,
464 true,
465 Some(24),
466 &theme,
467 MessageLayout::Grouped,
468 );
469 assert!(rendered.contains("─ Errors "));
470 assert!(
471 rendered
472 .lines()
473 .any(|line| line.trim().chars().all(|ch| ch == '─'))
474 );
475 }
476
477 #[test]
478 fn styled_render_color_toggle_controls_ansi() {
479 let mut messages = MessageBuffer::default();
480 messages.warning("careful");
481 let theme = crate::ui::theme::resolve_theme("rose-pine-moon");
482
483 let plain = messages.render_grouped_styled(
484 MessageLevel::Warning,
485 false,
486 false,
487 Some(28),
488 &theme,
489 MessageLayout::Grouped,
490 );
491 let colored = messages.render_grouped_styled(
492 MessageLevel::Warning,
493 true,
494 false,
495 Some(28),
496 &theme,
497 MessageLayout::Grouped,
498 );
499 assert!(!plain.contains("\x1b["));
500 assert!(colored.contains("\x1b["));
501 }
502
503 #[test]
504 fn minimal_render_flattens_messages_with_level_prefixes_unit() {
505 let mut messages = MessageBuffer::default();
506 messages.error("bad");
507 messages.warning("careful");
508 messages.info("hint");
509 let theme = crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME);
510
511 let rendered = messages.render_grouped_styled(
512 MessageLevel::Info,
513 false,
514 false,
515 Some(28),
516 &theme,
517 MessageLayout::Minimal,
518 );
519
520 assert!(rendered.contains("error: bad"));
521 assert!(rendered.contains("warning: careful"));
522 assert!(rendered.contains("info: hint"));
523 assert!(!rendered.contains("Errors"));
524 assert!(!rendered.contains("- bad"));
525 }
526
527 #[test]
528 fn minimal_render_matches_plain_snapshot_unit() {
529 let mut messages = MessageBuffer::default();
530 messages.error("bad");
531 messages.warning("careful");
532 messages.info("hint");
533 let theme = crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME);
534
535 let rendered = messages.render_grouped_styled(
536 MessageLevel::Info,
537 false,
538 false,
539 Some(18),
540 &theme,
541 MessageLayout::Minimal,
542 );
543
544 assert_eq!(rendered, "error: bad\nwarning: careful\ninfo: hint\n");
545 }
546
547 #[test]
548 fn grouped_render_matches_ascii_rule_snapshot_unit() {
549 let mut messages = MessageBuffer::default();
550 messages.error("bad");
551 messages.warning("careful");
552 let theme = crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME);
553
554 let rendered = messages.render_grouped_with_options(GroupedRenderOptions {
555 max_level: MessageLevel::Warning,
556 color: false,
557 unicode: false,
558 width: Some(18),
559 theme: &theme,
560 layout: MessageLayout::Grouped,
561 chrome_frame: SectionFrameStyle::TopBottom,
562 style_overrides: crate::ui::style::StyleOverrides::default(),
563 });
564
565 assert_eq!(
566 rendered,
567 "- Errors ---------\n- bad\n------------------\n\n- Warnings -------\n- careful\n------------------\n"
568 );
569 }
570
571 #[test]
572 fn verbosity_adjustment_clamps() {
573 assert_eq!(
574 adjust_verbosity(MessageLevel::Success, 1, 0),
575 MessageLevel::Info
576 );
577 assert_eq!(
578 adjust_verbosity(MessageLevel::Success, 2, 0),
579 MessageLevel::Trace
580 );
581 assert_eq!(
582 adjust_verbosity(MessageLevel::Success, 0, 9),
583 MessageLevel::Error
584 );
585 }
586
587 #[test]
588 fn message_level_helpers_cover_titles_env_and_rank_unit() {
589 assert_eq!(MessageLevel::Error.title(), "Errors");
590 assert_eq!(MessageLevel::Success.as_env_str(), "success");
591 assert_eq!(MessageLevel::from_rank(-1), MessageLevel::Error);
592 assert_eq!(MessageLevel::from_rank(1), MessageLevel::Warning);
593 assert_eq!(MessageLevel::from_rank(9), MessageLevel::Trace);
594 }
595
596 #[test]
597 fn message_layout_parser_and_buffer_helpers_cover_basic_paths_unit() {
598 assert_eq!(
599 MessageLayout::parse("grouped"),
600 Some(MessageLayout::Grouped)
601 );
602 assert_eq!(
603 MessageLayout::parse("minimal"),
604 Some(MessageLayout::Minimal)
605 );
606 assert_eq!(MessageLayout::parse("dense"), None);
607
608 let mut messages = MessageBuffer::default();
609 assert!(messages.is_empty());
610 messages.error("bad");
611 messages.success("ok");
612 messages.trace("trace");
613 assert!(!messages.is_empty());
614 assert!(
615 messages
616 .render_grouped(MessageLevel::Success)
617 .contains("Success")
618 );
619 }
620}