Skip to main content

osp_cli/ui/
messages.rs

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}