Skip to main content

osp_cli/ui/
messages.rs

1//! Buffered user-facing messages and their rendering helpers.
2//!
3//! This module exists so command execution can collect messages without
4//! deciding immediately how they should be shown. Callers push semantic levels
5//! into a [`MessageBuffer`], and the UI later renders that buffer in minimal or
6//! grouped form using the active theme and terminal settings.
7//!
8//! Contract:
9//!
10//! - this module owns message grouping and severity presentation
11//! - it should not own command execution or logging backends
12//! - callers should treat `MessageLevel` as user-facing severity, not as a
13//!   tracing subsystem
14
15use 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/// Severity level for buffered UI messages.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
37pub enum MessageLevel {
38    /// Error output that should remain visible at every verbosity.
39    Error,
40    /// Warning output for degraded or surprising behavior.
41    Warning,
42    /// Success output for completed operations.
43    Success,
44    /// Informational output for normal command progress.
45    Info,
46    /// Trace or debug-style output.
47    Trace,
48}
49
50impl MessageLevel {
51    /// Parses the message-level spellings accepted by config and environment
52    /// inputs.
53    ///
54    /// # Examples
55    ///
56    /// ```
57    /// use osp_cli::ui::MessageLevel;
58    ///
59    /// assert_eq!(MessageLevel::parse("warn"), Some(MessageLevel::Warning));
60    /// assert_eq!(MessageLevel::parse(" INFO "), Some(MessageLevel::Info));
61    /// assert_eq!(MessageLevel::parse("wat"), None);
62    /// ```
63    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    /// Returns the section title used for grouped rendering.
79    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    /// Returns the lowercase identifier used in environment-style output.
100    ///
101    /// # Examples
102    ///
103    /// ```
104    /// use osp_cli::ui::MessageLevel;
105    ///
106    /// assert_eq!(MessageLevel::Warning.as_env_str(), "warning");
107    /// ```
108    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/// Layout style used when rendering buffered messages.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum MessageLayout {
142    /// Render each message inline without grouped section chrome.
143    Minimal,
144    /// Group messages by severity and render section chrome.
145    Grouped,
146}
147
148impl MessageLayout {
149    /// Parses the message layout spellings accepted by configuration.
150    ///
151    /// # Examples
152    ///
153    /// ```
154    /// use osp_cli::ui::MessageLayout;
155    ///
156    /// assert_eq!(MessageLayout::parse("minimal"), Some(MessageLayout::Minimal));
157    /// assert_eq!(MessageLayout::parse("GROUPED"), Some(MessageLayout::Grouped));
158    /// assert_eq!(MessageLayout::parse("dense"), None);
159    /// ```
160    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/// A single UI message with its associated severity.
170#[derive(Debug, Clone)]
171pub struct UiMessage {
172    /// Severity assigned to the message.
173    pub level: MessageLevel,
174    /// Renderable message text.
175    pub text: String,
176}
177
178/// In-memory buffer for messages collected during command execution.
179#[derive(Debug, Clone, Default)]
180pub struct MessageBuffer {
181    entries: Vec<UiMessage>,
182}
183
184/// Options for rendering grouped message output.
185#[derive(Debug, Clone)]
186pub struct GroupedRenderOptions<'a> {
187    /// Highest message level that should be included in the output.
188    pub max_level: MessageLevel,
189    /// Whether ANSI color output is enabled.
190    pub color: bool,
191    /// Whether Unicode box-drawing and symbols are enabled.
192    pub unicode: bool,
193    /// Optional output width constraint.
194    pub width: Option<usize>,
195    /// Active theme used for semantic styling.
196    pub theme: &'a ThemeDefinition,
197    /// Message layout mode.
198    pub layout: MessageLayout,
199    /// Frame style used for grouped section chrome.
200    pub chrome_frame: SectionFrameStyle,
201    /// Explicit semantic style overrides layered above the theme.
202    pub style_overrides: StyleOverrides,
203}
204
205impl MessageBuffer {
206    /// Appends a message to the buffer.
207    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    /// Appends an error message to the buffer.
215    pub fn error<T: Into<String>>(&mut self, text: T) {
216        self.push(MessageLevel::Error, text);
217    }
218
219    /// Appends a warning message to the buffer.
220    pub fn warning<T: Into<String>>(&mut self, text: T) {
221        self.push(MessageLevel::Warning, text);
222    }
223
224    /// Appends a success message to the buffer.
225    pub fn success<T: Into<String>>(&mut self, text: T) {
226        self.push(MessageLevel::Success, text);
227    }
228
229    /// Appends an informational message to the buffer.
230    pub fn info<T: Into<String>>(&mut self, text: T) {
231        self.push(MessageLevel::Info, text);
232    }
233
234    /// Appends a trace message to the buffer.
235    pub fn trace<T: Into<String>>(&mut self, text: T) {
236        self.push(MessageLevel::Trace, text);
237    }
238
239    /// Returns `true` when the buffer contains no messages.
240    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    /// Renders messages with the default plain grouped layout.
251    ///
252    /// # Examples
253    ///
254    /// ```
255    /// use osp_cli::ui::{MessageBuffer, MessageLevel};
256    ///
257    /// let mut messages = MessageBuffer::default();
258    /// messages.error("bad");
259    /// messages.success("done");
260    ///
261    /// let rendered = messages.render_grouped(MessageLevel::Success);
262    ///
263    /// assert!(rendered.contains("Errors"));
264    /// assert!(rendered.contains("- bad"));
265    /// assert!(rendered.contains("Success"));
266    /// ```
267    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    /// Renders messages with explicit theme and layout settings.
280    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    /// Renders messages using a preassembled options struct.
302    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
417/// Adjusts a base verbosity level using `-v` and `-q` style counts.
418///
419/// # Examples
420///
421/// ```
422/// use osp_cli::ui::{MessageLevel, adjust_verbosity};
423///
424/// assert_eq!(adjust_verbosity(MessageLevel::Success, 1, 0), MessageLevel::Info);
425/// assert_eq!(adjust_verbosity(MessageLevel::Success, 0, 9), MessageLevel::Error);
426/// ```
427pub 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}