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 grouped_render_variants_cover_visibility_headers_and_color_toggles_unit() {
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 grouped = messages.render_grouped(MessageLevel::Success);
449 assert!(grouped.contains("Errors"));
450 assert!(grouped.contains("Warnings"));
451 assert!(grouped.contains("Success"));
452 assert!(!grouped.contains("Info"));
453 assert!(!grouped.contains("Trace"));
454
455 let theme = crate::ui::theme::resolve_theme("rose-pine-moon");
456 let boxed = messages.render_grouped_styled(
457 MessageLevel::Error,
458 false,
459 true,
460 Some(24),
461 &theme,
462 MessageLayout::Grouped,
463 );
464 assert!(boxed.contains("─ Errors "));
465 assert!(
466 boxed
467 .lines()
468 .any(|line| line.trim().chars().all(|ch| ch == '─'))
469 );
470
471 let plain = messages.render_grouped_styled(
472 MessageLevel::Warning,
473 false,
474 false,
475 Some(28),
476 &theme,
477 MessageLayout::Grouped,
478 );
479 let colored = messages.render_grouped_styled(
480 MessageLevel::Warning,
481 true,
482 false,
483 Some(28),
484 &theme,
485 MessageLayout::Grouped,
486 );
487 assert!(!plain.contains("\x1b["));
488 assert!(colored.contains("\x1b["));
489 }
490
491 #[test]
492 fn minimal_render_flattens_with_prefixes_and_stable_plain_output_unit() {
493 let mut messages = MessageBuffer::default();
494 messages.error("bad");
495 messages.warning("careful");
496 messages.info("hint");
497 let theme = crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME);
498
499 let rendered = messages.render_grouped_styled(
500 MessageLevel::Info,
501 false,
502 false,
503 Some(28),
504 &theme,
505 MessageLayout::Minimal,
506 );
507
508 assert!(rendered.contains("error: bad"));
509 assert!(rendered.contains("warning: careful"));
510 assert!(rendered.contains("info: hint"));
511 assert!(!rendered.contains("Errors"));
512 assert!(!rendered.contains("- bad"));
513
514 let narrow = messages.render_grouped_styled(
515 MessageLevel::Info,
516 false,
517 false,
518 Some(18),
519 &theme,
520 MessageLayout::Minimal,
521 );
522 assert_eq!(narrow, "error: bad\nwarning: careful\ninfo: hint\n");
523 }
524
525 #[test]
526 fn grouped_render_matches_ascii_rule_snapshot_unit() {
527 let mut messages = MessageBuffer::default();
528 messages.error("bad");
529 messages.warning("careful");
530 let theme = crate::ui::theme::resolve_theme(crate::ui::theme::DEFAULT_THEME_NAME);
531
532 let rendered = messages.render_grouped_with_options(GroupedRenderOptions {
533 max_level: MessageLevel::Warning,
534 color: false,
535 unicode: false,
536 width: Some(18),
537 theme: &theme,
538 layout: MessageLayout::Grouped,
539 chrome_frame: SectionFrameStyle::TopBottom,
540 style_overrides: crate::ui::style::StyleOverrides::default(),
541 });
542
543 assert_eq!(
544 rendered,
545 "- Errors ---------\n- bad\n------------------\n\n- Warnings -------\n- careful\n------------------\n"
546 );
547 }
548
549 #[test]
550 fn message_helper_paths_cover_verbosity_levels_layout_and_buffer_basics_unit() {
551 assert_eq!(
552 adjust_verbosity(MessageLevel::Success, 1, 0),
553 MessageLevel::Info
554 );
555 assert_eq!(
556 adjust_verbosity(MessageLevel::Success, 2, 0),
557 MessageLevel::Trace
558 );
559 assert_eq!(
560 adjust_verbosity(MessageLevel::Success, 0, 9),
561 MessageLevel::Error
562 );
563
564 assert_eq!(MessageLevel::Error.title(), "Errors");
565 assert_eq!(MessageLevel::Success.as_env_str(), "success");
566 assert_eq!(MessageLevel::from_rank(-1), MessageLevel::Error);
567 assert_eq!(MessageLevel::from_rank(1), MessageLevel::Warning);
568 assert_eq!(MessageLevel::from_rank(9), MessageLevel::Trace);
569
570 assert_eq!(
571 MessageLayout::parse("grouped"),
572 Some(MessageLayout::Grouped)
573 );
574 assert_eq!(
575 MessageLayout::parse("minimal"),
576 Some(MessageLayout::Minimal)
577 );
578 assert_eq!(MessageLayout::parse("dense"), None);
579
580 let mut messages = MessageBuffer::default();
581 assert!(messages.is_empty());
582 messages.error("bad");
583 messages.success("ok");
584 messages.trace("trace");
585 assert!(!messages.is_empty());
586 assert!(
587 messages
588 .render_grouped(MessageLevel::Success)
589 .contains("Success")
590 );
591 }
592}