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 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}